From 7af7eae0c92986ef1f90fe31f94cc72690a20e1e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 18 Feb 2021 09:27:40 +0100 Subject: [PATCH 001/251] Add start to wizard --- paths_cli/parsing/tools.py | 20 +++ paths_cli/wizard/core.py | 41 ++++++ paths_cli/wizard/cvs.py | 186 +++++++++++++++++++++++++ paths_cli/wizard/engines.py | 29 ++++ paths_cli/wizard/errors.py | 21 +++ paths_cli/wizard/joke.py | 54 +++++++ paths_cli/wizard/load_from_ops.py | 37 +++++ paths_cli/wizard/openmm.py | 88 ++++++++++++ paths_cli/wizard/tools.py | 17 +++ paths_cli/wizard/volumes.py | 125 +++++++++++++++++ paths_cli/wizard/wizard.py | 224 ++++++++++++++++++++++++++++++ 11 files changed, 842 insertions(+) create mode 100644 paths_cli/parsing/tools.py create mode 100644 paths_cli/wizard/core.py create mode 100644 paths_cli/wizard/cvs.py create mode 100644 paths_cli/wizard/engines.py create mode 100644 paths_cli/wizard/errors.py create mode 100644 paths_cli/wizard/joke.py create mode 100644 paths_cli/wizard/load_from_ops.py create mode 100644 paths_cli/wizard/openmm.py create mode 100644 paths_cli/wizard/tools.py create mode 100644 paths_cli/wizard/volumes.py create mode 100644 paths_cli/wizard/wizard.py diff --git a/paths_cli/parsing/tools.py b/paths_cli/parsing/tools.py new file mode 100644 index 00000000..52970a1d --- /dev/null +++ b/paths_cli/parsing/tools.py @@ -0,0 +1,20 @@ +def custom_eval(obj, named_objs=None): + string = str(obj) + # TODO: check that the only attribute access comes from a whitelist + namespace = { + 'np': __import__('numpy'), + 'math': __import__('math'), + } + return eval(string, namespace) + + +class UnknownAtomsError(RuntimeError): + pass + +def mdtraj_parse_atomlist(inp_str, topology): + # TODO: enable the parsing of either string-like atom labels or numeric + try: + arr = custom_eval(inp_str) + except: + pass # on any error, we do it the hard way + pass diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py new file mode 100644 index 00000000..c1e83343 --- /dev/null +++ b/paths_cli/wizard/core.py @@ -0,0 +1,41 @@ +import random +from joke import name_joke +from tools import a_an + +def name(wizard, obj, obj_type, store_name, default=None): + wizard.say(f"Now let's name your {obj_type}.") + name = None + while name is None: + name = wizard.ask("What do you want to call it?") + if name in getattr(wizard, store_name): + wizard.bad_input(f"Sorry, you already have {a_an(obj_type)} " + f"named {name}. Please try another name.") + name = None + + obj = obj.named(name) + + wizard.say(f"'{name}' is a good name for {a_an(obj_type)} {obj_type}. " + + name_joke(name, obj_type)) + return obj + +def abort_retry_quit(wizard, obj_type): + a_an = 'an' if obj_type[0] in 'aeiou' else 'a' + retry = wizard.ask(f"Do you want to try again to make {a_an} " + f"{obj_type}, do you want to continue without, or " + f"do you want to quit?", + options=["[R]etry", "[C]ontinue", "[Q]uit"]) + if retry == 'q': + exit() + elif retry == 'r': + return + elif retry == 'c': + return "continue" + else: + raise ImpossibleError() + + +def interpret_req(req): + _, num, direction = req + dir_str = {'+': 'at least ', '=': '', '-': 'at most '}[direction] + return dir_str + str(num) + diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py new file mode 100644 index 00000000..713ee25c --- /dev/null +++ b/paths_cli/wizard/cvs.py @@ -0,0 +1,186 @@ +from paths_cli.wizard.engines import engines +from paths_cli.parsing.tools import custom_eval +from paths_cli.wizard.load_from_ops import load_from_ops +from paths_cli.wizard.load_from_ops import LABEL as _load_label +from functools import partial +import numpy as np + +try: + import mdtraj as md +except ImportError: + HAS_MDTRAJ = False +else: + HAS_MDTRAJ = True + +def mdtraj_atom_helper(wizard, user_input, n_atoms): + wizard.say("You should specify atom indices enclosed in double " + "brackets, e.g, [" + str(list(range(n_atoms))) + "]") + # TODO: implement the following: + # wizard.say("You can specify atoms either as atom indices (which count " + # "from zero) or as atom labels of the format " + # "CHAIN:RESIDUE-ATOM, e.g., '0:ALA1-CA' for the alpha carbon " + # "of alanine 1 (this time counting from one, as in the PDB) " + # "of the 0th chain in the topology. You can also use letters " + # "for chain IDs, but note that A corresponds to the first " + # "chain in your topology, even if its name in the PDB file " + # "is B.") + +def _get_topology(wizard): + from paths_cli.wizard.engines import engines + topology = None + while topology is None: + if len(wizard.engines) == 0: + # SHOULD NEVER GET HERE + wizard.say("Hey, you need to define an MD engine before you " + "create CVs that refer to it. Let's do that now!") + engine = engines(wizard) + wizard.register(engine, 'engine', 'engines') + wizard.say("Now let's get back to defining your CV") + elif len(wizard.engines) == 1: + topology = list(wizard.engines.values())[0].topology + else: + engines = list(wizard.engines.keys) + name = wizard.ask("You have defined multiple engines. Which one " + "should I use to define your CV?", + options=engines) + topology = wizard.engines[name].topology + + return topology + +def _get_atom_indices(wizard, topology, cv_type): + # TODO: move the logic here to parsing.tools + n_atoms = {'distance': 2, + 'angle': 3, + 'dihedral': 4}[cv_type] + arr = None + helper = partial(mdtraj_atom_helper, n_atoms=n_atoms) + while arr is None: + atoms_str = wizard.ask("Which atoms do you want to measure the " + "{cv_type} between?", helper=helper) + try: + indices = custom_eval(atoms_str) + except Exception as e: + wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) + mdtraj_atom_helper(wizard, '?', n_atoms) + continue + + try: + arr = np.array(indices) + if arr.dtype != int: + raise TypeError("Input is not integers") + if arr.shape != (1, n_atoms): + # try to clean it up + if len(arr.shape) == 1 and arr.shape[0] == n_atoms: + arr.shape = (1, n_atoms) + else: + raise TypeError(f"Invalid input. Requires {n_atoms} " + "atoms.") + except Exception as e: + wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) + mdtraj_atom_helper(wizard, '?', n_atoms) + arr = None + continue + + if arr is not None: + atom_names = [str(topology.mdtraj.atom(i)) for i in arr[0]] + wizard.say("Using atoms: " + " ".join(atom_names)) + return arr + +def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, + kwarg_name, n_atoms): + from openpathsampling.experimental.storage.collective_variables import \ + MDTrajFunctionCV + wizard.say("We'll make a CV that measures the distance between two " + "atoms.") + topology = _get_topology(wizard) + indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms) + kwargs = {kwarg_name: indices} + + return MDTrajFunctionCV(func, topology, **kwargs) + +def distance(wizard): + return _mdtraj_function_cv( + wizard=wizard, + cv_does_str="distance between two atoms", + cv_user_prompt="measure the distance between", + func=md.compute_distances, + kwarg_name='atom_pairs', + n_atoms=2 + ) + +def angle(wizard): + return _mdtraj_function_cv( + wizard=wizard, + cv_does_str="angle made by three atoms", + cv_user_prompt="use to define the angle", + func=md.compute_angles, + kwarg_name='angle_indices', + n_atoms=3 + ) + +def dihedral(wizard): + return _mdtraj_function_cv( + wizard=wizard, + cv_does_str="dihedral made by four atoms", + cv_user_prompt="use to define the dihedral angle", + func=md.compute_dihedrals, + kwarg_name='indices', + n_atoms=4 + ) + +def rmsd(wizard): + raise NotImplementedError("RMSD has not yet been implemented") + +def coordinate(wizard): + atom_index = coord = None + while atom_index is None: + idx = wizard.ask("For which atom do you want to get the " + "coordinate? (counting from zero)") + try: + atom_index = int(idx) + except Exception as e: + wizard.exception("Sorry, I can't make an atom index from " + f"'{idx}'", e) + + while coord is None: + xyz = wizard.ask("Which coordinate (x, y, or z) do you want for " + f"atom {atom_index}?") + try: + coord = {'x': 0, 'y': 1, 'z': 2}[xyz] + except KeyError as e: + wizard.bad_input("Please select one of 'x', 'y', or 'z'") + +SUPPORTED_CVS = {} + +if HAS_MDTRAJ: + SUPPORTED_CVS.update({ + 'Distance': distance, + 'Angle': angle, + 'Dihedral': dihedral, + 'RMSD': rmsd, + }) + +SUPPORTED_CVS.update({ + 'Coordinate': coordinate, + 'Python script': ..., + _load_label: partial(load_from_ops, + store_name='cvs', + obj_name='CV'), +}) + +def cvs(wizard): + wizard.say("You'll need to describe your system in terms of " + "collective variables (CVs). We'll use these to define " + "things like stable states.") + cv_names = list(SUPPORTED_CVS.keys()) + cv = None + while cv is None: + cv_type = wizard.ask_enumerate("What kind of CV do you want to " + "define?", options=cv_names) + cv = SUPPORTED_CVS[cv_type](wizard) + return cv + +if __name__ == "__main__": + from paths_cli.wizard.wizard import Wizard + wiz = Wizard({}) + cvs(wiz) diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py new file mode 100644 index 00000000..519fd405 --- /dev/null +++ b/paths_cli/wizard/engines.py @@ -0,0 +1,29 @@ +import paths_cli.wizard.openmm as openmm +from load_from_ops import load_from_ops, LABEL as load_label +from functools import partial + +SUPPORTED_ENGINES = {} +for module in [openmm]: + SUPPORTED_ENGINES.update(module.SUPPORTED) + +SUPPORTED_ENGINES[load_label] = partial(load_from_ops, + store_name='engines', + obj_name='engine') + +def engines(wizard): + wizard.say("Let's make an engine. An engine describes how you'll do " + "the actual dynamics. Most of the details are given " + "in files that depend on the specific type of engine.") + engine_names = list(SUPPORTED_ENGINES.keys()) + eng_name = wizard.ask_enumerate( + "What will you use for the underlying engine?", + options=engine_names + ) + engine = SUPPORTED_ENGINES[eng_name](wizard) + return engine + +if __name__ == "__main__": + from paths_cli.wizard import wizard + wiz = wizard.Wizard({'engines': ('engines', 1, '=')}) + wiz.run_wizard() + diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py new file mode 100644 index 00000000..414f142d --- /dev/null +++ b/paths_cli/wizard/errors.py @@ -0,0 +1,21 @@ +class ImpossibleError(Exception): + def __init__(self, msg=None): + if msg is None: + msg = "Something went really wrong. You should never see this." + super().__init__(msg) + +def not_installed(package, obj_type): + retry = wizard.ask("Hey, it looks like you don't have {package} " + "installed. Do you want to try a different " + "{obj_type}, or do you want to quit?", + options=["[R]etry", "[Q]uit"]) + if retry == 'r': + return + elif retry == 'q': + exit() + else: + raise ImpossibleError() + + +FILE_LOADING_ERROR_MSG = ("Sorry, something went wrong when loading that " + "file.") diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py new file mode 100644 index 00000000..30000a50 --- /dev/null +++ b/paths_cli/wizard/joke.py @@ -0,0 +1,54 @@ +import random + +from paths_cli.wizard.tools import a_an + +_NAMES = ['Fred', 'Winston', 'Agnes', "Millicent", "Ophelia", "Laszlo", + "Werner"] +_THINGS = ['pet spider', 'teddy bear', 'close friend', 'school teacher', + 'iguana'] +_SPAWN = ['daughter', 'son', 'first-born'] + +_MISC = [ + "Named after its father, perhaps?", + "Isn't '{name}' also the name of a village in Tuscany?", + "Didn't Johnny Cash write a song about {a_an_thing} called '{name}'?", + "I think I'm going to start rapping as 'DJ {name}'.", +] + +def _joke1(name, obj_type): + return (f"I probably would have named it something like " + f"'{random.choice(_NAMES)}'.") + +def _joke2(name, obj_type): + thing = random.choice(_THINGS) + joke = (f"I had {a_an(thing)} {thing} named '{name}' " + f"when I was young.") + return joke + +def _joke3(name, obj_type): + return (f"I wanted to name my {random.choice(_SPAWN)} '{name}', but my " + f"wife wouldn't let me.") + +def _joke4(name, obj_type): + a_an_thing = a_an(obj_type) + f" {obj_type}" + return random.choice(_MISC).format(name=name, obj_type=obj_type, + a_an_thing=a_an_thing) + +def name_joke(name, obj_type): + rnd = random.random() + if 0 <= rnd < 0.30: + joke = _joke1 + elif 0.30 <= rnd < 0.70: + joke = _joke2 + elif 0.70 <= rnd < 0.85: + joke = _joke4 + else: + joke = _joke3 + return joke(name, obj_type) + +if __name__ == "__main__": + for _ in range(5): + print() + print(name_joke('AD_300K', 'engine')) + print() + print(name_joke('C_7eq', 'state')) diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py new file mode 100644 index 00000000..638c8bd9 --- /dev/null +++ b/paths_cli/wizard/load_from_ops.py @@ -0,0 +1,37 @@ +LABEL = "Load existing from OPS file" +from paths_cli.parameters import INPUT_FILE + +def named_objs_helper(storage, store_name): + def list_items(wizard, user_input): + store = getattr(storage, store_name) + names = [obj for obj in store if obj.is_named] + outstr = "\n".join(['* ' + obj.name for obj in names]) + wizard.say("Here's what I found:\n\n" + outstr) + + return list_items + + +def load_from_ops(wizard, store_name, obj_name): + wizard.say("Okay, we'll load it from an OPS file.") + storage = None + while storage is None: + filename = wizard.ask("What file can it be found in?", + default='filename') + try: + storage = INPUT_FILE.get(filename) + except Exception as e: + wizard.exception(FILE_LOADING_ERROR_MSG, e) + # TODO: error handling + + obj = None + while obj is None: + name = wizard.ask(f"What's the name of the {obj_name} you want to " + "load? (Type '?' to get a list of them)", + helper=named_objs_helper(storage, store_name)) + if name: + try: + obj = getattr(storage, store_name)[name] + except Exception as e: + wizard.exception("Something went wrong when loading " + f"{name}. Maybe check the spelling?", e) + return obj diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py new file mode 100644 index 00000000..828b4f0e --- /dev/null +++ b/paths_cli/wizard/openmm.py @@ -0,0 +1,88 @@ +from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed +from paths_cli.wizard.tools import get_int_value +try: + from simtk import openmm as mm + import mdtraj as md +except ImportError: + HAS_OPENMM = False +else: + HAS_OPENMM = True + + +def _openmm_serialization_helper(wizard, user_input): + wizard.say("You can write OpenMM objects like systems and integrators " + "to XML files using the XMLSerializer class. Learn more " + "here: \n" + "http://docs.openmm.org/latest/api-python/generated/" + "simtk.openmm.openmm.XmlSerializer.html") + + +def _load_openmm_xml(wizard, obj_type): + xml = wizard.ask( + f"Where is the XML file for your OpenMM {obj_type}?", + helper=_openmm_serialization_helper + ) + try: + with open(xml, 'r') as xml_f: + data = xml_f.read() + obj = mm.XmlSerializer.deserialize(data) + except Exception as e: + wizard.exception(FILE_LOADING_ERROR_MSG, e) + else: + return obj + +def _load_topology(wizard): + import openpathsampling as paths + topology = None + while topology is None: + filename = wizard.ask("Where is a PDB file describing your system?") + try: + snap = paths.engines.openmm.snapshot_from_pdb(filename) + except Exception as e: + wizard.exception(FILE_LOADING_ERROR_MSG, e) + continue + + topology = snap.topology + + return topology + +def openmm(wizard): + import openpathsampling as paths + # quick exit if not installed; but should be impossible to get here + if not HAS_OPENMM: + not_installed("OpenMM", "engine") + return + + wizard.say("Great! OpenMM gives you a lot of flexibility. " + "To use OpenMM in OPS, you need to provide XML versions of " + "your system, integrator, and some file containing " + "topology information.") + system = integrator = topology = None + while system is None: + system = _load_openmm_xml(wizard, 'system') + + while integrator is None: + integrator = _load_openmm_xml(wizard, 'integrator') + + while topology is None: + topology = _load_topology(wizard) + + n_frames_max = n_steps_per_frame = None + + n_steps_per_frame = get_int_value(wizard, + "How many MD steps per saved frame?") + n_frames_max = get_int_value(wizard, ("How many frames before aborting " + "a trajectory?")) + # TODO: assemble the OpenMM simulation + engine = paths.engines.openmm.Engine( + topology=topology, + system=system, + integrator=integrator, + options={ + 'n_steps_per_frame': n_steps_per_frame, + 'n_frames_max': n_frames_max, + } + ) + return engine + +SUPPORTED = {"OpenMM": openmm} if HAS_OPENMM else {} diff --git a/paths_cli/wizard/tools.py b/paths_cli/wizard/tools.py new file mode 100644 index 00000000..9de4e521 --- /dev/null +++ b/paths_cli/wizard/tools.py @@ -0,0 +1,17 @@ + +def a_an(obj): + return "an" if obj[0] in "aeiou" else "a" + +def yes_no(char): + return {'y': True, 'n': False}[char] + +def get_int_value(wizard, question, default=None, helper=None): + as_int = None + while as_int is None: + evald = wizard.ask_custom_eval(question, default=default, + helper=helper) + try: + as_int = int(evald) + except Exception as e: + wizard.exception("Sorry, I didn't understand that.", e) + return as_int diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py new file mode 100644 index 00000000..fe64c640 --- /dev/null +++ b/paths_cli/wizard/volumes.py @@ -0,0 +1,125 @@ +import operator +from paths_cli.wizard.core import interpret_req +from paths_cli.wizard.cvs import cvs + +def _vol_intro(wizard, as_state): + if as_state: + req = wizard.requirements['states'] + if len(wizard.states) == 0: + intro = ("Now let's define stable states for your system. " + f"You'll need to define {interpret_req(req)} of them.") + else: + intro = "Okay, let's define another stable state." + else: + intro = None + return intro + +def _binary_func_volume(wizard, op): + wizard.say("Let's make the first constituent volume:") + vol1 = volumes(wizard) + wizard.say(f"The first volume is:\n{str(vol1)}") + wizard.say("Let's make the second constituent volume:") + vol2 = volumes(wizard) + wizard.say(f"The second volume is:\n{str(vol2)}") + vol = op(vol1, vol2) + wizard.say(f"Created a volume:\n{str(vol)}") + return vol + +def intersection_volume(wizard): + wizard.say("This volume will be the intersection of two other volumes. " + "This means that it only allows phase space points that are " + "in both of the constituent volumes.") + return _binary_func_volume(wizard, operator.__and__) + +def union_volume(wizard): + wizard.say("This volume will be the union of two other volumes. " + "This means that it allows phase space points that are in " + "either of the constituent volumes.") + return _binary_func_volume(wizard, operator.__or__) + +def negated_volume(wizard): + wizard.say("This volume will be everything not in the subvolume. ") + wizard.say("Let's make the subvolume.") + subvol = volumes(wizard) + vol = ~subvol + wizard.say(f"Created a volume:\n{str(vol)}") + return vol + +def cv_defined_volume(wizard): + import openpathsampling as paths + wizard.say("A CV-defined volume allows an interval in a CV.") + cv = wizard.obj_selector('cvs', "CV", cvs) + period_min = period_max = lambda_min = lambda_max = None + is_periodic = None + while is_periodic is None: + is_periodic_char = wizard.ask("Is this CV periodic?", + options=["[Y]es", "[N]o"]) + is_periodic = {'y': True, 'n': False} + + if is_periodic: + while period_min is None: + period_min = wizard.ask_custom_eval( + "What is the lower bound of the period?" + ) + while period_max is None: + period_max = wizard.ask_custom_eval( + "What is the upper bound of the period?" + ) + + volume_bound_str = ("What is the {bound} allowed value for " + f"'{cv.name}' in this volume?") + + while lambda_min is None: + lambda_min = wizard.ask_custom_eval( + volume_bound_str.format(bound="minimum") + ) + + while lambda_max is None: + lambda_max = wizard.ask_custom_eval( + volume_bound_str.format(bound="maximum") + ) + + if is_periodic: + vol = paths.PeriodicCVDefinedVolume( + cv, lambda_min=lambda_min, lambda_max=lambda_max, + period_min=period_min, period_max=period_max + ) + else: + vol = paths.CVDefinedVolume( + cv, lambda_min=lambda_min, lambda_max=lambda_max, + ) + return vol + + +SUPPORTED_VOLUMES = { + 'CV-defined volume (allowed values of CV)': cv_defined_volume, + 'Intersection of two volumes (must be in both)': intersection_volume, + 'Union of two volumes (must be in at least one)': union_volume, + 'Complement of a volume (not in given volume)': negated_volume, +} + +def volumes(wizard, as_state=False): + intro = _vol_intro(wizard, as_state) + if intro is not None: + wizard.say(_vol_intro(wizard, as_state)) + + wizard.say("You can describe this as either a range of values for some " + "CV, or as some combination of other such volumes " + "(i.e., intersection or union).") + obj = "state" if as_state else "volume" + vol = None + while vol is None: + vol_type = wizard.ask_enumerate( + f"What describes this {obj}?", + options=list(SUPPORTED_VOLUMES.keys()) + ) + vol = SUPPORTED_VOLUMES[vol_type](wizard) + + return vol + + +if __name__ == "__main__": + from paths_cli.wizard.wizard import Wizard + wiz = Wizard({'states': ('states', 1, '+')}) + volumes(wiz, as_state=True) + diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py new file mode 100644 index 00000000..caef4bba --- /dev/null +++ b/paths_cli/wizard/wizard.py @@ -0,0 +1,224 @@ +import random +from functools import partial +import textwrap + +from paths_cli.wizard.cvs import cvs +from paths_cli.wizard.engines import engines +from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.tools import yes_no, a_an +from paths_cli.parsing.core import custom_eval +from paths_cli.wizard.joke import name_joke + +WIZARD_PAGES = { + "engines": (engines, "engine"), + "cvs": (cvs, "cv"), + "states": (partial(volumes, as_state=True), "state"), + "tps_network": ..., + "tps_scheme": ..., + "tps_finalize": ..., +} + +import shutil + +class Console: + # TODO: add logging so we can output the session + def print(self, *content): + print(*content) + + def input(self, content): + return input(content) + + @property + def width(self): + return shutil.get_terminal_size((80, 24)).columns + +class Wizard: + def __init__(self, requirements): + self.requirements = requirements + self.engines = {} + self.cvs = {} + self.states = {} + self.networks = {} + self.schemes = {} + + self.console = Console() + self.default = {} + + def _speak(self, content, preface): + # we do custom wrapping here + width = self.console.width - len(preface) + statement = preface + content + lines = statement.split("\n") + wrapped = textwrap.wrap(lines[0], width=width, subsequent_indent=" "*3) + for line in lines[1:]: + wrap_line = textwrap.indent(line, " "*3) + wrapped.append(wrap_line) + self.console.print("\n".join(wrapped)) + + def ask(self, question, options=None, default=None, helper=None): + result = None + while result is None: + result = self.console.input("🧙 " + question + " ") + self.console.print() + if helper and result.startswith("?"): + helper(self, result) + result = None + return result + + def say(self, content, preface="🧙 "): + self._speak(content, preface) + self.console.print() # adds a blank line + + def bad_input(self, content, preface="👺 "): + # just changes the default preface; maybe print 1st line red? + self.say(content, preface) + + def ask_enumerate(self, question, options): + self.say(question) + opt_string = "\n".join([f" {(i+1):>3}. {opt}" + for i, opt in enumerate(options)]) + self.say(opt_string, preface=" "*3) + result = None + while result is None: + choice = self.ask("Please select a number.", + options=[str(i+1) + for i in range(len(options))]) + try: + num = int(choice) - 1 + result = options[num] + except Exception: + self.bad_input(f"Sorry, '{choice}' is not a valid option.") + result = None + + return result + + # this should match the args for wizard.ask + def ask_custom_eval(self, question, options=None, default=None, + helper=None): + result = None + while result is None: + as_str = self.ask(question, options=options, default=default, + helper=helper) + try: + result = custom_eval(as_str) + except Exception as e: + self.exception(f"Sorry, I couldn't understand the input " + f"'{as_str}'", e) + result = None + + return result + + def obj_selector(self, store_name, text_name, create_func): + opts = {name: lambda wiz: obj + for name, obj in getattr(self, store_name).items()} + create_new = f"Create a new {text_name}" + opts[create_new] = create_func + sel = self.ask_enumerate(f"Which {text_name} would you like to " + "use?", list(opts.keys())) + obj = opts[sel](self) + if sel == create_new: + obj = self.register(obj, text_name, store_name) + return obj + + def exception(self, msg, exception): + self.bad_input(msg + "\nHere's the error I got:\n" + str(exception)) + + def _req_allows_another(self, req): + store, num, direction = req + if store is None: + return True + + count = len(getattr(self, store)) + result = {'=': count < num, + '+': True, + '-': count < num}[direction] + return result + + def name(self, obj, obj_type, store_name, default=None): + self.say(f"Now let's name your {obj_type}.") + name = None + while name is None: + name = self.ask("What do you want to call it?") + if name in getattr(self, store_name): + self.bad_input(f"Sorry, you already have {a_an(obj_type)} " + f"named {name}. Please try another name.") + name = None + + obj = obj.named(name) + + self.say(f"'{name}' is a good name for {a_an(obj_type)} {obj_type}. " + + name_joke(name, obj_type)) + return obj + + + def register(self, obj, obj_type, store_name): + if not obj.is_named: + obj = self.name(obj, obj_type, store_name) + store_dict = getattr(self, store_name) + store_dict[obj.name] = obj + return obj + + def save_to_file(self): + filename = None + while filename is None: + filename = self.ask("Where would you like to save your setup " + "database?") + if not filename.endswith(".db"): + self.bad_input("Files produced by this wizard must end in " + "'.db'.") + filename = None + continue + + if os.path.exists(filename): + overwrite = self.ask("{filename} exists. Overwrite it?", + options=["[Y]es", "[N]o"]) + overwrite = yes_no[overwrite] + if not overwrite: + filename = None + continue + + try: + storage = Storage(filename, mode='w') + except Exception as e: + self.exception(FILE_LOADING_ERROR_MSG, e) + else: + self._do_storage(storage) + + def _do_storage(self, storage): + pass + + def run_wizard(self): + for page, req in self.requirements.items(): + store_name, _, _ = req + while self._req_allows_another(req): + func, obj_type = WIZARD_PAGES[page] + obj = func(self) + self.register(obj, obj_type, store_name) + + +TPS_WIZARD = Wizard({ + # WIZARD_PAGE_NAME: (store_name, num, exact/at least/at most) + 'engines': ('engines', 1, '='), + 'cvs': ('cvs', 1, '+'), + 'states': ('states', 2, '+'), + 'tps_network': ('metworks', 1, '='), + 'tps_scheme': ('schemes', 1, '='), + 'tps_finalize': (None, None, None), +}) + +TIS_WIZARD = Wizard({ + 'engines': ('engines', 1, '='), + 'cvs': ('cvs', 1, '+'), + 'states': ('states', 2, '='), + 'tis_network': (1, '='), + 'tis_scheme': (1, '='), + 'tis_finalize': (), +}) + +MSTIS_WIZARD = Wizard({ + 'engines': (1, '='), + 'cvs': (1, '+'), + 'states': (2, '='), + 'mstis_network': (1, '='), + 'mstis_scheme': (1, '='), +}) From 7fec89197f8284d9dfcd66b4f001cbbd7c52e80e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 19 Feb 2021 10:14:31 +0100 Subject: [PATCH 002/251] TPS wizard works through state defs Only the TPS network and scheme remaining! --- paths_cli/wizard/core.py | 17 ++++++++-- paths_cli/wizard/cvs.py | 27 ++++++++-------- paths_cli/wizard/volumes.py | 4 +-- paths_cli/wizard/wizard.py | 62 ++++++++++++++++++++++++++++--------- 4 files changed, 78 insertions(+), 32 deletions(-) diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index c1e83343..201ab896 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -35,7 +35,18 @@ def abort_retry_quit(wizard, obj_type): def interpret_req(req): - _, num, direction = req - dir_str = {'+': 'at least ', '=': '', '-': 'at most '}[direction] - return dir_str + str(num) + _, min_, max_ = req + string = "" + if min_ == max_: + return str(min_) + + if min_ >= 1: + string += "at least " + str(min_) + + if max_ < float("inf"): + if string != "": + string += " and " + string += "at most " + str(max_) + + return string diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 713ee25c..e11ec4eb 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -47,20 +47,17 @@ def _get_topology(wizard): return topology -def _get_atom_indices(wizard, topology, cv_type): +def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): # TODO: move the logic here to parsing.tools - n_atoms = {'distance': 2, - 'angle': 3, - 'dihedral': 4}[cv_type] arr = None helper = partial(mdtraj_atom_helper, n_atoms=n_atoms) while arr is None: - atoms_str = wizard.ask("Which atoms do you want to measure the " - "{cv_type} between?", helper=helper) + atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", + helper=helper) try: indices = custom_eval(atoms_str) except Exception as e: - wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) + wizard.exception(f"Sorry, I did't understand '{atoms_str}'.", e) mdtraj_atom_helper(wizard, '?', n_atoms) continue @@ -81,21 +78,25 @@ def _get_atom_indices(wizard, topology, cv_type): arr = None continue - if arr is not None: - atom_names = [str(topology.mdtraj.atom(i)) for i in arr[0]] - wizard.say("Using atoms: " + " ".join(atom_names)) return arr def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, kwarg_name, n_atoms): from openpathsampling.experimental.storage.collective_variables import \ MDTrajFunctionCV - wizard.say("We'll make a CV that measures the distance between two " - "atoms.") + wizard.say(f"We'll make a CV that measures the {cv_does_str}.") topology = _get_topology(wizard) - indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms) + indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms, + cv_user_str=cv_user_prompt) kwargs = {kwarg_name: indices} + summary = ("Here's what we'll create:\n" + " Function: " + str(func.__name__) + "\n" + " Atoms: " + " ".join([str(topology.mdtraj.atom(i)) + for i in indices[0]]) + "\n" + " Topology: " + repr(topology.mdtraj)) + wizard.say(summary) + return MDTrajFunctionCV(func, topology, **kwargs) def distance(wizard): diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index fe64c640..a2557706 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -38,7 +38,7 @@ def union_volume(wizard): return _binary_func_volume(wizard, operator.__or__) def negated_volume(wizard): - wizard.say("This volume will be everything not in the subvolume. ") + wizard.say("This volume will be everything not in the subvolume.") wizard.say("Let's make the subvolume.") subvol = volumes(wizard) vol = ~subvol @@ -54,7 +54,7 @@ def cv_defined_volume(wizard): while is_periodic is None: is_periodic_char = wizard.ask("Is this CV periodic?", options=["[Y]es", "[N]o"]) - is_periodic = {'y': True, 'n': False} + is_periodic = {'y': True, 'n': False}[is_periodic_char] if is_periodic: while period_min is None: diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index caef4bba..791527fc 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -9,6 +9,13 @@ from paths_cli.parsing.core import custom_eval from paths_cli.wizard.joke import name_joke +DISPLAY_NAME = { + 'engines': "engine", + 'cvs': "CV", + 'states': "state", + 'tps_network': "network", +} + WIZARD_PAGES = { "engines": (engines, "engine"), "cvs": (cvs, "cv"), @@ -44,6 +51,9 @@ def __init__(self, requirements): self.console = Console() self.default = {} + def debug(content): + self.console.print(content) + def _speak(self, content, preface): # we do custom wrapping here width = self.console.width - len(preface) @@ -109,7 +119,7 @@ def ask_custom_eval(self, question, options=None, default=None, return result def obj_selector(self, store_name, text_name, create_func): - opts = {name: lambda wiz: obj + opts = {name: lambda wiz, o=obj: o for name, obj in getattr(self, store_name).items()} create_new = f"Create a new {text_name}" opts[create_new] = create_func @@ -123,16 +133,15 @@ def obj_selector(self, store_name, text_name, create_func): def exception(self, msg, exception): self.bad_input(msg + "\nHere's the error I got:\n" + str(exception)) - def _req_allows_another(self, req): - store, num, direction = req + def _req_do_another(self, req): + store, min_, max_ = req if store is None: - return True + return (True, False) count = len(getattr(self, store)) - result = {'=': count < num, - '+': True, - '-': count < num}[direction] - return result + allows = count < max_ + requires = count < min_ + return requires, allows def name(self, obj, obj_type, store_name, default=None): self.say(f"Now let's name your {obj_type}.") @@ -187,22 +196,44 @@ def save_to_file(self): def _do_storage(self, storage): pass + def _ask_do_another(self, obj_type): + do_another = None + while do_another is None: + do_another_char = self.ask( + f"Would you like to make another {obj_type}?", + options=["[Y]es", "[N]o"] + ) + try: + do_another = yes_no(do_another_char) + except KeyError: + self.bad_input("Sorry, I didn't understand that.") + return do_another + def run_wizard(self): for page, req in self.requirements.items(): store_name, _, _ = req - while self._req_allows_another(req): + do_another = True + while do_another: func, obj_type = WIZARD_PAGES[page] obj = func(self) self.register(obj, obj_type, store_name) + requires_another, allows_another = self._req_do_another(req) + if requires_another: + do_another = True + elif not requires_another and allows_another: + do_another = self._ask_do_another(obj_type) + else: + do_another = False + TPS_WIZARD = Wizard({ # WIZARD_PAGE_NAME: (store_name, num, exact/at least/at most) - 'engines': ('engines', 1, '='), - 'cvs': ('cvs', 1, '+'), - 'states': ('states', 2, '+'), - 'tps_network': ('metworks', 1, '='), - 'tps_scheme': ('schemes', 1, '='), + 'engines': ('engines', 1, 1), + 'cvs': ('cvs', 1, float('inf')), + 'states': ('states', 2, float('inf')), + 'tps_network': ('metworks', 1, 1), + 'tps_scheme': ('schemes', 1, 1), 'tps_finalize': (None, None, None), }) @@ -222,3 +253,6 @@ def run_wizard(self): 'mstis_network': (1, '='), 'mstis_scheme': (1, '='), }) + +if __name__ == "__main__": + TPS_WIZARD.run_wizard() From 250f4e27563b5916d59d1ac605629ad5be29a644 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 27 Feb 2021 12:56:30 +0100 Subject: [PATCH 003/251] a little more work on the wizard --- paths_cli/wizard/joke.py | 11 ++++++----- paths_cli/wizard/wizard.py | 7 ++++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index 30000a50..f35180ba 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -11,8 +11,9 @@ _MISC = [ "Named after its father, perhaps?", "Isn't '{name}' also the name of a village in Tuscany?", - "Didn't Johnny Cash write a song about {a_an_thing} called '{name}'?", + "Didn't Johnny Cash write a song about {a_an_thing} named '{name}'?", "I think I'm going to start rapping as 'DJ {name}'.", + "It would also be a good name for a death metal band.", ] def _joke1(name, obj_type): @@ -38,12 +39,12 @@ def name_joke(name, obj_type): rnd = random.random() if 0 <= rnd < 0.30: joke = _joke1 - elif 0.30 <= rnd < 0.70: + elif 0.30 <= rnd < 0.60: joke = _joke2 - elif 0.70 <= rnd < 0.85: - joke = _joke4 - else: + elif 0.60 <= rnd < 0.75: joke = _joke3 + else: + joke = _joke4 return joke(name, obj_type) if __name__ == "__main__": diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 791527fc..25588dc4 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -79,6 +79,11 @@ def say(self, content, preface="🧙 "): self._speak(content, preface) self.console.print() # adds a blank line + def start(self, content): + # eventually, this will tweak so that we preface with a line and use + # green text here + self.say(content) + def bad_input(self, content, preface="👺 "): # just changes the default preface; maybe print 1st line red? self.say(content, preface) @@ -228,7 +233,7 @@ def run_wizard(self): TPS_WIZARD = Wizard({ - # WIZARD_PAGE_NAME: (store_name, num, exact/at least/at most) + # WIZARD_PAGE_NAME: (store_name, min_num, max_num) 'engines': ('engines', 1, 1), 'cvs': ('cvs', 1, float('inf')), 'states': ('states', 2, float('inf')), From 666f9a997edbedabf302fde34e59eebe3f41168b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Mar 2021 12:46:12 +0100 Subject: [PATCH 004/251] major steps toward TPS wizard --- paths_cli/wizard/core.py | 13 ++++ paths_cli/wizard/joke.py | 6 +- paths_cli/wizard/shooting.py | 124 +++++++++++++++++++++++++++++++ paths_cli/wizard/tps.py | 138 +++++++++++++++++++++++++++++++++++ paths_cli/wizard/volumes.py | 2 +- paths_cli/wizard/wizard.py | 113 ++++++++++++++-------------- 6 files changed, 337 insertions(+), 59 deletions(-) create mode 100644 paths_cli/wizard/shooting.py create mode 100644 paths_cli/wizard/tps.py diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index 201ab896..82e23f2b 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -50,3 +50,16 @@ def interpret_req(req): return string + +def get_missing_object(wizard, obj_dict, display_name, fallback_func): + if len(obj_dict) == 0: + obj = fallback_func(wizard) + elif len(obj_dict) == 1: + obj = list(obj_dict.values())[0] + else: + objs = list(obj_dict.keys()) + sel = wizard.ask_enumerate(f"Which {display_name} would you like " + "to use?", options=objs) + obj = objs[sel] + return obj + diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index f35180ba..4f0f6b25 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -37,11 +37,11 @@ def _joke4(name, obj_type): def name_joke(name, obj_type): rnd = random.random() - if 0 <= rnd < 0.30: + if rnd < 0.25: joke = _joke1 - elif 0.30 <= rnd < 0.60: + elif rnd < 0.50: joke = _joke2 - elif 0.60 <= rnd < 0.75: + elif rnd < 0.65: joke = _joke3 else: joke = _joke4 diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py new file mode 100644 index 00000000..7c0fd802 --- /dev/null +++ b/paths_cli/wizard/shooting.py @@ -0,0 +1,124 @@ +from paths_cli.wizard.core import get_missing_object +from paths_cli.wizard.engines import engines +from paths_cli.wizard.cvs import cvs +from paths_cli.parsing.core import custom_eval + +import numpy as np + + +def uniform_selector(wizard): + import openpathsampling as paths + return paths.UniformSelector() + +def gaussian_selector(wizard): + import openpathsampling as paths + cv = wizard.ask_enumerate("Which CV do you want the Gaussian to be " + "based on?", options=wizard.cvs.keys()) + l_0 = wizard.ask_custom_eval(f"At what value of {cv.name} should the " + "Gaussian be centered?") + std = wizard.ask_custom_eval("What should be the standard deviation of " + "the Gaussian?") + alpha = 0.5 / (std**2) + selector = paths.GaussianBiasSelector(cv, alpha=alpha, l_0=l_0) + return selector + +def random_velocities(wizard): + pass + +def gaussian_momentum_shift(wizard): + pass + +def get_allowed_modifiers(engine): + allowed = [] + randomize_attribs = ['randomize_velocities', 'apply_constraints'] + if any(hasattr(engine, attr) for attr in randomize_attribs): + allowed.append('random_velocities') + + if not engine.has_constraints(): + allowed.append('velocity_changers') + + return allowed + +SHOOTING_SELECTORS = { + 'Uniform random': uniform_selector, + 'Gaussian bias': gaussian_selector, +} + + +def _get_selector(wizard, selectors): + if selectors is None: + selectors = SHOOTING_SELECTORS + selector = None + while selector is None: + sel = wizard.ask_enumerate("How do you want to select shooting " + "points?", options=list(selectors.keys())) + selector = selectors[sel](wizard) + + return selector + +def one_way_shooting(wizard, selectors=None, engine=None): + from openpathsampling import strategies + if engine is None: + engine = get_missing_object(wizard, wizard.engines, 'engine', + engines) + + selector = _get_selector(wizard, selectors) + strat = strategies.OneWayShootingStrategy(selector=selector) + return strat + +def two_way_shooting(wizard, selectors=None, modifiers=None): + pass + +def spring_shooting(wizard, engine=None): + from openpathsampling import strategies + if engine is None: + engine = get_missing_object(wizard, wizard.engines, 'engine', + engines) + + delta_max = wizard.ask_custom_eval( + "What is the maximum shift (delta_max) in frames?", type_=int + ) + k_spring = wizard.ask_custom_eval("What is the spring constant k?", + type_=float) + strat = strategies.SpringShootingStrategy(delta_max=delta_max, + k_spring=k_spring) + return strat + + +SHOOTING_TYPES = { + 'One-way (stochastic) shooting': one_way_shooting, + # 'Two-way shooting': two_way_shooting, + 'Spring shooting': spring_shooting, +} + + +def shooting(wizard, shooting_types=None, engine=None): + if shooting_types is None: + shooting_types = SHOOTING_TYPES + + if engine is None: + engine = get_missing_object(wizard, wizard.engines, 'engine', + engines) + + # allowed_modifiers = get_allowed_modifiers(engine) + # TWO_WAY = 'Two-way shooting' + # if len(allowed_modifiers) == 0 and TWO_WAY in shooting_types: + # del shooting_types[TWO_WAY] + # else: + # shooting_types[TWO_WAY] = partial(two_way_shooting, + # allowed_modifiers=allowed_modifiers) + + shooting_type = None + if len(shooting_types) == 1: + shooting_type = list(shooting_types.values())[0] + else: + while shooting_type is None: + type_name = wizard.ask_enumerate( + "Select the type of shooting move.", + options=list(shooting_types.keys()) + ) + shooting_type = shooting_types[type_name] + + shooting_strategy = shooting_type(wizard) + return shooting_strategy + diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py new file mode 100644 index 00000000..00170ec1 --- /dev/null +++ b/paths_cli/wizard/tps.py @@ -0,0 +1,138 @@ +from paths_cli.wizard.tools import a_an +from paths_cli.wizard.core import get_missing_object +from paths_cli.wizard.shooting import ( + shooting, + # ALGORITHMS + one_way_shooting, + two_way_shooting, + spring_shooting, + # SELECTORS + uniform_selector, + gaussian_selector, + # MODIFIERS + random_velocities, + # all_atom_delta_v, + # single_atom_delta_v, +) +from functools import partial + +def _select_states(wizard, state_type): + states = [] + do_another = True + while do_another: + options = [name for name in wizard.states + if name not in states] + done = f"No more {state_type} states to select" + if len(states) >= 1: + options.append(done) + + state = wizard.ask_enumerate( + f"Pick a state to use for as {a_an(state_type)} {state_type} " + "state", options=options + ) + if state == done: + do_another = False + else: + states.append(state) + + state_objs = [wizard.states[state] for state in states] + return state_objs + +def _get_pathlength(wizard): + pathlength = None + while pathlength is None: + len_str = wizard.ask("How long (in frames) do you want yout " + "trajectories to be?") + try: + pathlength = int(len_str) + except Exceptions as e: + wizard.exception(f"Sorry, I couldn't make '{len_str}' into " + "an integer.", e) + return pathlength + +def flex_length_tps_network(wizard): + import openpathsampling as paths + initial_states = _select_states(wizard, 'initial') + final_states = _select_states(wizard, 'final') + network = paths.TPSNetwork(initial_states, final_states) + return network + +def fixed_length_tps_network(wizard): + pathlength = _get_pathlength(wizard) + initial_states = _select_states(wizard, 'initial') + final_states = _select_states(wizard, 'final') + network = paths.FixedLengthTPSNetwork(initial_states, final_states, + length=pathlength) + return network + +def tps_network(wizard): + import openpathsampling as paths + FIXED = "Fixed length TPS" + FLEX = "Flexible length TPS" + tps_types = { + FLEX: paths.TPSNetwork, + FIXED: paths.FixedLengthTPSNetwork, + } + network_type = None + while network_type is None: + network_type = wizard.ask_enumerate( + "Do you want to do flexible path length TPS (recommended) " + "or fixed path length TPS?", options=list(tps_types.keys()) + ) + network_class = tps_types[network_type] + + if network_type == FIXED: + pathlength = _get_pathlength(wizard) + network_class = partial(network_class, length=pathlength) + + initial_states = _select_states(wizard, 'initial') + final_states = _select_states(wizard, 'final') + + # TODO: summary + + obj = network_class(initial_states, final_states) + return obj + +def _get_network(wizard): + if len(wizard.networks) == 0: + network = tps_network(wizard) + elif len(wizard.networks) == 1: + network = list(wizard.networks.values())[0] + else: + networks = list(wizard.networks.keys()) + sel = wizard.ask_enumerate("Which network would you like to use?", + options=networks) + network = wizard.networks[network] + return network + +def tps_scheme(wizard, network=None): + import openpathsampling as paths + from openpathsampling import strategies + if network is None: + network = get_missing_object(wizard, wizard.networks, 'network', + tps_network) + + shooting_strategy = shooting(wizard) + # TODO: add an option for shifting maybe? + global_strategy = strategies.OrganizeByMoveGroupStrategy() + scheme = paths.MoveScheme(network) + scheme.append(shooting_strategy) + scheme.append(global_strategy) + return scheme + +def tps_finalize(wizard): + pass + +def tps_setup(wizard): + network = tps_network(wizard) + scheme = tps_scheme(wizard, network) + # name it + # provide final info on how to use it + return scheme + + +if __name__ == "__main__": + from paths_cli.wizard.wizard import Wizard + wiz = Wizard({'tps_network': ('networks', 1, '='), + 'tps_scheme': ('schemes', 1, '='), + 'tps_finalize': (None, None, None)}) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index a2557706..e65fc263 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -4,7 +4,7 @@ def _vol_intro(wizard, as_state): if as_state: - req = wizard.requirements['states'] + req = wizard.requirements['state'] if len(wizard.states) == 0: intro = ("Now let's define stable states for your system. " f"You'll need to define {interpret_req(req)} of them.") diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 25588dc4..ed08ec66 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -5,26 +5,13 @@ from paths_cli.wizard.cvs import cvs from paths_cli.wizard.engines import engines from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.tps import ( + flex_length_tps_network, fixed_length_tps_network, tps_scheme +) from paths_cli.wizard.tools import yes_no, a_an from paths_cli.parsing.core import custom_eval from paths_cli.wizard.joke import name_joke -DISPLAY_NAME = { - 'engines': "engine", - 'cvs': "CV", - 'states': "state", - 'tps_network': "network", -} - -WIZARD_PAGES = { - "engines": (engines, "engine"), - "cvs": (cvs, "cv"), - "states": (partial(volumes, as_state=True), "state"), - "tps_network": ..., - "tps_scheme": ..., - "tps_finalize": ..., -} - import shutil class Console: @@ -40,8 +27,12 @@ def width(self): return shutil.get_terminal_size((80, 24)).columns class Wizard: - def __init__(self, requirements): - self.requirements = requirements + def __init__(self, steps): + self.steps = steps + self.requirements = { + step.display_name: (step.store_name, step.minimum, step.maximum) + for step in steps + } self.engines = {} self.cvs = {} self.states = {} @@ -109,13 +100,13 @@ def ask_enumerate(self, question, options): # this should match the args for wizard.ask def ask_custom_eval(self, question, options=None, default=None, - helper=None): + helper=None, type_=float): result = None while result is None: as_str = self.ask(question, options=options, default=default, helper=helper) try: - result = custom_eval(as_str) + result = type_(custom_eval(as_str)) except Exception as e: self.exception(f"Sorry, I couldn't understand the input " f"'{as_str}'", e) @@ -215,49 +206,61 @@ def _ask_do_another(self, obj_type): return do_another def run_wizard(self): - for page, req in self.requirements.items(): - store_name, _, _ = req + for step in self.steps: + req = step.store_name, step.minimum, step.maximum do_another = True while do_another: - func, obj_type = WIZARD_PAGES[page] - obj = func(self) - self.register(obj, obj_type, store_name) + obj = step.func(self) + self.register(obj, step.display_name, step.store_name) requires_another, allows_another = self._req_do_another(req) if requires_another: do_another = True elif not requires_another and allows_another: - do_another = self._ask_do_another(obj_type) + do_another = self._ask_do_another(step.display_name) else: do_another = False - - -TPS_WIZARD = Wizard({ - # WIZARD_PAGE_NAME: (store_name, min_num, max_num) - 'engines': ('engines', 1, 1), - 'cvs': ('cvs', 1, float('inf')), - 'states': ('states', 2, float('inf')), - 'tps_network': ('metworks', 1, 1), - 'tps_scheme': ('schemes', 1, 1), - 'tps_finalize': (None, None, None), -}) - -TIS_WIZARD = Wizard({ - 'engines': ('engines', 1, '='), - 'cvs': ('cvs', 1, '+'), - 'states': ('states', 2, '='), - 'tis_network': (1, '='), - 'tis_scheme': (1, '='), - 'tis_finalize': (), -}) - -MSTIS_WIZARD = Wizard({ - 'engines': (1, '='), - 'cvs': (1, '+'), - 'states': (2, '='), - 'mstis_network': (1, '='), - 'mstis_scheme': (1, '='), -}) +from collections import namedtuple +WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', + 'minimum', 'maximum']) + +SINGLE_ENGINE_STEP = WizardStep(func=engines, + display_name="engine", + store_name="engines", + minimum=1, + maximum=1) +CVS_STEP = WizardStep(func=cvs, + display_name="CV", + store_name='cvs', + minimum=1, + maximum=float('inf')) + +MULTIPLE_STATES_STEP = WizardStep(func=partial(volumes, as_state=True), + display_name="state", + store_name="states", + minimum=2, + maximum=float('inf')) + +FLEX_LENGTH_TPS_WIZARD = Wizard([ + SINGLE_ENGINE_STEP, + CVS_STEP, + MULTIPLE_STATES_STEP, + WizardStep(func=flex_length_tps_network, + display_name="network", + store_name="networks", + minimum=1, + maximum=1), + WizardStep(func=tps_scheme, + display_name="move scheme", + store_name="schemes", + minimum=1, + maximum=1), +]) + +# FIXED_LENGTH_TPS_WIZARD +# TWO_STATE_TIS_WIZARD +# MULTIPLE_STATE_TIS_WIZARD +# MULTIPLE_INTERFACE_SET_TIS_WIZARD if __name__ == "__main__": - TPS_WIZARD.run_wizard() + FLEX_LENGTH_TPS_WIZARD.run_wizard() From 8910ba22e68e35a4ab6f0dbeb6a4810d0543b9d8 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Mar 2021 17:30:42 +0100 Subject: [PATCH 005/251] A little more cleanup on wizard stuff --- paths_cli/wizard/cvs.py | 4 ++-- paths_cli/wizard/wizard.py | 46 +++++++++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index e11ec4eb..14313578 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -158,12 +158,12 @@ def coordinate(wizard): 'Distance': distance, 'Angle': angle, 'Dihedral': dihedral, - 'RMSD': rmsd, + # 'RMSD': rmsd, }) SUPPORTED_CVS.update({ 'Coordinate': coordinate, - 'Python script': ..., + # 'Python script': ..., _load_label: partial(load_from_ops, store_name='cvs', obj_name='CV'), diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index ed08ec66..b7723584 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -1,4 +1,5 @@ import random +import os from functools import partial import textwrap @@ -10,6 +11,7 @@ ) from paths_cli.wizard.tools import yes_no, a_an from paths_cli.parsing.core import custom_eval +from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG from paths_cli.wizard.joke import name_joke import shutil @@ -41,6 +43,15 @@ def __init__(self, steps): self.console = Console() self.default = {} + self._patched = False # if we've done the monkey-patching + + def _patch(self): + import openpathsampling as paths + from openpathsampling.experimental.storage import monkey_patch_all + if not self._patched: + paths = monkey_patch_all(paths) + paths.InterfaceSet.simstore = True + self._patched = True def debug(content): self.console.print(content) @@ -86,7 +97,7 @@ def ask_enumerate(self, question, options): self.say(opt_string, preface=" "*3) result = None while result is None: - choice = self.ask("Please select a number.", + choice = self.ask("Please select a number:", options=[str(i+1) for i in range(len(options))]) try: @@ -127,7 +138,8 @@ def obj_selector(self, store_name, text_name, create_func): return obj def exception(self, msg, exception): - self.bad_input(msg + "\nHere's the error I got:\n" + str(exception)) + self.bad_input(f"{msg}\nHere's the error I got:\n" + f"{exception.__class__.__name__}: {exception}") def _req_do_another(self, req): store, min_, max_ = req @@ -164,6 +176,7 @@ def register(self, obj, obj_type, store_name): return obj def save_to_file(self): + from openpathsampling.experimental.storage import Storage filename = None while filename is None: filename = self.ask("Where would you like to save your setup " @@ -175,9 +188,9 @@ def save_to_file(self): continue if os.path.exists(filename): - overwrite = self.ask("{filename} exists. Overwrite it?", + overwrite = self.ask(f"{filename} exists. Overwrite it?", options=["[Y]es", "[N]o"]) - overwrite = yes_no[overwrite] + overwrite = yes_no(overwrite) if not overwrite: filename = None continue @@ -189,8 +202,27 @@ def save_to_file(self): else: self._do_storage(storage) + def _storage_description_line(self, store_name): + store = getattr(self, store_name) + if len(store) == 1: + store_name = store_name[:-1] # chop the 's' + + line = f"* {len(store)} {store_name}: " + str(list(store.keys())) + return line + def _do_storage(self, storage): - pass + store_names = ['engines', 'cvs', 'states', 'networks', 'schemes'] + lines = [self._storage_description_line(store_name) + for store_name in store_names] + statement = ("I'm going to store the following items:\n\n" + + "\n".join(lines)) + self.say(statement) + for store_name in store_names: + store = getattr(self, store_name) + for obj in store.values(): + storage.save(obj) + + self.say("Success! Everthing has been stored in your file.") def _ask_do_another(self, obj_type): do_another = None @@ -206,6 +238,7 @@ def _ask_do_another(self, obj_type): return do_another def run_wizard(self): + self._patch() # try to hide the slowness of our first import for step in self.steps: req = step.store_name, step.minimum, step.maximum do_another = True @@ -220,6 +253,8 @@ def run_wizard(self): else: do_another = False + self.save_to_file() + from collections import namedtuple WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', 'minimum', 'maximum']) @@ -229,6 +264,7 @@ def run_wizard(self): store_name="engines", minimum=1, maximum=1) + CVS_STEP = WizardStep(func=cvs, display_name="CV", store_name='cvs', From 0b60338d67114507c54f0dd0596f7dcd507d059b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Mar 2021 20:05:32 +0100 Subject: [PATCH 006/251] Start to add tests for wizard --- paths_cli/tests/wizard/mock_wizard.py | 12 +++++++ paths_cli/tests/wizard/test_core.py | 51 +++++++++++++++++++++++++++ paths_cli/tests/wizard/test_tools.py | 18 ++++++++++ paths_cli/wizard/__init__.py | 0 paths_cli/wizard/core.py | 11 +++--- paths_cli/wizard/engines.py | 4 ++- paths_cli/wizard/openmm.py | 11 +++--- paths_cli/wizard/tools.py | 14 +------- paths_cli/wizard/wizard.py | 6 ++-- 9 files changed, 100 insertions(+), 27 deletions(-) create mode 100644 paths_cli/tests/wizard/mock_wizard.py create mode 100644 paths_cli/tests/wizard/test_core.py create mode 100644 paths_cli/tests/wizard/test_tools.py create mode 100644 paths_cli/wizard/__init__.py diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py new file mode 100644 index 00000000..547ebd2a --- /dev/null +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -0,0 +1,12 @@ +from paths_cli.wizard.wizard import Wizard +import mock + +def make_mock_wizard(inputs): + wizard = Wizard([]) + wizard.console.input = mock.Mock(return_value=inputs) + return wizard + +def make_mock_retry_wizard(inputs): + wizard = Wizard([]) + wizard.console.input = mock.Mock(side_effect=inputs) + return wizard diff --git a/paths_cli/tests/wizard/test_core.py b/paths_cli/tests/wizard/test_core.py new file mode 100644 index 00000000..19b7c462 --- /dev/null +++ b/paths_cli/tests/wizard/test_core.py @@ -0,0 +1,51 @@ +import pytest +import mock + +from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV + +from paths_cli.wizard.core import * + +from paths_cli.tests.wizard.mock_wizard import ( + make_mock_wizard, make_mock_retry_wizard +) + +def test_name(): + wizard = make_mock_wizard('foo') + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + assert not cv.is_named + result = name(wizard, cv, obj_type="CV", store_name="cvs") + assert result is cv + assert result.is_named + assert result.name == 'foo' + +def test_name_exists(): + wizard = make_mock_retry_wizard(['foo', 'bar']) + wizard.cvs['foo'] = 'placeholder' + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + assert not cv.is_named + result = name(wizard, cv, obj_type="CV", store_name="cvs") + assert result is cv + assert result.is_named + assert result.name == 'bar' + assert wizard.console.input.call_count == 2 + +@pytest.mark.parametrize('req,expected', [ + (('foo', 2, 2), '2'), (('foo', 2, float('inf')), 'at least 2'), + (('foo', 0, 2), 'at most 2'), + (('foo', 1, 3), 'at least 1 and at most 3') +]) +def test_interpret_req(req, expected): + assert interpret_req(req) == expected + +@pytest.mark.parametrize('length,expected', [ + (0, 'foo'), (1, 'baz'), (2, 'quux'), +]) +def test_get_missing_object(length, expected): + dct = dict([('bar', 'baz'), ('qux', 'quux')][:length]) + fallback = lambda x: 'foo' + wizard = make_mock_wizard('2') + result = get_missing_object(wizard, dct, display_name='string', + fallback_func=fallback) + assert result == expected + pass diff --git a/paths_cli/tests/wizard/test_tools.py b/paths_cli/tests/wizard/test_tools.py new file mode 100644 index 00000000..b8dbff3e --- /dev/null +++ b/paths_cli/tests/wizard/test_tools.py @@ -0,0 +1,18 @@ +import pytest +import mock + +from paths_cli.wizard.tools import * + +@pytest.mark.parametrize('word,expected', [ + ('foo', 'a'), ('egg', 'an') +]) +def test_a_an(word, expected): + assert a_an(word) == expected + + +@pytest.mark.parametrize('user_inp,expected', [ + ('y', True), ('n', False), + # ('Y', True), ('N', False), ('yes', True), ('no', False) +]) +def test_yes_no(user_inp, expected): + assert yes_no(user_inp) == expected diff --git a/paths_cli/wizard/__init__.py b/paths_cli/wizard/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index 82e23f2b..fb22970d 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -1,6 +1,6 @@ import random -from joke import name_joke -from tools import a_an +from paths_cli.wizard.joke import name_joke +from paths_cli.wizard.tools import a_an def name(wizard, obj, obj_type, store_name, default=None): wizard.say(f"Now let's name your {obj_type}.") @@ -9,7 +9,8 @@ def name(wizard, obj, obj_type, store_name, default=None): name = wizard.ask("What do you want to call it?") if name in getattr(wizard, store_name): wizard.bad_input(f"Sorry, you already have {a_an(obj_type)} " - f"named {name}. Please try another name.") + f"{obj_type} named {name}. Please try another " + "name.") name = None obj = obj.named(name) @@ -19,7 +20,7 @@ def name(wizard, obj, obj_type, store_name, default=None): return obj def abort_retry_quit(wizard, obj_type): - a_an = 'an' if obj_type[0] in 'aeiou' else 'a' + a_an = a_an(obj_type) retry = wizard.ask(f"Do you want to try again to make {a_an} " f"{obj_type}, do you want to continue without, or " f"do you want to quit?", @@ -60,6 +61,6 @@ def get_missing_object(wizard, obj_dict, display_name, fallback_func): objs = list(obj_dict.keys()) sel = wizard.ask_enumerate(f"Which {display_name} would you like " "to use?", options=objs) - obj = objs[sel] + obj = obj_dict[sel] return obj diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 519fd405..f3b0c4b4 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -1,5 +1,7 @@ import paths_cli.wizard.openmm as openmm -from load_from_ops import load_from_ops, LABEL as load_label +from paths_cli.wizard.load_from_ops import ( + load_from_ops, LABEL as load_label +) from functools import partial SUPPORTED_ENGINES = {} diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 828b4f0e..11697b97 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,5 +1,4 @@ from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed -from paths_cli.wizard.tools import get_int_value try: from simtk import openmm as mm import mdtraj as md @@ -69,10 +68,12 @@ def openmm(wizard): n_frames_max = n_steps_per_frame = None - n_steps_per_frame = get_int_value(wizard, - "How many MD steps per saved frame?") - n_frames_max = get_int_value(wizard, ("How many frames before aborting " - "a trajectory?")) + n_steps_per_frame = wizard.ask_custom_eval( + "How many MD steps per saved frame?", type_=int + ) + n_frames_max = wizard.ask_custom_eval( + "How many frames before aborting a trajectory?", type_=int + ) # TODO: assemble the OpenMM simulation engine = paths.engines.openmm.Engine( topology=topology, diff --git a/paths_cli/wizard/tools.py b/paths_cli/wizard/tools.py index 9de4e521..a1bc158b 100644 --- a/paths_cli/wizard/tools.py +++ b/paths_cli/wizard/tools.py @@ -1,17 +1,5 @@ - def a_an(obj): - return "an" if obj[0] in "aeiou" else "a" + return "an" if obj[0].lower() in "aeiou" else "a" def yes_no(char): return {'y': True, 'n': False}[char] - -def get_int_value(wizard, question, default=None, helper=None): - as_int = None - while as_int is None: - evald = wizard.ask_custom_eval(question, default=default, - helper=helper) - try: - as_int = int(evald) - except Exception as e: - wizard.exception("Sorry, I didn't understand that.", e) - return as_int diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index b7723584..38f8e274 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -16,7 +16,7 @@ import shutil -class Console: +class Console: # no-cov # TODO: add logging so we can output the session def print(self, *content): print(*content) @@ -45,7 +45,7 @@ def __init__(self, steps): self.default = {} self._patched = False # if we've done the monkey-patching - def _patch(self): + def _patch(self): # no-cov import openpathsampling as paths from openpathsampling.experimental.storage import monkey_patch_all if not self._patched: @@ -53,7 +53,7 @@ def _patch(self): paths.InterfaceSet.simstore = True self._patched = True - def debug(content): + def debug(content): # no-cov self.console.print(content) def _speak(self, content, preface): From 68066b1477356874b4c2b398dfe6751c66c8ec2b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 6 Mar 2021 13:38:27 +0100 Subject: [PATCH 007/251] tests for most of the Wizard object --- paths_cli/tests/wizard/test_wizard.py | 234 ++++++++++++++++++++++++++ paths_cli/wizard/errors.py | 3 + paths_cli/wizard/openmm.py | 2 +- paths_cli/wizard/wizard.py | 58 ++++--- 4 files changed, 274 insertions(+), 23 deletions(-) create mode 100644 paths_cli/tests/wizard/test_wizard.py diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py new file mode 100644 index 00000000..b1dd11c7 --- /dev/null +++ b/paths_cli/tests/wizard/test_wizard.py @@ -0,0 +1,234 @@ +import pytest +import mock +from paths_cli.tests.wizard.mock_wizard import ( + make_mock_wizard, make_mock_retry_wizard +) +import pathlib + +from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV + +from paths_cli.wizard.wizard import * + +class MockConsole: + def __init__(self, inputs=None): + if isinstance(inputs, str): + inputs = [inputs] + elif inputs is None: + inputs = [] + self.inputs = inputs + self._input_iter = iter(inputs) + self.log = [] + self.width = 80 + self.input_call_count = 0 + + def print(self, content=""): + self.log.append(content) + + def input(self, content): + self.input_call_count += 1 + user_input = next(self._input_iter) + self.log.append(content + " " + user_input) + return user_input + + @property + def log_text(self): + return "\n".join(self.log) + + +class TestWizard: + def setup(self): + self.wizard = Wizard([]) + + def test_initialization(self): + wizard = Wizard([SINGLE_ENGINE_STEP]) + assert wizard.requirements == {'engine': ('engines', 1, 1)} + + def test_ask(self): + console = MockConsole('foo') + self.wizard.console = console + result = self.wizard.ask('bar') + assert result == 'foo' + assert 'bar' in console.log_text + + def test_ask_help(self): + pass + + def _generic_speak_test(self, func_name): + console = MockConsole() + self.wizard.console = console + func = getattr(self.wizard, func_name) + func('foo') + assert 'foo' in console.log_text + + def test_say(self): + self._generic_speak_test('say') + + def test_start(self): + self._generic_speak_test('start') + + def test_bad_input(self): + self._generic_speak_test('bad_input') + + @pytest.mark.parametrize('bad_choice', [False, True]) + def test_ask_enumerate(self, bad_choice): + inputs = {False: '1', True: ['10', '1']}[bad_choice] + console = MockConsole(inputs) + self.wizard.console = console + selected = self.wizard.ask_enumerate('foo', options=['bar', 'baz']) + assert selected == 'bar' + assert 'foo' in console.log_text + assert '1. bar' in console.log_text + assert '2. baz' in console.log_text + assert console.input_call_count == len(inputs) + if bad_choice: + assert "'10'" in console.log_text + assert 'not a valid option' in console.log_text + else: + assert "'10'" not in console.log_text + assert 'not a valid option' not in console.log_text + + @pytest.mark.parametrize('inputs, type_, expected', [ + ("2+2", int, 4), ("0.1 * 0.1", float, 0.1*0.1), ("2.4", int, 2) + ]) + def test_ask_custom_eval(self, inputs, type_, expected): + console = MockConsole(inputs) + self.wizard.console = console + result = self.wizard.ask_custom_eval('foo', type_=type_) + assert result == expected + assert console.input_call_count == 1 + assert 'foo' in console.log_text + + def test_ask_custom_eval_bad_type(self): + console = MockConsole(['"bar"', '2+2']) + self.wizard.console = console + result = self.wizard.ask_custom_eval('foo') + assert result == 4 + assert console.input_call_count == 2 + assert "I couldn't understand" in console.log_text + assert "ValueError" in console.log_text + + def test_ask_custom_eval_bad_input(self): + console = MockConsole(['bar', '2+2']) + self.wizard.console = console + result = self.wizard.ask_custom_eval('foo') + assert result == 4 + assert console.input_call_count == 2 + assert "I couldn't understand" in console.log_text + assert "NameError" in console.log_text + + @pytest.mark.parametrize('inputs,expected', [('1', 'bar'), + ('2', 'new')]) + def test_obj_selector(self, inputs, expected): + create_func = lambda wiz: 'new' + console = MockConsole(inputs) + self.wizard.console = console + self.wizard.cvs = {'foo': 'bar'} + def mock_register(obj, name, store): + console.print("registered") + return obj + self.wizard.register = mock_register + sel = self.wizard.obj_selector('cvs', "CV", create_func) + assert sel == expected + assert "CV" in console.log_text + if inputs == '2': + assert "registered" in console.log_text + + def test_exception(self): + console = MockConsole() + self.wizard.console = console + err = RuntimeError("baz") + self.wizard.exception('foo', err) + assert 'foo' in console.log_text + assert "RuntimeError: baz" in console.log_text + + def test_name(self): + # why does that exist? cf core.name + # actually, it looks like we never use core.name; remove that and + # move its test here? + pass + + @pytest.mark.parametrize('named', [True, False]) + def test_register(self, named): + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + if named: + cv = cv.named('foo') + do_naming = lambda obj, obj_type, store_name: cv.named('foo') + assert cv.is_named == named + self.wizard.name = mock.Mock(side_effect=do_naming) + assert len(self.wizard.cvs) == 0 + self.wizard.register(cv, 'CV', 'cvs') + assert len(self.wizard.cvs) == 1 + assert cv.name == 'foo' + + def _get_storage(self, inputs, expected): + console = MockConsole(inputs) + self.wizard.console = console + storage = self.wizard.get_storage() + assert storage.backend.filename == expected + assert console.input_call_count == len(inputs) + + @pytest.mark.parametrize('fnames', [(['setup.db']), + (['setup.nc', 'setup.db'])]) + def test_get_storage(self, tmpdir, fnames): + inputs = [os.path.join(tmpdir, inp) for inp in fnames] + self._get_storage(inputs, inputs[-1]) + + @pytest.mark.parametrize('overwrite', [True, False]) + def test_get_storage_exists(self, overwrite, tmpdir): + inputs = [os.path.join(tmpdir, 'setup.db'), + {True: 'y', False: 'n'}[overwrite]] + if not overwrite: + inputs.append(os.path.join(tmpdir, 'setup2.db')) + + pathlib.Path(inputs[0]).touch() + expected = 'setup.db' if overwrite else 'setup2.db' + self._get_storage(inputs, os.path.join(tmpdir, expected)) + + def test_get_storage_file_error(self, tmpdir): + inputs = ['/oogabooga/foo/bar.db', os.path.join(tmpdir, 'setup.db')] + self._get_storage(inputs, inputs[-1]) + + @pytest.mark.parametrize('count', [0, 1, 2]) + def test_storage_description_line(self, count): + from openpathsampling.experimental.storage.collective_variables \ + import CoordinateFunctionCV + expected = {0: "", + 1: "* 1 cv: ['foo']", + 2: "* 2 cvs: ['foo', 'bar']"}[count] + + self.wizard.cvs = { + name: CoordinateFunctionCV(lambda s: s.xyz[0][0]).named(name) + for name in ['foo', 'bar'][:count] + } + line = self.wizard._storage_description_line('cvs') + assert line == expected + + def test_save_to_file(self): + pass + + @pytest.mark.parametrize('req,count,expected', [ + (('cvs', 1, 1), 0, (True, True)), + (('cvs', 1, 1), 1, (False, False)), + (('cvs', 1, 2), 1, (False, True)), + ((None, 0, 0), 0, (True, False)) + ]) + def test_req_do_another(self, req, count, expected): + self.wizard.cvs = {str(i): 'foo' for i in range(count)} + assert self.wizard._req_do_another(req) == expected + + @pytest.mark.parametrize('inputs,expected', [ + (['y'], True), (['z', 'y'], True), (['z', 'n'], False), + (['n'], False) + ]) + def test_ask_do_another(self, inputs, expected): + console = MockConsole(inputs) + self.wizard.console = console + result = self.wizard._ask_do_another("CV") + assert result == expected + assert "another CV" in console.log_text + if len(inputs) > 1: + assert "Sorry" in console.log_text + + def test_run_wizard(self): + pass diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py index 414f142d..7ec16747 100644 --- a/paths_cli/wizard/errors.py +++ b/paths_cli/wizard/errors.py @@ -4,6 +4,9 @@ def __init__(self, msg=None): msg = "Something went really wrong. You should never see this." super().__init__(msg) +class RestartObjectException(BaseException): + pass + def not_installed(package, obj_type): retry = wizard.ask("Hey, it looks like you don't have {package} " "installed. Do you want to try a different " diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 11697b97..e98117f9 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -8,7 +8,7 @@ HAS_OPENMM = True -def _openmm_serialization_helper(wizard, user_input): +def _openmm_serialization_helper(wizard, user_input): # no-cov wizard.say("You can write OpenMM objects like systems and integrators " "to XML files using the XMLSerializer class. Learn more " "here: \n" diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 38f8e274..fe8e3dbd 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -11,7 +11,9 @@ ) from paths_cli.wizard.tools import yes_no, a_an from paths_cli.parsing.core import custom_eval -from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG +from paths_cli.wizard.errors import ( + FILE_LOADING_ERROR_MSG, RestartObjectException +) from paths_cli.wizard.joke import name_joke import shutil @@ -41,6 +43,8 @@ def __init__(self, steps): self.networks = {} self.schemes = {} + self.last_used_file = None # for loading + self.console = Console() self.default = {} self._patched = False # if we've done the monkey-patching @@ -54,10 +58,12 @@ def _patch(self): # no-cov self._patched = True def debug(content): # no-cov + # debug does no pretty-printing self.console.print(content) def _speak(self, content, preface): # we do custom wrapping here + # TODO: move this to the console class; should also wrap on `input` width = self.console.width - len(preface) statement = preface + content lines = statement.split("\n") @@ -91,6 +97,7 @@ def bad_input(self, content, preface="👺 "): self.say(content, preface) def ask_enumerate(self, question, options): + """Ask the user to select from a list of options""" self.say(question) opt_string = "\n".join([f" {(i+1):>3}. {opt}" for i, opt in enumerate(options)]) @@ -133,6 +140,7 @@ def obj_selector(self, store_name, text_name, create_func): sel = self.ask_enumerate(f"Which {text_name} would you like to " "use?", list(opts.keys())) obj = opts[sel](self) + print(sel, obj) if sel == create_new: obj = self.register(obj, text_name, store_name) return obj @@ -141,16 +149,6 @@ def exception(self, msg, exception): self.bad_input(f"{msg}\nHere's the error I got:\n" f"{exception.__class__.__name__}: {exception}") - def _req_do_another(self, req): - store, min_, max_ = req - if store is None: - return (True, False) - - count = len(getattr(self, store)) - allows = count < max_ - requires = count < min_ - return requires, allows - def name(self, obj, obj_type, store_name, default=None): self.say(f"Now let's name your {obj_type}.") name = None @@ -175,16 +173,15 @@ def register(self, obj, obj_type, store_name): store_dict[obj.name] = obj return obj - def save_to_file(self): + def get_storage(self): from openpathsampling.experimental.storage import Storage - filename = None - while filename is None: + storage = None + while storage is None: filename = self.ask("Where would you like to save your setup " "database?") if not filename.endswith(".db"): self.bad_input("Files produced by this wizard must end in " "'.db'.") - filename = None continue if os.path.exists(filename): @@ -192,30 +189,32 @@ def save_to_file(self): options=["[Y]es", "[N]o"]) overwrite = yes_no(overwrite) if not overwrite: - filename = None continue try: storage = Storage(filename, mode='w') except Exception as e: self.exception(FILE_LOADING_ERROR_MSG, e) - else: - self._do_storage(storage) + + return storage def _storage_description_line(self, store_name): store = getattr(self, store_name) + if len(store) == 0: + return "" + if len(store) == 1: store_name = store_name[:-1] # chop the 's' line = f"* {len(store)} {store_name}: " + str(list(store.keys())) return line - def _do_storage(self, storage): + def save_to_file(self, storage): store_names = ['engines', 'cvs', 'states', 'networks', 'schemes'] lines = [self._storage_description_line(store_name) for store_name in store_names] statement = ("I'm going to store the following items:\n\n" - + "\n".join(lines)) + + "\n".join([line for line in lines if len(line) > 0])) self.say(statement) for store_name in store_names: store = getattr(self, store_name) @@ -224,6 +223,16 @@ def _do_storage(self, storage): self.say("Success! Everthing has been stored in your file.") + def _req_do_another(self, req): + store, min_, max_ = req + if store is None: + return (True, False) + + count = len(getattr(self, store)) + allows = count < max_ + requires = count < min_ + return requires, allows + def _ask_do_another(self, obj_type): do_another = None while do_another is None: @@ -243,7 +252,11 @@ def run_wizard(self): req = step.store_name, step.minimum, step.maximum do_another = True while do_another: - obj = step.func(self) + try: + obj = step.func(self) + except RestartObjectException: + self.say("Okay, let's try that again.") + continue self.register(obj, step.display_name, step.store_name) requires_another, allows_another = self._req_do_another(req) if requires_another: @@ -253,7 +266,8 @@ def run_wizard(self): else: do_another = False - self.save_to_file() + storage = self.get_storage() + self.save_to_file(storage) from collections import namedtuple WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', From 4da1b9a2f34bd5dffb96ca2cec94019f8bcbc520 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 6 Mar 2021 17:07:06 +0100 Subject: [PATCH 008/251] move MockConsole, start to get_object decorator --- paths_cli/tests/wizard/mock_wizard.py | 33 +++++++++++++++++++++++++ paths_cli/tests/wizard/test_wizard.py | 26 +------------------- paths_cli/wizard/core.py | 8 ++++++ paths_cli/wizard/openmm.py | 35 ++++++++++----------------- 4 files changed, 55 insertions(+), 47 deletions(-) diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py index 547ebd2a..880bf6d6 100644 --- a/paths_cli/tests/wizard/mock_wizard.py +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -10,3 +10,36 @@ def make_mock_retry_wizard(inputs): wizard = Wizard([]) wizard.console.input = mock.Mock(side_effect=inputs) return wizard + +class MockConsole: + def __init__(self, inputs=None): + if isinstance(inputs, str): + inputs = [inputs] + elif inputs is None: + inputs = [] + self.inputs = inputs + self._input_iter = iter(inputs) + self.log = [] + self.width = 80 + self.input_call_count = 0 + + def print(self, content=""): + self.log.append(content) + + def input(self, content): + self.input_call_count += 1 + user_input = next(self._input_iter) + self.log.append(content + " " + user_input) + return user_input + + @property + def log_text(self): + return "\n".join(self.log) + +def mock_wizard(inputs): + wizard = Wizard([]) + console = MockConsole(inputs) + wizard.console = console + return wizard + + diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index b1dd11c7..9ac556f6 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -1,7 +1,7 @@ import pytest import mock from paths_cli.tests.wizard.mock_wizard import ( - make_mock_wizard, make_mock_retry_wizard + MockConsole ) import pathlib @@ -10,30 +10,6 @@ from paths_cli.wizard.wizard import * -class MockConsole: - def __init__(self, inputs=None): - if isinstance(inputs, str): - inputs = [inputs] - elif inputs is None: - inputs = [] - self.inputs = inputs - self._input_iter = iter(inputs) - self.log = [] - self.width = 80 - self.input_call_count = 0 - - def print(self, content=""): - self.log.append(content) - - def input(self, content): - self.input_call_count += 1 - user_input = next(self._input_iter) - self.log.append(content + " " + user_input) - return user_input - - @property - def log_text(self): - return "\n".join(self.log) class TestWizard: diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index fb22970d..34387569 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -64,3 +64,11 @@ def get_missing_object(wizard, obj_dict, display_name, fallback_func): obj = obj_dict[sel] return obj + +def get_object(func): + def inner(*args, **kwargs): + obj = None + while obj is None: + obj = func(*args, **kwargs) + return obj + return inner diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index e98117f9..b6d75146 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,4 +1,5 @@ from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed +from paths_cli.wizard.core import get_object try: from simtk import openmm as mm import mdtraj as md @@ -16,6 +17,7 @@ def _openmm_serialization_helper(wizard, user_input): # no-cov "simtk.openmm.openmm.XmlSerializer.html") +@get_object def _load_openmm_xml(wizard, obj_type): xml = wizard.ask( f"Where is the XML file for your OpenMM {obj_type}?", @@ -30,20 +32,17 @@ def _load_openmm_xml(wizard, obj_type): else: return obj +@get_object def _load_topology(wizard): import openpathsampling as paths - topology = None - while topology is None: - filename = wizard.ask("Where is a PDB file describing your system?") - try: - snap = paths.engines.openmm.snapshot_from_pdb(filename) - except Exception as e: - wizard.exception(FILE_LOADING_ERROR_MSG, e) - continue - - topology = snap.topology + filename = wizard.ask("Where is a PDB file describing your system?") + try: + snap = paths.engines.openmm.snapshot_from_pdb(filename) + except Exception as e: + wizard.exception(FILE_LOADING_ERROR_MSG, e) + return - return topology + return snap.topology def openmm(wizard): import openpathsampling as paths @@ -56,17 +55,9 @@ def openmm(wizard): "To use OpenMM in OPS, you need to provide XML versions of " "your system, integrator, and some file containing " "topology information.") - system = integrator = topology = None - while system is None: - system = _load_openmm_xml(wizard, 'system') - - while integrator is None: - integrator = _load_openmm_xml(wizard, 'integrator') - - while topology is None: - topology = _load_topology(wizard) - - n_frames_max = n_steps_per_frame = None + system = _load_openmm_xml(wizard, 'system') + integrator = _load_openmm_xml(wizard, 'integrator') + topology = _load_topology(wizard) n_steps_per_frame = wizard.ask_custom_eval( "How many MD steps per saved frame?", type_=int From c17f3d1d44c23a77011cb55a1a855213326e8c7b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 6 Mar 2021 18:35:47 +0100 Subject: [PATCH 009/251] more work toward using get_object --- paths_cli/wizard/cvs.py | 6 ++- paths_cli/wizard/load_from_ops.py | 48 +++++++++++-------- paths_cli/wizard/shooting.py | 19 ++++---- paths_cli/wizard/tps.py | 22 ++++----- paths_cli/wizard/wizard.py | 78 +++++++++++++++---------------- 5 files changed, 89 insertions(+), 84 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 14313578..0b0ac75e 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -12,7 +12,7 @@ else: HAS_MDTRAJ = True -def mdtraj_atom_helper(wizard, user_input, n_atoms): +def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov wizard.say("You should specify atom indices enclosed in double " "brackets, e.g, [" + str(list(range(n_atoms))) + "]") # TODO: implement the following: @@ -28,6 +28,7 @@ def mdtraj_atom_helper(wizard, user_input, n_atoms): def _get_topology(wizard): from paths_cli.wizard.engines import engines topology = None + # TODO: isn't this get_missing_object? while topology is None: if len(wizard.engines) == 0: # SHOULD NEVER GET HERE @@ -48,10 +49,10 @@ def _get_topology(wizard): return topology def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): - # TODO: move the logic here to parsing.tools arr = None helper = partial(mdtraj_atom_helper, n_atoms=n_atoms) while arr is None: + # switch to get_custom_eval atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", helper=helper) try: @@ -62,6 +63,7 @@ def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): continue try: + # move this logic to parsing.tools arr = np.array(indices) if arr.dtype != int: raise TypeError("Input is not integers") diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 638c8bd9..0bebbc06 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -1,5 +1,6 @@ LABEL = "Load existing from OPS file" from paths_cli.parameters import INPUT_FILE +from paths_cli.wizard.core import get_object def named_objs_helper(storage, store_name): def list_items(wizard, user_input): @@ -10,28 +11,35 @@ def list_items(wizard, user_input): return list_items +@get_object +def _get_ops_storage(wizard): + filename = wizard.ask("What file can it be found in?", + default='filename') + try: + storage = INPUT_FILE.get(filename) + except Exception as e: + wizard.exception(FILE_LOADING_ERROR_MSG, e) + return -def load_from_ops(wizard, store_name, obj_name): - wizard.say("Okay, we'll load it from an OPS file.") - storage = None - while storage is None: - filename = wizard.ask("What file can it be found in?", - default='filename') + return storage + +@get_object +def _get_ops_object(wizard, storage, store_name, obj_name): + name = wizard.ask(f"What's the name of the {obj_name} you want to " + "load? (Type '?' to get a list of them)", + helper=named_objs_helper(storage, store_name)) + if name: try: - storage = INPUT_FILE.get(filename) + obj = getattr(storage, store_name)[name] except Exception as e: - wizard.exception(FILE_LOADING_ERROR_MSG, e) - # TODO: error handling + wizard.exception("Something went wrong when loading " + f"{name}. Maybe check the spelling?", e) + return + else: + return obj - obj = None - while obj is None: - name = wizard.ask(f"What's the name of the {obj_name} you want to " - "load? (Type '?' to get a list of them)", - helper=named_objs_helper(storage, store_name)) - if name: - try: - obj = getattr(storage, store_name)[name] - except Exception as e: - wizard.exception("Something went wrong when loading " - f"{name}. Maybe check the spelling?", e) +def load_from_ops(wizard, store_name, obj_name): + wizard.say("Okay, we'll load it from an OPS file.") + storage = _get_ops_storage(wizard) + obj = _get_ops_object(wizard, storage, store_name, obj_name) return obj diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 7c0fd802..74d0f55d 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -49,11 +49,9 @@ def _get_selector(wizard, selectors): if selectors is None: selectors = SHOOTING_SELECTORS selector = None - while selector is None: - sel = wizard.ask_enumerate("How do you want to select shooting " - "points?", options=list(selectors.keys())) - selector = selectors[sel](wizard) - + sel = wizard.ask_enumerate("How do you want to select shooting " + "points?", options=list(selectors.keys())) + selector = selectors[sel](wizard) return selector def one_way_shooting(wizard, selectors=None, engine=None): @@ -112,12 +110,11 @@ def shooting(wizard, shooting_types=None, engine=None): if len(shooting_types) == 1: shooting_type = list(shooting_types.values())[0] else: - while shooting_type is None: - type_name = wizard.ask_enumerate( - "Select the type of shooting move.", - options=list(shooting_types.keys()) - ) - shooting_type = shooting_types[type_name] + type_name = wizard.ask_enumerate( + "Select the type of shooting move.", + options=list(shooting_types.keys()) + ) + shooting_type = shooting_types[type_name] shooting_strategy = shooting_type(wizard) return shooting_strategy diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 00170ec1..65aa230e 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -17,6 +17,7 @@ from functools import partial def _select_states(wizard, state_type): + # TODO: change this significantly ... I dislike in usage states = [] do_another = True while do_another: @@ -39,6 +40,7 @@ def _select_states(wizard, state_type): return state_objs def _get_pathlength(wizard): + # redo as get_custom_eval with int type pathlength = None while pathlength is None: len_str = wizard.ask("How long (in frames) do you want yout " @@ -58,6 +60,7 @@ def flex_length_tps_network(wizard): return network def fixed_length_tps_network(wizard): + import openpathsampling as paths pathlength = _get_pathlength(wizard) initial_states = _select_states(wizard, 'initial') final_states = _select_states(wizard, 'final') @@ -74,12 +77,11 @@ def tps_network(wizard): FIXED: paths.FixedLengthTPSNetwork, } network_type = None - while network_type is None: - network_type = wizard.ask_enumerate( - "Do you want to do flexible path length TPS (recommended) " - "or fixed path length TPS?", options=list(tps_types.keys()) - ) - network_class = tps_types[network_type] + network_type = wizard.ask_enumerate( + "Do you want to do flexible path length TPS (recommended) " + "or fixed path length TPS?", options=list(tps_types.keys()) + ) + network_class = tps_types[network_type] if network_type == FIXED: pathlength = _get_pathlength(wizard) @@ -102,7 +104,7 @@ def _get_network(wizard): networks = list(wizard.networks.keys()) sel = wizard.ask_enumerate("Which network would you like to use?", options=networks) - network = wizard.networks[network] + network = wizard.networks[sel] return network def tps_scheme(wizard, network=None): @@ -120,9 +122,6 @@ def tps_scheme(wizard, network=None): scheme.append(global_strategy) return scheme -def tps_finalize(wizard): - pass - def tps_setup(wizard): network = tps_network(wizard) scheme = tps_scheme(wizard, network) @@ -134,5 +133,4 @@ def tps_setup(wizard): if __name__ == "__main__": from paths_cli.wizard.wizard import Wizard wiz = Wizard({'tps_network': ('networks', 1, '='), - 'tps_scheme': ('schemes', 1, '='), - 'tps_finalize': (None, None, None)}) + 'tps_scheme': ('schemes', 1, '=')}) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index fe8e3dbd..c2ef44f8 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -10,11 +10,12 @@ flex_length_tps_network, fixed_length_tps_network, tps_scheme ) from paths_cli.wizard.tools import yes_no, a_an -from paths_cli.parsing.core import custom_eval +from paths_cli.wizard.core import get_object from paths_cli.wizard.errors import ( FILE_LOADING_ERROR_MSG, RestartObjectException ) from paths_cli.wizard.joke import name_joke +from paths_cli.parsing.core import custom_eval import shutil @@ -73,14 +74,13 @@ def _speak(self, content, preface): wrapped.append(wrap_line) self.console.print("\n".join(wrapped)) + @get_object def ask(self, question, options=None, default=None, helper=None): - result = None - while result is None: - result = self.console.input("🧙 " + question + " ") - self.console.print() - if helper and result.startswith("?"): - helper(self, result) - result = None + result = self.console.input("🧙 " + question + " ") + self.console.print() + if helper and result.startswith("?"): + helper(self, result) + return return result def say(self, content, preface="🧙 "): @@ -117,21 +117,20 @@ def ask_enumerate(self, question, options): return result # this should match the args for wizard.ask + @get_object def ask_custom_eval(self, question, options=None, default=None, helper=None, type_=float): - result = None - while result is None: - as_str = self.ask(question, options=options, default=default, - helper=helper) - try: - result = type_(custom_eval(as_str)) - except Exception as e: - self.exception(f"Sorry, I couldn't understand the input " - f"'{as_str}'", e) - result = None - + as_str = self.ask(question, options=options, default=default, + helper=helper) + try: + result = type_(custom_eval(as_str)) + except Exception as e: + self.exception(f"Sorry, I couldn't understand the input " + f"'{as_str}'", e) + return return result + def obj_selector(self, store_name, text_name, create_func): opts = {name: lambda wiz, o=obj: o for name, obj in getattr(self, store_name).items()} @@ -173,31 +172,32 @@ def register(self, obj, obj_type, store_name): store_dict[obj.name] = obj return obj + @get_object def get_storage(self): from openpathsampling.experimental.storage import Storage - storage = None - while storage is None: - filename = self.ask("Where would you like to save your setup " - "database?") - if not filename.endswith(".db"): - self.bad_input("Files produced by this wizard must end in " - "'.db'.") - continue - - if os.path.exists(filename): - overwrite = self.ask(f"{filename} exists. Overwrite it?", - options=["[Y]es", "[N]o"]) - overwrite = yes_no(overwrite) - if not overwrite: - continue - - try: - storage = Storage(filename, mode='w') - except Exception as e: - self.exception(FILE_LOADING_ERROR_MSG, e) + filename = self.ask("Where would you like to save your setup " + "database?") + if not filename.endswith(".db"): + self.bad_input("Files produced by this wizard must end in " + "'.db'.") + return + + if os.path.exists(filename): + overwrite = self.ask(f"{filename} exists. Overwrite it?", + options=["[Y]es", "[N]o"]) + overwrite = yes_no(overwrite) + if not overwrite: + return + + try: + storage = Storage(filename, mode='w') + except Exception as e: + self.exception(FILE_LOADING_ERROR_MSG, e) + return return storage + def _storage_description_line(self, store_name): store = getattr(self, store_name) if len(store) == 0: From 77fa5cec7c3952a292796c365663674dfea521a2 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 6 Mar 2021 21:24:36 +0100 Subject: [PATCH 010/251] tests for OpenMM wizard --- paths_cli/tests/wizard/test_openmm.py | 83 +++++++++++++++++++++++++++ paths_cli/wizard/openmm.py | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 paths_cli/tests/wizard/test_openmm.py diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py new file mode 100644 index 00000000..be03bc27 --- /dev/null +++ b/paths_cli/tests/wizard/test_openmm.py @@ -0,0 +1,83 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.openmm import ( + _load_openmm_xml, _load_topology, openmm +) + +@pytest.fixture +def ad_openmm(tmpdir): + """ + Provide directory with files to start alanine depeptide sim in OpenMM + """ + mm = pytest.importorskip('simtk.openmm') + u = pytest.importorskip('simtk.unit') + openmmtools = pytest.importorskip('openmmtools') + md = pytest.importorskip('mdtraj') + testsystem = openmmtools.testsystems.AlanineDipeptideVacuum() + integrator = openmmtools.integrators.VVVRIntegrator( + 300 * u.kelvin, + 1.0 / u.picosecond, + 2.0 * u.femtosecond + ) + traj = md.Trajectory([testsystem.positions.value_in_unit(u.nanometer)], + topology=testsystem.mdtraj_topology) + files = {'integrator.xml': integrator, + 'system.xml': testsystem.system} + with tmpdir.as_cwd(): + for fname, obj in files.items(): + with open(fname, mode='w') as f: + f.write(mm.XmlSerializer.serialize(obj)) + + traj.save('ad.pdb') + + return tmpdir + + +@pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) +def test_load_openmm_xml(ad_openmm, obj_type): + mm = pytest.importorskip('simtk.openmm') + filename = f"{obj_type}.xml" + inputs = [filename] + expected_count = 1 + if obj_type == 'foo': + inputs.append('integrator.xml') + expected_count = 2 + + wizard = mock_wizard(inputs) + superclass = {'integrator': mm.CustomIntegrator, + 'system': mm.System, + 'foo': mm.CustomIntegrator}[obj_type] + with ad_openmm.as_cwd(): + obj = _load_openmm_xml(wizard, obj_type) + assert isinstance(obj, superclass) + + assert wizard.console.input_call_count == expected_count + +@pytest.mark.parametrize('setup', ['normal', 'bad_filetype', 'no_file']) +def test_load_topology(ad_openmm, setup): + import openpathsampling as paths + inputs = {'normal': [], + 'bad_filetype': ['foo.bar'], + 'no_file': ['foo.pdb']}[setup] + expected_text = {'normal': "PDB file", + 'bad_filetype': 'trr', + 'no_file': 'No such file'}[setup] + inputs += ['ad.pdb'] + wizard = mock_wizard(inputs) + with ad_openmm.as_cwd(): + top = _load_topology(wizard) + + assert isinstance(top, paths.engines.openmm.topology.MDTrajTopology) + assert wizard.console.input_call_count == len(inputs) + assert expected_text in wizard.console.log_text + +def test_openmm(ad_openmm): + inputs = ['system.xml', 'integrator.xml', 'ad.pdb', '10', '10000'] + wizard = mock_wizard(inputs) + with ad_openmm.as_cwd(): + engine = openmm(wizard) + assert engine.n_frames_max == 10000 + assert engine.n_steps_per_frame == 10 diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index b6d75146..c56515f6 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -47,7 +47,7 @@ def _load_topology(wizard): def openmm(wizard): import openpathsampling as paths # quick exit if not installed; but should be impossible to get here - if not HAS_OPENMM: + if not HAS_OPENMM: # no-cov not_installed("OpenMM", "engine") return From 76ea517b5e113832d34d1fa51718786a39e81394 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Mar 2021 15:02:14 +0100 Subject: [PATCH 011/251] Prep tests for CVs --- paths_cli/tests/wizard/conftest.py | 50 +++++++++++++++++++++++++++ paths_cli/tests/wizard/mock_wizard.py | 7 +++- paths_cli/tests/wizard/test_openmm.py | 28 --------------- 3 files changed, 56 insertions(+), 29 deletions(-) create mode 100644 paths_cli/tests/wizard/conftest.py diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py new file mode 100644 index 00000000..de90aa5d --- /dev/null +++ b/paths_cli/tests/wizard/conftest.py @@ -0,0 +1,50 @@ +import pytest + +import openpathsampling as paths +import mdtraj as md + +@pytest.fixture +def ad_openmm(tmpdir): + """ + Provide directory with files to start alanine depeptide sim in OpenMM + """ + mm = pytest.importorskip('simtk.openmm') + u = pytest.importorskip('simtk.unit') + openmmtools = pytest.importorskip('openmmtools') + md = pytest.importorskip('mdtraj') + testsystem = openmmtools.testsystems.AlanineDipeptideVacuum() + integrator = openmmtools.integrators.VVVRIntegrator( + 300 * u.kelvin, + 1.0 / u.picosecond, + 2.0 * u.femtosecond + ) + traj = md.Trajectory([testsystem.positions.value_in_unit(u.nanometer)], + topology=testsystem.mdtraj_topology) + files = {'integrator.xml': integrator, + 'system.xml': testsystem.system} + with tmpdir.as_cwd(): + for fname, obj in files.items(): + with open(fname, mode='w') as f: + f.write(mm.XmlSerializer.serialize(obj)) + + traj.save('ad.pdb') + + return tmpdir + +@pytest.fixture +def ad_engine(ad_openmm): + with ad_openmm.as_cwd(): + pdb = md.load('ad.pdb') + topology = paths.engines.openmm.topology.MDTrajTopology( + pdb.topology + ) + engine = paths.engines.openmm.Engine( + system='system.xml', + integrator='integrator.xml', + topology=topology, + options={'n_steps_per_frame': 10, + 'n_frames_max': 10000} + ).named('ad_engine') + return engine + +# TODO: add fixtures for all the AD things: CVs, states diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py index 880bf6d6..a2b0a4b9 100644 --- a/paths_cli/tests/wizard/mock_wizard.py +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -28,7 +28,12 @@ def print(self, content=""): def input(self, content): self.input_call_count += 1 - user_input = next(self._input_iter) + try: + user_input = next(self._input_iter) + except StopIteration as e: + print(self.log_text) + raise e + self.log.append(content + " " + user_input) return user_input diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index be03bc27..348d06bf 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -7,34 +7,6 @@ _load_openmm_xml, _load_topology, openmm ) -@pytest.fixture -def ad_openmm(tmpdir): - """ - Provide directory with files to start alanine depeptide sim in OpenMM - """ - mm = pytest.importorskip('simtk.openmm') - u = pytest.importorskip('simtk.unit') - openmmtools = pytest.importorskip('openmmtools') - md = pytest.importorskip('mdtraj') - testsystem = openmmtools.testsystems.AlanineDipeptideVacuum() - integrator = openmmtools.integrators.VVVRIntegrator( - 300 * u.kelvin, - 1.0 / u.picosecond, - 2.0 * u.femtosecond - ) - traj = md.Trajectory([testsystem.positions.value_in_unit(u.nanometer)], - topology=testsystem.mdtraj_topology) - files = {'integrator.xml': integrator, - 'system.xml': testsystem.system} - with tmpdir.as_cwd(): - for fname, obj in files.items(): - with open(fname, mode='w') as f: - f.write(mm.XmlSerializer.serialize(obj)) - - traj.save('ad.pdb') - - return tmpdir - @pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) def test_load_openmm_xml(ad_openmm, obj_type): From 5d2e3f86e818220e2b581a38a4e8bb2409f3d533 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Mar 2021 15:03:18 +0100 Subject: [PATCH 012/251] Some CV cleanup; more periodicity to CVs --- paths_cli/wizard/core.py | 6 ++ paths_cli/wizard/cvs.py | 132 ++++++++++++++++++++++-------------- paths_cli/wizard/volumes.py | 19 +----- 3 files changed, 89 insertions(+), 68 deletions(-) diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index 34387569..b700ce9c 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -2,6 +2,10 @@ from paths_cli.wizard.joke import name_joke from paths_cli.wizard.tools import a_an +from collections import namedtuple + +WizardSay = namedtuple("WizardSay", ['msg', 'mode']) + def name(wizard, obj, obj_type, store_name, default=None): wizard.say(f"Now let's name your {obj_type}.") name = None @@ -72,3 +76,5 @@ def inner(*args, **kwargs): obj = func(*args, **kwargs) return obj return inner + + diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 0b0ac75e..1e28d90a 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -2,6 +2,8 @@ from paths_cli.parsing.tools import custom_eval from paths_cli.wizard.load_from_ops import load_from_ops from paths_cli.wizard.load_from_ops import LABEL as _load_label +from paths_cli.wizard.core import get_object + from functools import partial import numpy as np @@ -28,65 +30,67 @@ def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov def _get_topology(wizard): from paths_cli.wizard.engines import engines topology = None - # TODO: isn't this get_missing_object? - while topology is None: - if len(wizard.engines) == 0: - # SHOULD NEVER GET HERE - wizard.say("Hey, you need to define an MD engine before you " - "create CVs that refer to it. Let's do that now!") - engine = engines(wizard) - wizard.register(engine, 'engine', 'engines') - wizard.say("Now let's get back to defining your CV") - elif len(wizard.engines) == 1: - topology = list(wizard.engines.values())[0].topology - else: - engines = list(wizard.engines.keys) - name = wizard.ask("You have defined multiple engines. Which one " - "should I use to define your CV?", - options=engines) - topology = wizard.engines[name].topology + # TODO: this is very similar to get_missing_object, but has more + # reporting; is there some way to add the reporting to + # get_missing_object? + if len(wizard.engines) == 0: + # SHOULD NEVER GET HERE IF WIZARDS ARE DESIGNED CORRECTLY + wizard.say("Hey, you need to define an MD engine before you " + "create CVs that refer to it. Let's do that now!") + engine = engines(wizard) + wizard.register(engine, 'engine', 'engines') + wizard.say("Now let's get back to defining your CV.") + topology = engine.topology + elif len(wizard.engines) == 1: + topology = list(wizard.engines.values())[0].topology + else: + wizard.say("You have defined multiple engines, and need to pick " + "one to use to get a the topology for your CV.") + engine = wizard.obj_selector('engines', 'engine', engines) + topology = engine.topology + wizard.say("Now let's get back to defining your CV.") return topology +@get_object def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): - arr = None helper = partial(mdtraj_atom_helper, n_atoms=n_atoms) - while arr is None: - # switch to get_custom_eval - atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", - helper=helper) - try: - indices = custom_eval(atoms_str) - except Exception as e: - wizard.exception(f"Sorry, I did't understand '{atoms_str}'.", e) - mdtraj_atom_helper(wizard, '?', n_atoms) - continue - - try: - # move this logic to parsing.tools - arr = np.array(indices) - if arr.dtype != int: - raise TypeError("Input is not integers") - if arr.shape != (1, n_atoms): - # try to clean it up - if len(arr.shape) == 1 and arr.shape[0] == n_atoms: - arr.shape = (1, n_atoms) - else: - raise TypeError(f"Invalid input. Requires {n_atoms} " - "atoms.") - except Exception as e: - wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) - mdtraj_atom_helper(wizard, '?', n_atoms) - arr = None - continue + # switch to get_custom_eval + atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", + helper=helper) + try: + indices = custom_eval(atoms_str) + except Exception as e: + wizard.exception(f"Sorry, I did't understand '{atoms_str}'.", e) + mdtraj_atom_helper(wizard, '?', n_atoms) + return + + try: + # move this logic to parsing.tools + arr = np.array(indices) + if arr.dtype != int: + raise TypeError("Input is not integers") + if arr.shape != (1, n_atoms): + # try to clean it up + if len(arr.shape) == 1 and arr.shape[0] == n_atoms: + arr.shape = (1, n_atoms) + else: + raise TypeError(f"Invalid input. Requires {n_atoms} " + "atoms.") + except Exception as e: + wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) + mdtraj_atom_helper(wizard, '?', n_atoms) + return return arr + def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, - kwarg_name, n_atoms): + kwarg_name, n_atoms, period): from openpathsampling.experimental.storage.collective_variables import \ MDTrajFunctionCV wizard.say(f"We'll make a CV that measures the {cv_does_str}.") + period_min, period_max = period topology = _get_topology(wizard) indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms, cv_user_str=cv_user_prompt) @@ -99,7 +103,8 @@ def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, " Topology: " + repr(topology.mdtraj)) wizard.say(summary) - return MDTrajFunctionCV(func, topology, **kwargs) + return MDTrajFunctionCV(func, topology, period_min=period_min, + period_max=period_max, **kwargs) def distance(wizard): return _mdtraj_function_cv( @@ -108,7 +113,8 @@ def distance(wizard): cv_user_prompt="measure the distance between", func=md.compute_distances, kwarg_name='atom_pairs', - n_atoms=2 + n_atoms=2, + period=(None, None) ) def angle(wizard): @@ -118,7 +124,8 @@ def angle(wizard): cv_user_prompt="use to define the angle", func=md.compute_angles, kwarg_name='angle_indices', - n_atoms=3 + n_atoms=3, + period=(-np.pi, np.pi) ) def dihedral(wizard): @@ -128,13 +135,15 @@ def dihedral(wizard): cv_user_prompt="use to define the dihedral angle", func=md.compute_dihedrals, kwarg_name='indices', - n_atoms=4 + n_atoms=4, + period=(-np.pi, np.pi) ) def rmsd(wizard): raise NotImplementedError("RMSD has not yet been implemented") def coordinate(wizard): + # TODO: atom_index should be from wizard.ask_custom_eval atom_index = coord = None while atom_index is None: idx = wizard.ask("For which atom do you want to get the " @@ -153,6 +162,27 @@ def coordinate(wizard): except KeyError as e: wizard.bad_input("Please select one of 'x', 'y', or 'z'") +def _get_period(wizard): + # to be used in custom CVs ... or maybe just in the file CV itself? + is_periodic = period_min = period_max = None + while is_periodic is None: + is_periodic_char = wizard.ask("Is this CV periodic?", + options=["[Y]es", "[N]o"]) + is_periodic = {'y': True, 'n': False}[is_periodic_char] + + if is_periodic: + while period_min is None: + period_min = wizard.ask_custom_eval( + "What is the lower bound of the period?" + ) + while period_max is None: + period_max = wizard.ask_custom_eval( + "What is the upper bound of the period?" + ) + return period_min, period_max + + + SUPPORTED_CVS = {} if HAS_MDTRAJ: diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index e65fc263..75bc81e9 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -50,22 +50,7 @@ def cv_defined_volume(wizard): wizard.say("A CV-defined volume allows an interval in a CV.") cv = wizard.obj_selector('cvs', "CV", cvs) period_min = period_max = lambda_min = lambda_max = None - is_periodic = None - while is_periodic is None: - is_periodic_char = wizard.ask("Is this CV periodic?", - options=["[Y]es", "[N]o"]) - is_periodic = {'y': True, 'n': False}[is_periodic_char] - - if is_periodic: - while period_min is None: - period_min = wizard.ask_custom_eval( - "What is the lower bound of the period?" - ) - while period_max is None: - period_max = wizard.ask_custom_eval( - "What is the upper bound of the period?" - ) - + is_periodic = cv.is_periodic volume_bound_str = ("What is the {bound} allowed value for " f"'{cv.name}' in this volume?") @@ -82,7 +67,7 @@ def cv_defined_volume(wizard): if is_periodic: vol = paths.PeriodicCVDefinedVolume( cv, lambda_min=lambda_min, lambda_max=lambda_max, - period_min=period_min, period_max=period_max + period_min=cv.period_min, period_max=cv.period_max ) else: vol = paths.CVDefinedVolume( From b5ec9ce5742ee8682e4b2ce0707e7888b9d2ccbe Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Mar 2021 18:00:09 +0100 Subject: [PATCH 013/251] Wizard ready for 2-state TPS demo --- paths_cli/commands/wizard.py | 12 +++++++ paths_cli/tests/wizard/test_wizard.py | 1 + paths_cli/wizard/steps.py | 32 ++++++++++++++++++ paths_cli/wizard/two_state_tps.py | 33 +++++++++++++++++++ paths_cli/wizard/volumes.py | 10 +++--- paths_cli/wizard/wizard.py | 47 +++------------------------ 6 files changed, 88 insertions(+), 47 deletions(-) create mode 100644 paths_cli/commands/wizard.py create mode 100644 paths_cli/wizard/steps.py create mode 100644 paths_cli/wizard/two_state_tps.py diff --git a/paths_cli/commands/wizard.py b/paths_cli/commands/wizard.py new file mode 100644 index 00000000..e319b4d8 --- /dev/null +++ b/paths_cli/commands/wizard.py @@ -0,0 +1,12 @@ +import click +from paths_cli.wizard.two_state_tps import TWO_STATE_TPS_WIZARD + +@click.command( + 'wizard', + short_help="run wizard for setting up simulations", +) +def wizard(): + TWO_STATE_TPS_WIZARD.run_wizard() + +CLI = wizard +SECTION = "Simulation setup" diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 9ac556f6..1d0b0227 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -9,6 +9,7 @@ CoordinateFunctionCV from paths_cli.wizard.wizard import * +from paths_cli.wizard.steps import SINGLE_ENGINE_STEP diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py new file mode 100644 index 00000000..23f36ece --- /dev/null +++ b/paths_cli/wizard/steps.py @@ -0,0 +1,32 @@ +from collections import namedtuple +from functools import partial + +from paths_cli.wizard.cvs import cvs +from paths_cli.wizard.engines import engines +from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.tps import ( + flex_length_tps_network, fixed_length_tps_network, tps_scheme +) + +WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', + 'minimum', 'maximum']) + +SINGLE_ENGINE_STEP = WizardStep(func=engines, + display_name="engine", + store_name="engines", + minimum=1, + maximum=1) + +CVS_STEP = WizardStep(func=cvs, + display_name="CV", + store_name='cvs', + minimum=1, + maximum=float('inf')) + +MULTIPLE_STATES_STEP = WizardStep(func=partial(volumes, as_state=True), + display_name="state", + store_name="states", + minimum=2, + maximum=float('inf')) + + diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py new file mode 100644 index 00000000..ac5d4e53 --- /dev/null +++ b/paths_cli/wizard/two_state_tps.py @@ -0,0 +1,33 @@ +from paths_cli.wizard.steps import ( + SINGLE_ENGINE_STEP, CVS_STEP, WizardStep +) +from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.tps import tps_scheme +from paths_cli.wizard.wizard import Wizard + +def two_state_tps(wizard, fixed_length=False): + import openpathsampling as paths + wizard.say("Now let's define the stable states for your system. " + "Let's start with your initial state.") + initial_state = volumes(wizard, as_state=True, intro="") + wizard.register(initial_state, 'initial state', 'states') + wizard.say("Next let's define your final state.") + final_state = volumes(wizard, as_state=True, intro="") + wizard.register(final_state, 'final state', 'states') + if fixed_length: + ... + else: + network = paths.TPSNetwork(initial_state, final_state) + scheme = tps_scheme(wizard, network=network) + return scheme + + +TWO_STATE_TPS_WIZARD = Wizard([ + SINGLE_ENGINE_STEP, + CVS_STEP, + WizardStep(func=two_state_tps, + display_name="TPS setup", + store_name='schemes', + minimum=1, + maximum=1) +]) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 75bc81e9..c86e2e94 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -83,10 +83,12 @@ def cv_defined_volume(wizard): 'Complement of a volume (not in given volume)': negated_volume, } -def volumes(wizard, as_state=False): - intro = _vol_intro(wizard, as_state) - if intro is not None: - wizard.say(_vol_intro(wizard, as_state)) +def volumes(wizard, as_state=False, intro=None): + if intro is None: + intro = _vol_intro(wizard, as_state) + + if intro: # disallow None and "" + wizard.say(intro) wizard.say("You can describe this as either a range of values for some " "CV, or as some combination of other such volumes " diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index c2ef44f8..c63809a0 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -3,12 +3,6 @@ from functools import partial import textwrap -from paths_cli.wizard.cvs import cvs -from paths_cli.wizard.engines import engines -from paths_cli.wizard.volumes import volumes -from paths_cli.wizard.tps import ( - flex_length_tps_network, fixed_length_tps_network, tps_scheme -) from paths_cli.wizard.tools import yes_no, a_an from paths_cli.wizard.core import get_object from paths_cli.wizard.errors import ( @@ -247,6 +241,9 @@ def _ask_do_another(self, obj_type): return do_another def run_wizard(self): + self.start("Hi! I'm the OpenPathSampling Wizard.") + # TODO: next line is only temporary + self.say("Today I'll help you set up a 2-state TPS simulation.") self._patch() # try to hide the slowness of our first import for step in self.steps: req = step.store_name, step.minimum, step.maximum @@ -269,45 +266,9 @@ def run_wizard(self): storage = self.get_storage() self.save_to_file(storage) -from collections import namedtuple -WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', - 'minimum', 'maximum']) - -SINGLE_ENGINE_STEP = WizardStep(func=engines, - display_name="engine", - store_name="engines", - minimum=1, - maximum=1) - -CVS_STEP = WizardStep(func=cvs, - display_name="CV", - store_name='cvs', - minimum=1, - maximum=float('inf')) - -MULTIPLE_STATES_STEP = WizardStep(func=partial(volumes, as_state=True), - display_name="state", - store_name="states", - minimum=2, - maximum=float('inf')) - -FLEX_LENGTH_TPS_WIZARD = Wizard([ - SINGLE_ENGINE_STEP, - CVS_STEP, - MULTIPLE_STATES_STEP, - WizardStep(func=flex_length_tps_network, - display_name="network", - store_name="networks", - minimum=1, - maximum=1), - WizardStep(func=tps_scheme, - display_name="move scheme", - store_name="schemes", - minimum=1, - maximum=1), -]) # FIXED_LENGTH_TPS_WIZARD +# MULTIPLE_STATE_TPS_WIZARD # TWO_STATE_TIS_WIZARD # MULTIPLE_STATE_TIS_WIZARD # MULTIPLE_INTERFACE_SET_TIS_WIZARD From b32d03a1767a792d390d0db44f5b42d2644ec21e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Mar 2021 19:49:32 +0100 Subject: [PATCH 014/251] Add tests for CV wizards --- paths_cli/parsing/tools.py | 28 +++++++-- paths_cli/tests/wizard/test_cvs.py | 95 ++++++++++++++++++++++++++++++ paths_cli/wizard/cvs.py | 39 ++++-------- paths_cli/wizard/wizard.py | 3 + 4 files changed, 133 insertions(+), 32 deletions(-) create mode 100644 paths_cli/tests/wizard/test_cvs.py diff --git a/paths_cli/parsing/tools.py b/paths_cli/parsing/tools.py index 52970a1d..77b48380 100644 --- a/paths_cli/parsing/tools.py +++ b/paths_cli/parsing/tools.py @@ -1,3 +1,5 @@ +import numpy as np + def custom_eval(obj, named_objs=None): string = str(obj) # TODO: check that the only attribute access comes from a whitelist @@ -11,10 +13,24 @@ def custom_eval(obj, named_objs=None): class UnknownAtomsError(RuntimeError): pass -def mdtraj_parse_atomlist(inp_str, topology): +def mdtraj_parse_atomlist(inp_str, n_atoms, topology=None): + """ + n_atoms: int + number of atoms expected + """ + # TODO: change n_atoms to the shape desired? # TODO: enable the parsing of either string-like atom labels or numeric - try: - arr = custom_eval(inp_str) - except: - pass # on any error, we do it the hard way - pass + indices = custom_eval(inp_str) + + arr = np.array(indices) + if arr.dtype != int: + raise TypeError("Input is not integers") + if arr.shape != (1, n_atoms): + # try to clean it up + if len(arr.shape) == 1 and arr.shape[0] == n_atoms: + arr.shape = (1, n_atoms) + else: + raise TypeError(f"Invalid input. Requires {n_atoms} " + "atoms.") + + return arr diff --git a/paths_cli/tests/wizard/test_cvs.py b/paths_cli/tests/wizard/test_cvs.py new file mode 100644 index 00000000..69bea11a --- /dev/null +++ b/paths_cli/tests/wizard/test_cvs.py @@ -0,0 +1,95 @@ +import pytest +import mock +import numpy as np + +from functools import partial + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.cvs import ( + _get_topology, _get_atom_indices, distance, angle, dihedral, rmsd, + coordinate, cvs, SUPPORTED_CVS +) + +import openpathsampling as paths +from openpathsampling.experimental.storage.collective_variables import \ + MDTrajFunctionCV +from openpathsampling.tests.test_helpers import make_1d_traj + +@pytest.mark.parametrize('n_engines', [0, 1, 2]) +def test_get_topology(ad_engine, n_engines): + inputs = {0: [], 1: [], 2: ['1']}[n_engines] + wizard = mock_wizard(inputs) + engines = [ad_engine, paths.engines.NoEngine(None).named('foo')] + wizard.engines = {eng.name: eng for eng in engines[:n_engines]} + def mock_register(obj, obj_type, store_name): + wizard.engines[obj.name] = obj + wizard.register = mock_register + + mock_engines = mock.Mock(return_value=ad_engine) + patch_loc = 'paths_cli.wizard.engines.engines' + with mock.patch(patch_loc, new=mock_engines): + topology = _get_topology(wizard) + assert isinstance(topology, + paths.engines.openmm.topology.MDTrajTopology) + +@pytest.mark.parametrize('inputs', [ + (['[[1, 2]]']), (['1, 2']), + (['[[1, 2, 3]]', '[[1, 2]]']), +]) +def test_get_atom_indices(ad_engine, inputs): + wizard = mock_wizard(inputs) + arr = _get_atom_indices(wizard, ad_engine.topology, 2, "use") + np.testing.assert_array_equal(arr, np.array([[1, 2]])) + assert wizard.console.input_call_count == len(inputs) + if len(inputs) > 1: + assert "I didn't understand" in wizard.console.log_text + +def _mdtraj_function_test(wizard, func, md_func, ad_openmm, ad_engine): + md = pytest.importorskip('mdtraj') + wizard.engines[ad_engine.name] = ad_engine + with ad_openmm.as_cwd(): + cv = func(wizard) + pdb = md.load('ad.pdb') + assert isinstance(cv, MDTrajFunctionCV) + traj = paths.engines.openmm.trajectory_from_mdtraj(pdb) + np.testing.assert_array_almost_equal(md_func(pdb)[0], cv(traj)) + +def test_distance(ad_openmm, ad_engine): + md = pytest.importorskip('mdtraj') + wizard = mock_wizard(['0, 1']) + md_func = partial(md.compute_distances, atom_pairs=[[0, 1]]) + _mdtraj_function_test(wizard, distance, md_func, ad_openmm, ad_engine) + +def test_angle(ad_openmm, ad_engine): + md = pytest.importorskip('mdtraj') + wizard = mock_wizard(['0, 1, 2']) + md_func = partial(md.compute_angles, angle_indices=[[0, 1, 2]]) + _mdtraj_function_test(wizard, angle, md_func, ad_openmm, ad_engine) + +def test_dihedral(ad_openmm, ad_engine): + md = pytest.importorskip('mdtraj') + wizard = mock_wizard(['0, 1, 2, 3']) + md_func = partial(md.compute_dihedrals, indices=[[0, 1, 2, 3]]) + _mdtraj_function_test(wizard, dihedral, md_func, ad_openmm, ad_engine) + +@pytest.mark.parametrize('inputs', [ + (['0', 'x']), ('foo', '0', 'x'), (['0', 'q', 'x']) +]) +def test_coordinate(inputs): + wizard = mock_wizard(inputs) + cv = coordinate(wizard) + traj = make_1d_traj([5.0]) + assert cv(traj[0]) == 5.0 + if 'foo' in inputs: + assert "I can't make an atom index" in wizard.console.log_text + if 'q' in inputs: + assert "Please select one of" in wizard.console.log_text + +@pytest.mark.parametrize('inputs', [(['foo', 'Distance']), (['Distance'])]) +def test_cvs(inputs): + wizard = mock_wizard(inputs) + say_hello = mock.Mock(return_value="hello") + with mock.patch.dict(SUPPORTED_CVS, {'Distance': say_hello}): + assert cvs(wizard) == "hello" + assert wizard.console.input_call_count == len(inputs) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 1e28d90a..059a034e 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,5 +1,5 @@ from paths_cli.wizard.engines import engines -from paths_cli.parsing.tools import custom_eval +from paths_cli.parsing.tools import custom_eval, mdtraj_parse_atomlist from paths_cli.wizard.load_from_ops import load_from_ops from paths_cli.wizard.load_from_ops import LABEL as _load_label from paths_cli.wizard.core import get_object @@ -59,26 +59,9 @@ def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", helper=helper) try: - indices = custom_eval(atoms_str) + arr = mdtraj_parse_atomlist(atoms_str, n_atoms, topology) except Exception as e: - wizard.exception(f"Sorry, I did't understand '{atoms_str}'.", e) - mdtraj_atom_helper(wizard, '?', n_atoms) - return - - try: - # move this logic to parsing.tools - arr = np.array(indices) - if arr.dtype != int: - raise TypeError("Input is not integers") - if arr.shape != (1, n_atoms): - # try to clean it up - if len(arr.shape) == 1 and arr.shape[0] == n_atoms: - arr.shape = (1, n_atoms) - else: - raise TypeError(f"Invalid input. Requires {n_atoms} " - "atoms.") - except Exception as e: - wizard.exception(f"Sorry, I did't understand '{atoms_str}'", e) + wizard.exception(f"Sorry, I didn't understand '{atoms_str}'.", e) mdtraj_atom_helper(wizard, '?', n_atoms) return @@ -144,6 +127,8 @@ def rmsd(wizard): def coordinate(wizard): # TODO: atom_index should be from wizard.ask_custom_eval + from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV atom_index = coord = None while atom_index is None: idx = wizard.ask("For which atom do you want to get the " @@ -162,6 +147,10 @@ def coordinate(wizard): except KeyError as e: wizard.bad_input("Please select one of 'x', 'y', or 'z'") + cv = CoordinateFunctionCV(lambda snap: snap.xyz[atom_index][coord]) + return cv + + def _get_period(wizard): # to be used in custom CVs ... or maybe just in the file CV itself? is_periodic = period_min = period_max = None @@ -206,14 +195,12 @@ def cvs(wizard): "collective variables (CVs). We'll use these to define " "things like stable states.") cv_names = list(SUPPORTED_CVS.keys()) - cv = None - while cv is None: - cv_type = wizard.ask_enumerate("What kind of CV do you want to " - "define?", options=cv_names) - cv = SUPPORTED_CVS[cv_type](wizard) + cv_type = wizard.ask_enumerate("What kind of CV do you want to " + "define?", options=cv_names) + cv = SUPPORTED_CVS[cv_type](wizard) return cv -if __name__ == "__main__": +if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard wiz = Wizard({}) cvs(wiz) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index c63809a0..b82adfb2 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -101,6 +101,9 @@ def ask_enumerate(self, question, options): choice = self.ask("Please select a number:", options=[str(i+1) for i in range(len(options))]) + if choice in options: + return choice + try: num = int(choice) - 1 result = options[num] From a52bac0b3aaf9773638f26f2359c28a7e7efe554 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 8 Mar 2021 14:06:02 +0100 Subject: [PATCH 015/251] tests for volume wizards --- paths_cli/tests/wizard/test_volumes.py | 138 +++++++++++++++++++++++++ paths_cli/wizard/volumes.py | 3 +- 2 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 paths_cli/tests/wizard/test_volumes.py diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py new file mode 100644 index 00000000..1b8495f4 --- /dev/null +++ b/paths_cli/tests/wizard/test_volumes.py @@ -0,0 +1,138 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.volumes import ( + _vol_intro, intersection_volume, union_volume, negated_volume, + cv_defined_volume, volumes, SUPPORTED_VOLUMES +) + +import openpathsampling as paths +from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV + +from openpathsampling.tests.test_helpers import make_1d_traj + +def _wrap(x, period_min, period_max): + # used in testing periodic CVs + while x >= period_max: + x -= period_max - period_min + while x < period_min: + x += period_max - period-min + return x + + +@pytest.fixture +def volume_setup(): + cv = CoordinateFunctionCV(lambda snap: snap.xyz[0][0]).named('x') + vol1 = paths.CVDefinedVolume(cv, 0.0, 1.0) + vol2 = paths.CVDefinedVolume(cv, 0.5, 1.5) + return vol1, vol2 + +@pytest.mark.parametrize('as_state,has_state', [ + (True, False), (True, True), (False, False) +]) +def test_vol_intro(as_state, has_state): + wizard = mock_wizard([]) + wizard.requirements['state'] = ('states', 2, float('inf')) + if has_state: + wizard.states['foo'] = 'placeholder' + + intro = _vol_intro(wizard, as_state) + + if as_state and has_state: + assert "another stable state" in intro + elif as_state and not has_state: + assert "You'll need to define" in intro + elif not as_state: + assert intro is None + else: + raise RuntimeError("WTF?") + +def _binary_volume_test(volume_setup, func): + vol1, vol2 = volume_setup + wizard = mock_wizard([]) + mock_volumes = mock.Mock(side_effect=[vol1, vol2]) + with mock.patch('paths_cli.wizard.volumes.volumes', new=mock_volumes): + vol = func(wizard) + + assert "first volume" in wizard.console.log_text + assert "second volume" in wizard.console.log_text + assert "Created" in wizard.console.log_text + return wizard, vol + +def test_intersection_volume(volume_setup): + wizard, vol = _binary_volume_test(volume_setup, intersection_volume) + assert "intersection" in wizard.console.log_text + traj = make_1d_traj([0.25, 0.75]) + assert not vol(traj[0]) + assert vol(traj[1]) + +def test_union_volume(volume_setup): + wizard, vol = _binary_volume_test(volume_setup, union_volume) + assert "union" in wizard.console.log_text + traj = make_1d_traj([0.25, 0.75, 1.75]) + assert vol(traj[0]) + assert vol(traj[1]) + assert not vol(traj[2]) + +def test_negated_volume(volume_setup): + init_vol, _ = volume_setup + traj = make_1d_traj([0.5, 1.5]) + assert init_vol(traj[0]) + assert not init_vol(traj[1]) + wizard = mock_wizard([]) + mock_vol = mock.Mock(return_value=init_vol) + with mock.patch('paths_cli.wizard.volumes.volumes', new=mock_vol): + vol = negated_volume(wizard) + + assert "not in" in wizard.console.log_text + assert not vol(traj[0]) + assert vol(traj[1]) + +@pytest.mark.parametrize('periodic', [True, False]) +def test_cv_defined_volume(periodic): + if periodic: + min_ = 0.0 + max_ = 1.0 + cv = CoordinateFunctionCV( + lambda snap: _wrap(snap.xyz[0][0], period_min=min_, + period_max=max_), + period_min=min_, period_max=max_ + ).named('x') + inputs = ['x', '0.75', '1.25'] + in_state = make_1d_traj([0.2, 0.8]) + out_state = make_1d_traj([0.5]) + else: + cv = CoordinateFunctionCV(lambda snap: snap.xyz[0][0]).named('x') + inputs = ['x', '0.0', '1.0'] + in_state = make_1d_traj([0.5]) + out_state = make_1d_traj([-0.1, 1.1]) + wizard = mock_wizard(inputs) + wizard.cvs[cv.name] = cv + vol = cv_defined_volume(wizard) + assert "interval" in wizard.console.log_text + for snap in in_state: + assert vol(snap) + for snap in out_state: + assert not vol(snap) + +@pytest.mark.parametrize('intro', [None, "", "foo"]) +def test_volumes(intro): + say_hello = mock.Mock(return_value="hello!") + wizard = mock_wizard(['Hello world']) + with mock.patch.dict(SUPPORTED_VOLUMES, {'Hello world': say_hello}): + assert volumes(wizard, intro=intro) == "hello!" + assert wizard.console.input_call_count == 1 + + + n_statements = 2 * (1 + 3) + # 2: line and blank; (1 + 3): 1 in volumes + 3 in ask_enumerate + + if intro == 'foo': + assert 'foo' in wizard.console.log_text + assert len(wizard.console.log) == n_statements + 2 # from intro + else: + assert 'foo' not in wizard.console.log_text + assert len(wizard.console.log) == n_statements diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index c86e2e94..e64e7211 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -105,8 +105,7 @@ def volumes(wizard, as_state=False, intro=None): return vol -if __name__ == "__main__": +if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard wiz = Wizard({'states': ('states', 1, '+')}) volumes(wiz, as_state=True) - From a9c4d960ff325c1d3878b94a7ddcb68b143a5b56 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 8 Mar 2021 14:06:17 +0100 Subject: [PATCH 016/251] simplify code to reflect current usage --- paths_cli/wizard/shooting.py | 28 ++++----- paths_cli/wizard/steps.py | 4 +- paths_cli/wizard/tps.py | 107 +---------------------------------- paths_cli/wizard/wizard.py | 1 - 4 files changed, 16 insertions(+), 124 deletions(-) diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 74d0f55d..f9ad869d 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -22,22 +22,22 @@ def gaussian_selector(wizard): selector = paths.GaussianBiasSelector(cv, alpha=alpha, l_0=l_0) return selector -def random_velocities(wizard): - pass +# def random_velocities(wizard): + # pass -def gaussian_momentum_shift(wizard): - pass +# def gaussian_momentum_shift(wizard): + # pass -def get_allowed_modifiers(engine): - allowed = [] - randomize_attribs = ['randomize_velocities', 'apply_constraints'] - if any(hasattr(engine, attr) for attr in randomize_attribs): - allowed.append('random_velocities') +# def get_allowed_modifiers(engine): + # allowed = [] + # randomize_attribs = ['randomize_velocities', 'apply_constraints'] + # if any(hasattr(engine, attr) for attr in randomize_attribs): + # allowed.append('random_velocities') - if not engine.has_constraints(): - allowed.append('velocity_changers') + # if not engine.has_constraints(): + # allowed.append('velocity_changers') - return allowed + # return allowed SHOOTING_SELECTORS = { 'Uniform random': uniform_selector, @@ -64,8 +64,8 @@ def one_way_shooting(wizard, selectors=None, engine=None): strat = strategies.OneWayShootingStrategy(selector=selector) return strat -def two_way_shooting(wizard, selectors=None, modifiers=None): - pass +# def two_way_shooting(wizard, selectors=None, modifiers=None): + # pass def spring_shooting(wizard, engine=None): from openpathsampling import strategies diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py index 23f36ece..e77e72b2 100644 --- a/paths_cli/wizard/steps.py +++ b/paths_cli/wizard/steps.py @@ -4,9 +4,7 @@ from paths_cli.wizard.cvs import cvs from paths_cli.wizard.engines import engines from paths_cli.wizard.volumes import volumes -from paths_cli.wizard.tps import ( - flex_length_tps_network, fixed_length_tps_network, tps_scheme -) +from paths_cli.wizard.tps import tps_scheme WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', 'minimum', 'maximum']) diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 65aa230e..c4386e16 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -1,100 +1,8 @@ from paths_cli.wizard.tools import a_an from paths_cli.wizard.core import get_missing_object -from paths_cli.wizard.shooting import ( - shooting, - # ALGORITHMS - one_way_shooting, - two_way_shooting, - spring_shooting, - # SELECTORS - uniform_selector, - gaussian_selector, - # MODIFIERS - random_velocities, - # all_atom_delta_v, - # single_atom_delta_v, -) +from paths_cli.wizard.shooting import shooting from functools import partial -def _select_states(wizard, state_type): - # TODO: change this significantly ... I dislike in usage - states = [] - do_another = True - while do_another: - options = [name for name in wizard.states - if name not in states] - done = f"No more {state_type} states to select" - if len(states) >= 1: - options.append(done) - - state = wizard.ask_enumerate( - f"Pick a state to use for as {a_an(state_type)} {state_type} " - "state", options=options - ) - if state == done: - do_another = False - else: - states.append(state) - - state_objs = [wizard.states[state] for state in states] - return state_objs - -def _get_pathlength(wizard): - # redo as get_custom_eval with int type - pathlength = None - while pathlength is None: - len_str = wizard.ask("How long (in frames) do you want yout " - "trajectories to be?") - try: - pathlength = int(len_str) - except Exceptions as e: - wizard.exception(f"Sorry, I couldn't make '{len_str}' into " - "an integer.", e) - return pathlength - -def flex_length_tps_network(wizard): - import openpathsampling as paths - initial_states = _select_states(wizard, 'initial') - final_states = _select_states(wizard, 'final') - network = paths.TPSNetwork(initial_states, final_states) - return network - -def fixed_length_tps_network(wizard): - import openpathsampling as paths - pathlength = _get_pathlength(wizard) - initial_states = _select_states(wizard, 'initial') - final_states = _select_states(wizard, 'final') - network = paths.FixedLengthTPSNetwork(initial_states, final_states, - length=pathlength) - return network - -def tps_network(wizard): - import openpathsampling as paths - FIXED = "Fixed length TPS" - FLEX = "Flexible length TPS" - tps_types = { - FLEX: paths.TPSNetwork, - FIXED: paths.FixedLengthTPSNetwork, - } - network_type = None - network_type = wizard.ask_enumerate( - "Do you want to do flexible path length TPS (recommended) " - "or fixed path length TPS?", options=list(tps_types.keys()) - ) - network_class = tps_types[network_type] - - if network_type == FIXED: - pathlength = _get_pathlength(wizard) - network_class = partial(network_class, length=pathlength) - - initial_states = _select_states(wizard, 'initial') - final_states = _select_states(wizard, 'final') - - # TODO: summary - - obj = network_class(initial_states, final_states) - return obj - def _get_network(wizard): if len(wizard.networks) == 0: network = tps_network(wizard) @@ -121,16 +29,3 @@ def tps_scheme(wizard, network=None): scheme.append(shooting_strategy) scheme.append(global_strategy) return scheme - -def tps_setup(wizard): - network = tps_network(wizard) - scheme = tps_scheme(wizard, network) - # name it - # provide final info on how to use it - return scheme - - -if __name__ == "__main__": - from paths_cli.wizard.wizard import Wizard - wiz = Wizard({'tps_network': ('networks', 1, '='), - 'tps_scheme': ('schemes', 1, '=')}) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index b82adfb2..f679d0e7 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -136,7 +136,6 @@ def obj_selector(self, store_name, text_name, create_func): sel = self.ask_enumerate(f"Which {text_name} would you like to " "use?", list(opts.keys())) obj = opts[sel](self) - print(sel, obj) if sel == create_new: obj = self.register(obj, text_name, store_name) return obj From a85118fd5544a79d93d3b2176815a4db798c5494 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 8 Mar 2021 20:46:20 +0100 Subject: [PATCH 017/251] more cleanup of unused --- paths_cli/wizard/cvs.py | 21 --------------------- paths_cli/wizard/tps.py | 12 ------------ 2 files changed, 33 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 059a034e..c6345878 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -151,27 +151,6 @@ def coordinate(wizard): return cv -def _get_period(wizard): - # to be used in custom CVs ... or maybe just in the file CV itself? - is_periodic = period_min = period_max = None - while is_periodic is None: - is_periodic_char = wizard.ask("Is this CV periodic?", - options=["[Y]es", "[N]o"]) - is_periodic = {'y': True, 'n': False}[is_periodic_char] - - if is_periodic: - while period_min is None: - period_min = wizard.ask_custom_eval( - "What is the lower bound of the period?" - ) - while period_max is None: - period_max = wizard.ask_custom_eval( - "What is the upper bound of the period?" - ) - return period_min, period_max - - - SUPPORTED_CVS = {} if HAS_MDTRAJ: diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index c4386e16..27f783a3 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -3,18 +3,6 @@ from paths_cli.wizard.shooting import shooting from functools import partial -def _get_network(wizard): - if len(wizard.networks) == 0: - network = tps_network(wizard) - elif len(wizard.networks) == 1: - network = list(wizard.networks.values())[0] - else: - networks = list(wizard.networks.keys()) - sel = wizard.ask_enumerate("Which network would you like to use?", - options=networks) - network = wizard.networks[sel] - return network - def tps_scheme(wizard, network=None): import openpathsampling as paths from openpathsampling import strategies From 42aa3c9c9e47e1bb4f65d541daeddb7f4028eb85 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 18 Mar 2021 18:09:42 +0100 Subject: [PATCH 018/251] More tests; work toward handling Spring Shooting --- paths_cli/tests/wizard/conftest.py | 22 +++++++++++++++++++++- paths_cli/wizard/shooting.py | 16 ++++++++++------ paths_cli/wizard/tps.py | 8 +++++++- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index de90aa5d..d3c2447c 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -47,4 +47,24 @@ def ad_engine(ad_openmm): ).named('ad_engine') return engine -# TODO: add fixtures for all the AD things: CVs, states +@pytest.fixture +def toy_engine(): + pes = (paths.engines.toy.OuterWalls([1.0, 1.0], [1.0, 1.0]) + + paths.engines.toy.Gaussian(-1.0, [12.0, 12.0], [-0.5, 0.0]) + + paths.engines.toy.Gaussian(-1.0, [12.0, 12.0], [0.5, 0.0])) + topology = paths.engines.toy.Topology(n_spatial=2, + masses=[1.0], + pes=pes) + integ = paths.engines.toy.LangevinBAOABIntegrator( + dt=0.02, + temperature=0.1, + gamma=2.5 + ) + options = {'integ': integ, + 'n_frames_max': 5000, + 'n_steps_per_frame': 1} + engine = paths.engines.toy.Engine( + options=options, + topology=topology + ).named('toy-engine') + return engine diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index f9ad869d..91096b9c 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -12,8 +12,10 @@ def uniform_selector(wizard): def gaussian_selector(wizard): import openpathsampling as paths - cv = wizard.ask_enumerate("Which CV do you want the Gaussian to be " - "based on?", options=wizard.cvs.keys()) + cv_name = wizard.ask_enumerate("Which CV do you want the Gaussian to " + "be based on?", + options=wizard.cvs.keys()) + cv = wizard.cvs[cv_name] l_0 = wizard.ask_custom_eval(f"At what value of {cv.name} should the " "Gaussian be centered?") std = wizard.ask_custom_eval("What should be the standard deviation of " @@ -45,7 +47,7 @@ def gaussian_selector(wizard): } -def _get_selector(wizard, selectors): +def _get_selector(wizard, selectors=None): if selectors is None: selectors = SHOOTING_SELECTORS selector = None @@ -61,7 +63,8 @@ def one_way_shooting(wizard, selectors=None, engine=None): engines) selector = _get_selector(wizard, selectors) - strat = strategies.OneWayShootingStrategy(selector=selector) + strat = strategies.OneWayShootingStrategy(selector=selector, + engine=engine) return strat # def two_way_shooting(wizard, selectors=None, modifiers=None): @@ -78,8 +81,9 @@ def spring_shooting(wizard, engine=None): ) k_spring = wizard.ask_custom_eval("What is the spring constant k?", type_=float) - strat = strategies.SpringShootingStrategy(delta_max=delta_max, - k_spring=k_spring) + strat = strategies.SpringShootingMoveScheme( + delta_max=delta_max, k_spring=k_spring, engine=engine + ) return strat diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 27f783a3..c1134d93 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -3,6 +3,7 @@ from paths_cli.wizard.shooting import shooting from functools import partial + def tps_scheme(wizard, network=None): import openpathsampling as paths from openpathsampling import strategies @@ -10,7 +11,12 @@ def tps_scheme(wizard, network=None): network = get_missing_object(wizard, wizard.networks, 'network', tps_network) - shooting_strategy = shooting(wizard) + shooting_strategy = shooting(wizard, network) + + if isinstance(shooting_strategy, paths.MoveScheme): + # this means we got a fixed scheme and can't do strategies + return shooting_strategy + # TODO: add an option for shifting maybe? global_strategy = strategies.OrganizeByMoveGroupStrategy() scheme = paths.MoveScheme(network) From 13f7108f688d1dcb7d1d8df399e92f0ad8730f93 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 18 Mar 2021 18:29:24 +0100 Subject: [PATCH 019/251] fix up tests --- paths_cli/wizard/shooting.py | 11 +++++++---- paths_cli/wizard/tps.py | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 91096b9c..0a96033e 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -1,3 +1,5 @@ +from functools import partial + from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.engines import engines from paths_cli.wizard.cvs import cvs @@ -71,7 +73,7 @@ def one_way_shooting(wizard, selectors=None, engine=None): # pass def spring_shooting(wizard, engine=None): - from openpathsampling import strategies + import openpathsampling as paths if engine is None: engine = get_missing_object(wizard, wizard.engines, 'engine', engines) @@ -81,9 +83,10 @@ def spring_shooting(wizard, engine=None): ) k_spring = wizard.ask_custom_eval("What is the spring constant k?", type_=float) - strat = strategies.SpringShootingMoveScheme( - delta_max=delta_max, k_spring=k_spring, engine=engine - ) + strat = partial(paths.SpringShootingMoveScheme, + delta_max=delta_max, + k_spring=k_spring, + engine=engine) return strat diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index c1134d93..2e5996b4 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -13,9 +13,9 @@ def tps_scheme(wizard, network=None): shooting_strategy = shooting(wizard, network) - if isinstance(shooting_strategy, paths.MoveScheme): + if not isinstance(shooting_strategy, paths.MoveStrategy): # this means we got a fixed scheme and can't do strategies - return shooting_strategy + return shooting_strategy(network=network) # TODO: add an option for shifting maybe? global_strategy = strategies.OrganizeByMoveGroupStrategy() From 9d6c3dda089d6b5c5caf515fb08d12f6ca425236 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 18 Mar 2021 20:33:33 +0100 Subject: [PATCH 020/251] Add tests for shooting, load_from_ops --- paths_cli/tests/wizard/test_load_from_ops.py | 98 ++++++++++++++++++++ paths_cli/tests/wizard/test_shooting.py | 67 +++++++++++++ paths_cli/wizard/load_from_ops.py | 4 +- 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 paths_cli/tests/wizard/test_load_from_ops.py create mode 100644 paths_cli/tests/wizard/test_shooting.py diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py new file mode 100644 index 00000000..38601ad1 --- /dev/null +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -0,0 +1,98 @@ +import pytest +import mock + +import openpathsampling as paths + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.load_from_ops import ( + named_objs_helper, _get_ops_storage, _get_ops_object, load_from_ops +) + +# for some reason I couldn't get these to work with MagicMock +class NamedObj: + def __init__(self, name): + self.name = name + self.is_named = True + +class FakeStore: + def __init__(self, objects): + self._objects = objects + self._named_objects = {obj.name: obj for obj in objects} + + def __getitem__(self, key): + if isinstance(key, int): + return self._objects[key] + elif isinstance(key, str): + return self._named_objects[key] + else: + raise TypeError("Huh?") + + def __iter__(self): + return iter(self._objects) + +class FakeStorage: + def __init__(self, foo): + self.foo = foo + +@pytest.fixture +def ops_file_fixture(): + # store name 'foo', objects named 'bar', 'baz' + bar = NamedObj('bar') + baz = NamedObj('baz') + foo = FakeStore([bar, baz]) + storage = FakeStorage(foo) + return storage + +def test_named_objs_helper(ops_file_fixture): + wizard = mock_wizard([]) + helper_func = named_objs_helper(ops_file_fixture, 'foo') + helper_func(wizard, 'any') + assert "what I found" in wizard.console.log_text + assert "bar" in wizard.console.log_text + assert "baz" in wizard.console.log_text + +@pytest.mark.parametrize('with_failure', [False, True]) +def test_get_ops_storage(tmpdir, with_failure): + fake_file = tmpdir / 'foo.db' + fake_file.write_text('bar', 'utf-8') + + failure_text = ['baz.db'] if with_failure else [] + wizard = mock_wizard(failure_text + [str(fake_file)]) + + with mock.patch('paths_cli.wizard.load_from_ops.INPUT_FILE', + new=mock.Mock(get=open)): + storage = _get_ops_storage(wizard) + assert storage.read() == 'bar' + if with_failure: + assert 'something went wrong' in wizard.console.log_text + else: + assert 'something went wrong' not in wizard.console.log_text + +@pytest.mark.parametrize('with_failure', [False, True]) +def test_get_ops_object(ops_file_fixture, with_failure): + failure_text = ['qux'] if with_failure else [] + wizard = mock_wizard(failure_text + ['bar']) + obj = _get_ops_object(wizard, ops_file_fixture, + store_name='foo', + obj_name='FOOMAGIC') + assert isinstance(obj, NamedObj) + assert obj.name == 'bar' + log = wizard.console.log_text + assert 'name of the FOOMAGIC' in log + fail_msg = 'Something went wrong' + if with_failure: + assert fail_msg in log + else: + assert fail_msg not in log + +def test_load_from_ops(ops_file_fixture): + wizard = mock_wizard(['anyfile.db', 'bar']) + with mock.patch('paths_cli.wizard.load_from_ops.INPUT_FILE.get', + mock.Mock(return_value=ops_file_fixture)): + obj = load_from_ops(wizard, 'foo', 'FOOMAGIC') + + assert isinstance(obj, NamedObj) + assert obj.name == 'bar' + + pass diff --git a/paths_cli/tests/wizard/test_shooting.py b/paths_cli/tests/wizard/test_shooting.py new file mode 100644 index 00000000..8c3c97b0 --- /dev/null +++ b/paths_cli/tests/wizard/test_shooting.py @@ -0,0 +1,67 @@ +import pytest +import mock +from functools import partial + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.shooting import ( + # selectors + uniform_selector, gaussian_selector, _get_selector, SHOOTING_SELECTORS, + # shooting algorithms + one_way_shooting, spring_shooting, + # main func + shooting, SHOOTING_TYPES +) + +import openpathsampling as paths +from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV + +def test_uniform_selector(): + wizard = mock_wizard([]) + sel = uniform_selector(wizard) + assert isinstance(sel, paths.UniformSelector) + assert wizard.console.input_call_count == 0 + +def test_gaussian_selector(): + wizard = mock_wizard(['x', '1.0', '0.5']) + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]).named('x') + wizard.cvs[cv.name] = cv + sel = gaussian_selector(wizard) + assert isinstance(sel, paths.GaussianBiasSelector) + assert sel.alpha == 2.0 + assert sel.l_0 == 1.0 + +def test_get_selector(): + wizard = mock_wizard(['Uniform random']) + sel = _get_selector(wizard) + assert isinstance(sel, paths.UniformSelector) + +def test_one_way_shooting(toy_engine): + wizard = mock_wizard(['Uniform random']) + wizard.engines[toy_engine.name] = toy_engine + strategy = one_way_shooting(wizard) + assert isinstance(strategy, paths.strategies.OneWayShootingStrategy) + assert isinstance(strategy.selector, paths.UniformSelector) + assert strategy.engine == toy_engine + +def test_spring_shooting(toy_engine): + wizard = mock_wizard(['10', '0.25']) + wizard.engines[toy_engine.name] = toy_engine + strategy = spring_shooting(wizard) + assert isinstance(strategy, partial) + assert strategy.func == paths.SpringShootingMoveScheme + assert strategy.keywords['delta_max'] == 10 + assert strategy.keywords['k_spring'] == 0.25 + assert strategy.keywords['engine'] == toy_engine + +@pytest.mark.parametrize('shooting_types', [{}, None]) +def test_shooting(toy_engine, shooting_types): + key = 'One-way (stochastic) shooting' + wizard = mock_wizard([key]) + wizard.engines[toy_engine.name] = toy_engine + foo = mock.Mock(return_value='foo') + if shooting_types == {}: + shooting_types = {key: foo} + with mock.patch.dict(SHOOTING_TYPES, {key: foo}): + assert shooting(wizard, shooting_types=shooting_types) == 'foo' diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 0bebbc06..98f19d77 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -1,7 +1,9 @@ -LABEL = "Load existing from OPS file" from paths_cli.parameters import INPUT_FILE from paths_cli.wizard.core import get_object +from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG +LABEL = "Load existing from OPS file" + def named_objs_helper(storage, store_name): def list_items(wizard, user_input): store = getattr(storage, store_name) From 6467b59721d07d2b5ae6e4346fe41e5dbd5c75a1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 19 Mar 2021 15:30:06 +0100 Subject: [PATCH 021/251] Remove name from core (used as wizard.name) --- paths_cli/tests/wizard/test_core.py | 36 +++++++++++++-------------- paths_cli/tests/wizard/test_wizard.py | 25 +++++++++++++++---- paths_cli/wizard/core.py | 33 ------------------------ paths_cli/wizard/tps.py | 2 +- 4 files changed, 39 insertions(+), 57 deletions(-) diff --git a/paths_cli/tests/wizard/test_core.py b/paths_cli/tests/wizard/test_core.py index 19b7c462..31b3bc99 100644 --- a/paths_cli/tests/wizard/test_core.py +++ b/paths_cli/tests/wizard/test_core.py @@ -10,25 +10,25 @@ make_mock_wizard, make_mock_retry_wizard ) -def test_name(): - wizard = make_mock_wizard('foo') - cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) - assert not cv.is_named - result = name(wizard, cv, obj_type="CV", store_name="cvs") - assert result is cv - assert result.is_named - assert result.name == 'foo' +# def test_name(): + # wizard = make_mock_wizard('foo') + # cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + # assert not cv.is_named + # result = name(wizard, cv, obj_type="CV", store_name="cvs") + # assert result is cv + # assert result.is_named + # assert result.name == 'foo' -def test_name_exists(): - wizard = make_mock_retry_wizard(['foo', 'bar']) - wizard.cvs['foo'] = 'placeholder' - cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) - assert not cv.is_named - result = name(wizard, cv, obj_type="CV", store_name="cvs") - assert result is cv - assert result.is_named - assert result.name == 'bar' - assert wizard.console.input.call_count == 2 +# def test_name_exists(): + # wizard = make_mock_retry_wizard(['foo', 'bar']) + # wizard.cvs['foo'] = 'placeholder' + # cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + # assert not cv.is_named + # result = name(wizard, cv, obj_type="CV", store_name="cvs") + # assert result is cv + # assert result.is_named + # assert result.name == 'bar' + # assert wizard.console.input.call_count == 2 @pytest.mark.parametrize('req,expected', [ (('foo', 2, 2), '2'), (('foo', 2, float('inf')), 'at least 2'), diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 1d0b0227..4eaf1815 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -1,7 +1,7 @@ import pytest import mock from paths_cli.tests.wizard.mock_wizard import ( - MockConsole + MockConsole, make_mock_wizard, make_mock_retry_wizard ) import pathlib @@ -120,10 +120,25 @@ def test_exception(self): assert "RuntimeError: baz" in console.log_text def test_name(self): - # why does that exist? cf core.name - # actually, it looks like we never use core.name; remove that and - # move its test here? - pass + wizard = make_mock_wizard('foo') + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + assert not cv.is_named + result = wizard.name(cv, obj_type="CV", store_name="cvs") + assert result is cv + assert result.is_named + assert result.name == 'foo' + + def test_name_exists(self): + wizard = make_mock_retry_wizard(['foo', 'bar']) + wizard.cvs['foo'] = 'placeholder' + cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) + assert not cv.is_named + result = wizard.name(cv, obj_type="CV", store_name="cvs") + assert result is cv + assert result.is_named + assert result.name == 'bar' + assert wizard.console.input.call_count == 2 + @pytest.mark.parametrize('named', [True, False]) def test_register(self, named): diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index b700ce9c..51f9f983 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -6,39 +6,6 @@ WizardSay = namedtuple("WizardSay", ['msg', 'mode']) -def name(wizard, obj, obj_type, store_name, default=None): - wizard.say(f"Now let's name your {obj_type}.") - name = None - while name is None: - name = wizard.ask("What do you want to call it?") - if name in getattr(wizard, store_name): - wizard.bad_input(f"Sorry, you already have {a_an(obj_type)} " - f"{obj_type} named {name}. Please try another " - "name.") - name = None - - obj = obj.named(name) - - wizard.say(f"'{name}' is a good name for {a_an(obj_type)} {obj_type}. " - + name_joke(name, obj_type)) - return obj - -def abort_retry_quit(wizard, obj_type): - a_an = a_an(obj_type) - retry = wizard.ask(f"Do you want to try again to make {a_an} " - f"{obj_type}, do you want to continue without, or " - f"do you want to quit?", - options=["[R]etry", "[C]ontinue", "[Q]uit"]) - if retry == 'q': - exit() - elif retry == 'r': - return - elif retry == 'c': - return "continue" - else: - raise ImpossibleError() - - def interpret_req(req): _, min_, max_ = req string = "" diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 2e5996b4..db82df41 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -11,7 +11,7 @@ def tps_scheme(wizard, network=None): network = get_missing_object(wizard, wizard.networks, 'network', tps_network) - shooting_strategy = shooting(wizard, network) + shooting_strategy = shooting(wizard, network=network) if not isinstance(shooting_strategy, paths.MoveStrategy): # this means we got a fixed scheme and can't do strategies From d2050e4ab4cf946254b3dda3fe5efca86110c2ab Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 20 Mar 2021 12:57:54 +0100 Subject: [PATCH 022/251] Add tests for TPS wizard --- paths_cli/tests/wizard/test_tps.py | 43 ++++++++++++++++++++++++++++++ paths_cli/wizard/tps.py | 9 +++++-- paths_cli/wizard/two_state_tps.py | 2 +- 3 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 paths_cli/tests/wizard/test_tps.py diff --git a/paths_cli/tests/wizard/test_tps.py b/paths_cli/tests/wizard/test_tps.py new file mode 100644 index 00000000..7f0fda35 --- /dev/null +++ b/paths_cli/tests/wizard/test_tps.py @@ -0,0 +1,43 @@ +import pytest +import mock + +from functools import partial + +import openpathsampling as paths +from openpathsampling import strategies + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.tps import tps_scheme + + + +@pytest.fixture +def tps_network(): + cv = paths.CoordinateFunctionCV('x', lambda s: s.xyz[0][0]) + state_A = paths.CVDefinedVolume(cv, float("-inf"), 0).named("A") + state_B = paths.CVDefinedVolume(cv, 0, float("inf")).named("B") + network = paths.TPSNetwork(state_A, state_B).named('tps-network') + return network + +@pytest.mark.parametrize('as_scheme', [False, True]) +def test_tps_scheme(tps_network, toy_engine, as_scheme): + wizard = mock_wizard([]) + wizard.networks = {tps_network.name: tps_network} + if as_scheme: + strategy = partial(paths.SpringShootingMoveScheme, + k_spring=0.01, + delta_max=10, + engine=toy_engine) + else: + strategy = strategies.OneWayShootingStrategy( + selector=paths.UniformSelector(), + engine=toy_engine + ) + with mock.patch('paths_cli.wizard.tps.shooting', + new=mock.Mock(return_value=strategy)): + scheme = tps_scheme(wizard) + + assert isinstance(scheme, paths.MoveScheme) + if as_scheme: + assert isinstance(scheme, paths.SpringShootingMoveScheme) diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index db82df41..72d8b3de 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -1,8 +1,13 @@ from paths_cli.wizard.tools import a_an from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.shooting import shooting +from paths_cli.wizard.volumes import volumes + from functools import partial +def tps_network(wizard): + raise NotImplementedError("Still need to add other network choic") + def tps_scheme(wizard, network=None): import openpathsampling as paths @@ -11,9 +16,9 @@ def tps_scheme(wizard, network=None): network = get_missing_object(wizard, wizard.networks, 'network', tps_network) - shooting_strategy = shooting(wizard, network=network) + shooting_strategy = shooting(wizard) - if not isinstance(shooting_strategy, paths.MoveStrategy): + if not isinstance(shooting_strategy, strategies.MoveStrategy): # this means we got a fixed scheme and can't do strategies return shooting_strategy(network=network) diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index ac5d4e53..48d806f4 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -15,7 +15,7 @@ def two_state_tps(wizard, fixed_length=False): final_state = volumes(wizard, as_state=True, intro="") wizard.register(final_state, 'final state', 'states') if fixed_length: - ... + ... # no-cov (will add this later) else: network = paths.TPSNetwork(initial_state, final_state) scheme = tps_scheme(wizard, network=network) From 94cd34e80c6b7cf7b53dc0016b0017d7fefbccb0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 20 Mar 2021 17:21:02 +0100 Subject: [PATCH 023/251] Tests for two_state_tps.py --- paths_cli/tests/wizard/conftest.py | 9 +++++++ paths_cli/tests/wizard/test_tps.py | 10 -------- paths_cli/tests/wizard/test_two_state_tps.py | 25 ++++++++++++++++++++ paths_cli/wizard/core.py | 2 +- paths_cli/wizard/two_state_tps.py | 4 ++-- 5 files changed, 37 insertions(+), 13 deletions(-) create mode 100644 paths_cli/tests/wizard/test_two_state_tps.py diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index d3c2447c..df6a6679 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -68,3 +68,12 @@ def toy_engine(): topology=topology ).named('toy-engine') return engine + + +@pytest.fixture +def tps_network(): + cv = paths.CoordinateFunctionCV('x', lambda s: s.xyz[0][0]) + state_A = paths.CVDefinedVolume(cv, float("-inf"), 0).named("A") + state_B = paths.CVDefinedVolume(cv, 0, float("inf")).named("B") + network = paths.TPSNetwork(state_A, state_B).named('tps-network') + return network diff --git a/paths_cli/tests/wizard/test_tps.py b/paths_cli/tests/wizard/test_tps.py index 7f0fda35..83b51f7b 100644 --- a/paths_cli/tests/wizard/test_tps.py +++ b/paths_cli/tests/wizard/test_tps.py @@ -10,16 +10,6 @@ from paths_cli.wizard.tps import tps_scheme - - -@pytest.fixture -def tps_network(): - cv = paths.CoordinateFunctionCV('x', lambda s: s.xyz[0][0]) - state_A = paths.CVDefinedVolume(cv, float("-inf"), 0).named("A") - state_B = paths.CVDefinedVolume(cv, 0, float("inf")).named("B") - network = paths.TPSNetwork(state_A, state_B).named('tps-network') - return network - @pytest.mark.parametrize('as_scheme', [False, True]) def test_tps_scheme(tps_network, toy_engine, as_scheme): wizard = mock_wizard([]) diff --git a/paths_cli/tests/wizard/test_two_state_tps.py b/paths_cli/tests/wizard/test_two_state_tps.py new file mode 100644 index 00000000..22d55c98 --- /dev/null +++ b/paths_cli/tests/wizard/test_two_state_tps.py @@ -0,0 +1,25 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.two_state_tps import two_state_tps + +def mock_tps_scheme(wizard, network): + wizard.say(str(network.__class__)) + return "this would be a scheme" + +@mock.patch('paths_cli.wizard.two_state_tps.tps_scheme', + new=mock_tps_scheme) +def test_two_state_tps(tps_network): + wizard = mock_wizard([]) + state_A = tps_network.initial_states[0] + state_B = tps_network.final_states[0] + with mock.patch('paths_cli.wizard.two_state_tps.volumes', + new=mock.Mock(side_effect=[state_A, state_B])): + scheme = two_state_tps(wizard) + assert scheme == "this would be a scheme" + assert "network.TPSNetwork" in wizard.console.log_text + + + diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index 51f9f983..fdeee94e 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -1,9 +1,9 @@ import random -from paths_cli.wizard.joke import name_joke from paths_cli.wizard.tools import a_an from collections import namedtuple +WIZARD_STORE_NAMES = ['engines', 'cvs', 'states', 'networks', 'schemes'] WizardSay = namedtuple("WizardSay", ['msg', 'mode']) def interpret_req(req): diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index 48d806f4..a62aa9bf 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -1,8 +1,8 @@ +from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.tps import tps_scheme from paths_cli.wizard.steps import ( SINGLE_ENGINE_STEP, CVS_STEP, WizardStep ) -from paths_cli.wizard.volumes import volumes -from paths_cli.wizard.tps import tps_scheme from paths_cli.wizard.wizard import Wizard def two_state_tps(wizard, fixed_length=False): From 3f183de908cfcf8afb78ac456832d7e1b9e45c9b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 20 Mar 2021 18:54:34 +0100 Subject: [PATCH 024/251] tests for engines and errors --- .coveragerc | 1 + paths_cli/tests/wizard/test_engines.py | 14 ++++++++++++++ paths_cli/tests/wizard/test_errors.py | 21 +++++++++++++++++++++ paths_cli/wizard/errors.py | 10 +++++----- 4 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 paths_cli/tests/wizard/test_engines.py create mode 100644 paths_cli/tests/wizard/test_errors.py diff --git a/.coveragerc b/.coveragerc index 0c7c3715..d5a61508 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,3 +7,4 @@ exclude_lines = no-cov def __repr__ raise NotImplementedError + __name__ == "__main__": diff --git a/paths_cli/tests/wizard/test_engines.py b/paths_cli/tests/wizard/test_engines.py new file mode 100644 index 00000000..40655fcd --- /dev/null +++ b/paths_cli/tests/wizard/test_engines.py @@ -0,0 +1,14 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.engines import engines, SUPPORTED_ENGINES + +def test_engines(): + wizard = mock_wizard(['foo']) + with mock.patch.dict(SUPPORTED_ENGINES, + {'foo': mock.Mock(return_value='ran foo')}): + foo = engines(wizard) + assert foo == 'ran foo' + diff --git a/paths_cli/tests/wizard/test_errors.py b/paths_cli/tests/wizard/test_errors.py new file mode 100644 index 00000000..a502acc5 --- /dev/null +++ b/paths_cli/tests/wizard/test_errors.py @@ -0,0 +1,21 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.errors import * + +def test_impossible_error_default(): + with pytest.raises(ImpossibleError, match="You should never see this."): + raise ImpossibleError() + +@pytest.mark.parametrize('inputs', ['r', 'q']) +def test_not_installed(inputs): + expected = {'r': RestartObjectException, 'q': SystemExit}[inputs] + wizard = mock_wizard([inputs]) + with pytest.raises(expected): + not_installed(wizard, 'foo', 'widget') + log = wizard.console.log_text + assert 'foo installed' in log + assert 'different widget' in log + diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py index 7ec16747..2a212310 100644 --- a/paths_cli/wizard/errors.py +++ b/paths_cli/wizard/errors.py @@ -7,16 +7,16 @@ def __init__(self, msg=None): class RestartObjectException(BaseException): pass -def not_installed(package, obj_type): - retry = wizard.ask("Hey, it looks like you don't have {package} " +def not_installed(wizard, package, obj_type): + retry = wizard.ask(f"Hey, it looks like you don't have {package} " "installed. Do you want to try a different " - "{obj_type}, or do you want to quit?", + f"{obj_type}, or do you want to quit?", options=["[R]etry", "[Q]uit"]) if retry == 'r': - return + raise RestartObjectException() elif retry == 'q': exit() - else: + else: # no-cov raise ImpossibleError() From 1850972803c55db40a3ea0c7a9a77aa7bfcee1ad Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 21 Mar 2021 01:31:39 +0100 Subject: [PATCH 025/251] Add core & engines for yaml compiling --- paths_cli/parsing/__init__.py | 1 + paths_cli/parsing/core.py | 182 +++++++++++++++++++++++++++++ paths_cli/parsing/engines.py | 48 ++++++++ paths_cli/parsing/errors.py | 14 +++ paths_cli/parsing/test_core.py | 8 ++ paths_cli/parsing/test_engines.py | 74 ++++++++++++ paths_cli/parsing/test_topology.py | 11 ++ paths_cli/parsing/topology.py | 39 +++++++ 8 files changed, 377 insertions(+) create mode 100644 paths_cli/parsing/__init__.py create mode 100644 paths_cli/parsing/core.py create mode 100644 paths_cli/parsing/engines.py create mode 100644 paths_cli/parsing/errors.py create mode 100644 paths_cli/parsing/test_core.py create mode 100644 paths_cli/parsing/test_engines.py create mode 100644 paths_cli/parsing/test_topology.py create mode 100644 paths_cli/parsing/topology.py diff --git a/paths_cli/parsing/__init__.py b/paths_cli/parsing/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/paths_cli/parsing/__init__.py @@ -0,0 +1 @@ + diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py new file mode 100644 index 00000000..2fcda926 --- /dev/null +++ b/paths_cli/parsing/core.py @@ -0,0 +1,182 @@ +import os +import importlib +import yaml + +from collections import namedtuple, abc + +from .errors import InputError +from .tools import custom_eval + +def listify(obj): + listified = False + if not isinstance(obj, list): + obj = [obj] + listified = True + return obj, listified + +def unlistify(obj, listified): + if listified: + assert len(obj) == 1 + obj = obj[0] + return obj + + +class NamedObjects(abc.MutableMapping): + """Class to track named objects and their descriptions""" + def __init__(self, global_dct): + names, dcts = self.all_name_descriptions(global_dct) + self.objects = {name: None for name in names} + self.descriptions = {name: dct for name, dct in zip(names, dcts)} + + def __getitem__(self, key): + return self.objects[key] + + def __setitem__(self, key, value): + self.objects[key] = value + + def __delitem__(self, key): + del self.objects[key] + + def __iter__(self): + return iter(self.objects) + + def __len__(self): + return len(self.objects) + + @staticmethod + def all_name_descriptions(dct): + """Find all the named objets and their dict descriptions + + Parameters + ---------- + dct : dict + output from loading YAML + """ + names = [] + dcts = [] + name = None + if isinstance(dct, list): + for item in dct: + name_list, dct_list = \ + NamedObjects.all_name_descriptions(item) + names.extend(name_list) + dcts.extend(dct_list) + elif isinstance(dct, dict): + for k, v in dct.items(): + if isinstance(v, (dict, list)): + name_list, dct_list = \ + NamedObjects.all_name_descriptions(v) + names.extend(name_list) + dcts.extend(dct_list) + try: + name = dct['name'] + except KeyError: + pass + else: + names.append(name) + dcts.append(dct) + + return names, dcts + + +class InstanceBuilder: + # TODO: add schema as an input so we can autogenerate our JSON schema! + def __init__(self, builder, attribute_table, defaults=None, module=None, + remapper=None): + self.module = module + self.builder = builder + self.attribute_table = attribute_table + # TODO use none_to_default + if remapper is None: + remapper = lambda x: x + self.remapper = remapper + if defaults is None: + defaults = {} + self.defaults = defaults + + def select_builder(self, dct, named_objs): + if self.module is not None: + builder = getattr(importlib.import_module(self.module), self.builder) + else: + builder = self.builder + return builder + + + def __call__(self, dct, named_objs): + # TODO: support aliases in dct[attr] + input_dct = self.defaults.copy().update(dct) + # new_dct = {attr: func(dct[attr], named_objs) + # for attr, func in self.attribute_table.items()} + new_dct = {} + for attr, func in self.attribute_table.items(): + new_dct[attr] = func(dct[attr], named_objs) + ops_dct = self.remapper(new_dct) + builder = self.select_builder(dct, named_objs) + return builder(**ops_dct) + + +class Parser: + """Generic parse class; instances for each category""" + def __init__(self, type_dispatch, label): + self.type_dispatch = type_dispatch + self.label = label + self.named_objs = {} + + def _parse_str(self, name, named_objs): + try: + return self.named_objs[name] + except KeyError as e: + raise e # TODO: replace with better error + + # def _parse_str(self, name, named_objs): + # obj = named_objs[name] + # if obj is None: + # try: + # definition = named_objs.definitions[name] + # except KeyError: + # raise InputError.unknown_name(self.label, name) + # else: + # obj = self(definition, named_objects) + # named_objs[name] = obj + + # return obj + + def _parse_dict(self, dct, named_objs): + dct = dct.copy() # make a local copy + name = dct.pop('name', None) + type_name = dct.pop('type') + obj = self.type_dispatch[type_name](dct, named_objs) + # return obj.named(name) + if name is not None: + if name in self.named_objs: + raise RuntimeError("Same name twice") # TODO improve + obj = obj.named(name) + self.named_objs[name] = obj + + + def parse(self, dct, named_objs): + if isinstance(dct, str): + return self._parse_str(dct, named_objs) + else: + return self._parse_dict(dct, named_objs) + + def __call__(self, dct, named_objs): + dcts, listified = listify(dct) + objs = [self.parse(d, named_objs) for d in dcts] + results = unlistify(objs, listified) + return results + + def add_type(self, type_name, type_function): + if type_name in self.type_dispatch: + raise RuntimeError("Already exists") + self.type_dispatch[type_name] = type_function + + + +CATEGORY_ALIASES = { + "cv": ["cvs"], + "volume": ["states", "initial_state", "final_state"], + "engine": ["engines"], +} + +CANONICAL_CATEGORY = {e: k for k, v in CATEGORY_ALIASES.items() for e in v} diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py new file mode 100644 index 00000000..f9a0b18e --- /dev/null +++ b/paths_cli/parsing/engines.py @@ -0,0 +1,48 @@ +from .topology import build_topology +from .core import Parser, InstanceBuilder, custom_eval + +try: + from simtk import openmm as mm +except ImportError: + HAS_OPENMM = False +else: + HAS_OPENMM = True + +def load_openmm_xml(filename, named_objs): + if not HAS_OPENMM: # pragma: no cover + raise RuntimeError("OpenMM does not seem to be installed") + + with open(filename, mode='r') as f: + obj = mm.XmlSerializer.deserialize(f.read()) + + return obj + +def openmm_options(dct): + n_steps_per_frame = dct.pop('n_steps_per_frame') + n_frames_max = dct.pop('n_frames_max') + options = {'n_steps_per_frame': n_steps_per_frame, + 'n_frames_max': n_frames_max} + dct['options'] = options + return dct + + +OPENMM_ATTRS = { + 'topology': build_topology, + 'system': load_openmm_xml, + 'integrator': load_openmm_xml, + 'n_steps_per_frame': lambda v, _: int(v), + 'n_frames_max': lambda v, _: int(v) +} + +build_openmm_engine = InstanceBuilder( + module='openpathsampling.engines.openmm', + builder='Engine', + attribute_table=OPENMM_ATTRS, + remapper=openmm_options +) + +TYPE_MAPPING = { + 'openmm': build_openmm_engine, +} + +engine_parser = Parser(TYPE_MAPPING, label="engine") diff --git a/paths_cli/parsing/errors.py b/paths_cli/parsing/errors.py new file mode 100644 index 00000000..6e05e080 --- /dev/null +++ b/paths_cli/parsing/errors.py @@ -0,0 +1,14 @@ +class InputError(Exception): + @classmethod + def invalid_input(cls, value, attr, type_name=None, name=None): + msg = f"'{value}' is not a valid input for {attr}" + if type_name is not None: + msg += f" in {type_name}" + if name is not None: + msg += f" named {name}" + return cls(msg) + + @classmethod + def unknown_name(cls, type_name, name): + return cls(f"Unable to find a {type_name} named {name}") + diff --git a/paths_cli/parsing/test_core.py b/paths_cli/parsing/test_core.py new file mode 100644 index 00000000..3c2e27f9 --- /dev/null +++ b/paths_cli/parsing/test_core.py @@ -0,0 +1,8 @@ +import pytest + +import numpy.testing as npt + +from paths_cli.parsing.core import * + + + diff --git a/paths_cli/parsing/test_engines.py b/paths_cli/parsing/test_engines.py new file mode 100644 index 00000000..2ed1a86d --- /dev/null +++ b/paths_cli/parsing/test_engines.py @@ -0,0 +1,74 @@ +import pytest +import yaml +import os + +from paths_cli.parsing.engines import * +from paths_cli.parsing.errors import InputError +import openpathsampling as paths + +from openpathsampling.engines import openmm as ops_openmm +import mdtraj as md + + +class TestOpenMMEngineBuilder(object): + def setup(self): + self.cwd = os.getcwd() + self.yml = "\n".join([ + "type: openmm", "name: engine", "system: system.xml", + "integrator: integrator.xml", "topology: ad.pdb", + "n_steps_per_frame: 10", "n_frames_max: 10000" + ]) + pass + + def teardown(self): + os.chdir(self.cwd) + + def _create_files(self, tmpdir): + mm = pytest.importorskip('simtk.openmm') + openmmtools = pytest.importorskip('openmmtools') + unit = pytest.importorskip('simtk.unit') + ad = openmmtools.testsystems.AlanineDipeptideVacuum() + integrator = openmmtools.integrators.VVVRIntegrator( + 300*unit.kelvin, 1.0/unit.picosecond, 2.0*unit.femtosecond + ) + with open(os.path.join(tmpdir, 'system.xml'), mode='w') as f: + f.write(mm.XmlSerializer.serialize(ad.system)) + with open(os.path.join(tmpdir, 'integrator.xml'), mode='w') as f: + f.write(mm.XmlSerializer.serialize(integrator)) + + trj = md.Trajectory(ad.positions.value_in_unit(unit.nanometer), + topology=ad.mdtraj_topology) + trj.save(os.path.join(tmpdir, "ad.pdb")) + + def test_load_openmm_xml(self, tmpdir): + mm = pytest.importorskip('simtk.openmm') + self._create_files(tmpdir) + os.chdir(tmpdir) + for fname in ['system.xml', 'integrator.xml', 'ad.pdb']: + assert fname in os.listdir() + + integ = load_openmm_xml('integrator.xml', {}) + assert isinstance(integ, mm.CustomIntegrator) + sys = load_openmm_xml('system.xml', {}) + assert isinstance(sys, mm.System) + + def test_openmm_options(self): + dct = yaml.load(self.yml, yaml.FullLoader) + dct = openmm_options(dct) + assert dct == {'type': 'openmm', 'name': 'engine', + 'system': 'system.xml', + 'integrator': 'integrator.xml', + 'topology': 'ad.pdb', + 'options': {'n_steps_per_frame': 10, + 'n_frames_max': 10000}} + + def test_build_openmm_engine(self, tmpdir): + self._create_files(tmpdir) + os.chdir(tmpdir) + dct = yaml.load(self.yml, yaml.FullLoader) + engine = build_openmm_engine(dct, {}) + assert isinstance(engine, ops_openmm.Engine) + snap = ops_openmm.tools.ops_load_trajectory('ad.pdb')[0] + engine.current_snapshot = snap + engine.simulation.minimizeEnergy() + engine.generate_next_frame() diff --git a/paths_cli/parsing/test_topology.py b/paths_cli/parsing/test_topology.py new file mode 100644 index 00000000..d0fe3124 --- /dev/null +++ b/paths_cli/parsing/test_topology.py @@ -0,0 +1,11 @@ +import pytest +from openpathsampling.tests.test_helpers import data_filename + +from paths_cli.parsing.topology import * + +class TestBuildTopology: + def test_build_topology_file(self): + ad_pdb = data_filename("ala_small_traj.pdb") + topology = build_topology(ad_pdb, {}) + assert topology.n_spatial == 3 + assert topology.n_atoms == 1651 diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py new file mode 100644 index 00000000..2dd8295c --- /dev/null +++ b/paths_cli/parsing/topology.py @@ -0,0 +1,39 @@ +import os +from .errors import InputError + +def get_topology_from_engine(dct, named_objs): + """If given the name of an engine, use that engine's topology""" + if dct in named_objs: + engine = named_objs[dct] + try: + return engine.topology + except AttributeError: + pass + +def get_topology_from_file(dct, named_objs): + """If given the name of a file, use that to create the topology""" + if os.path.exists(dct): + import mdtraj as md + import openpathsampling as paths + trj = md.load(dct) + return paths.engines.openmm.topology.MDTrajTopology(trj.topology) + + +class MultiStrategyBuilder: + # move to core + def __init__(self, strategies, label): + self.strategies = strategies + self.label = label + + def __call__(self, dct, named_objs): + for strategy in self.strategies: + result = strategy(dct, named_objs) + if result is not None: + return result + + # only get here if we failed + raise InputError.invalid_input(dct, self.label) + +build_topology = MultiStrategyBuilder([get_topology_from_file, + get_topology_from_engine], + label='topology') From 4392e41374edd177036a49887616d6e54d6d5c3a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 21 Mar 2021 01:41:43 +0100 Subject: [PATCH 026/251] Remove named_obj param in engines, topologies --- paths_cli/parsing/core.py | 24 ++++++++++++------------ paths_cli/parsing/engines.py | 6 +++--- paths_cli/parsing/test_engines.py | 6 +++--- paths_cli/parsing/test_topology.py | 2 +- paths_cli/parsing/topology.py | 13 +++++++------ 5 files changed, 26 insertions(+), 25 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 2fcda926..f359a697 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -94,7 +94,7 @@ def __init__(self, builder, attribute_table, defaults=None, module=None, defaults = {} self.defaults = defaults - def select_builder(self, dct, named_objs): + def select_builder(self, dct): if self.module is not None: builder = getattr(importlib.import_module(self.module), self.builder) else: @@ -102,16 +102,16 @@ def select_builder(self, dct, named_objs): return builder - def __call__(self, dct, named_objs): + def __call__(self, dct): # TODO: support aliases in dct[attr] input_dct = self.defaults.copy().update(dct) # new_dct = {attr: func(dct[attr], named_objs) # for attr, func in self.attribute_table.items()} new_dct = {} for attr, func in self.attribute_table.items(): - new_dct[attr] = func(dct[attr], named_objs) + new_dct[attr] = func(dct[attr]) ops_dct = self.remapper(new_dct) - builder = self.select_builder(dct, named_objs) + builder = self.select_builder(dct) return builder(**ops_dct) @@ -122,7 +122,7 @@ def __init__(self, type_dispatch, label): self.label = label self.named_objs = {} - def _parse_str(self, name, named_objs): + def _parse_str(self, name): try: return self.named_objs[name] except KeyError as e: @@ -141,11 +141,11 @@ def _parse_str(self, name, named_objs): # return obj - def _parse_dict(self, dct, named_objs): + def _parse_dict(self, dct): dct = dct.copy() # make a local copy name = dct.pop('name', None) type_name = dct.pop('type') - obj = self.type_dispatch[type_name](dct, named_objs) + obj = self.type_dispatch[type_name](dct) # return obj.named(name) if name is not None: if name in self.named_objs: @@ -154,15 +154,15 @@ def _parse_dict(self, dct, named_objs): self.named_objs[name] = obj - def parse(self, dct, named_objs): + def parse(self, dct): if isinstance(dct, str): - return self._parse_str(dct, named_objs) + return self._parse_str(dct) else: - return self._parse_dict(dct, named_objs) + return self._parse_dict(dct) - def __call__(self, dct, named_objs): + def __call__(self, dct): dcts, listified = listify(dct) - objs = [self.parse(d, named_objs) for d in dcts] + objs = [self.parse(d) for d in dcts] results = unlistify(objs, listified) return results diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index f9a0b18e..703db5b2 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -8,7 +8,7 @@ else: HAS_OPENMM = True -def load_openmm_xml(filename, named_objs): +def load_openmm_xml(filename): if not HAS_OPENMM: # pragma: no cover raise RuntimeError("OpenMM does not seem to be installed") @@ -30,8 +30,8 @@ def openmm_options(dct): 'topology': build_topology, 'system': load_openmm_xml, 'integrator': load_openmm_xml, - 'n_steps_per_frame': lambda v, _: int(v), - 'n_frames_max': lambda v, _: int(v) + 'n_steps_per_frame': int, + 'n_frames_max': int, } build_openmm_engine = InstanceBuilder( diff --git a/paths_cli/parsing/test_engines.py b/paths_cli/parsing/test_engines.py index 2ed1a86d..109b1542 100644 --- a/paths_cli/parsing/test_engines.py +++ b/paths_cli/parsing/test_engines.py @@ -47,9 +47,9 @@ def test_load_openmm_xml(self, tmpdir): for fname in ['system.xml', 'integrator.xml', 'ad.pdb']: assert fname in os.listdir() - integ = load_openmm_xml('integrator.xml', {}) + integ = load_openmm_xml('integrator.xml') assert isinstance(integ, mm.CustomIntegrator) - sys = load_openmm_xml('system.xml', {}) + sys = load_openmm_xml('system.xml') assert isinstance(sys, mm.System) def test_openmm_options(self): @@ -66,7 +66,7 @@ def test_build_openmm_engine(self, tmpdir): self._create_files(tmpdir) os.chdir(tmpdir) dct = yaml.load(self.yml, yaml.FullLoader) - engine = build_openmm_engine(dct, {}) + engine = build_openmm_engine(dct) assert isinstance(engine, ops_openmm.Engine) snap = ops_openmm.tools.ops_load_trajectory('ad.pdb')[0] engine.current_snapshot = snap diff --git a/paths_cli/parsing/test_topology.py b/paths_cli/parsing/test_topology.py index d0fe3124..f2392a2d 100644 --- a/paths_cli/parsing/test_topology.py +++ b/paths_cli/parsing/test_topology.py @@ -6,6 +6,6 @@ class TestBuildTopology: def test_build_topology_file(self): ad_pdb = data_filename("ala_small_traj.pdb") - topology = build_topology(ad_pdb, {}) + topology = build_topology(ad_pdb) assert topology.n_spatial == 3 assert topology.n_atoms == 1651 diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py index 2dd8295c..dfd5844c 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/parsing/topology.py @@ -1,16 +1,17 @@ import os from .errors import InputError -def get_topology_from_engine(dct, named_objs): +def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" - if dct in named_objs: - engine = named_objs[dct] + from paths_cli.parsing.engines import engine_parser + if dct in engine_parser.named_objs: + engine = enginer_parser.named_objs[dct] try: return engine.topology except AttributeError: pass -def get_topology_from_file(dct, named_objs): +def get_topology_from_file(dct): """If given the name of a file, use that to create the topology""" if os.path.exists(dct): import mdtraj as md @@ -25,9 +26,9 @@ def __init__(self, strategies, label): self.strategies = strategies self.label = label - def __call__(self, dct, named_objs): + def __call__(self, dct): for strategy in self.strategies: - result = strategy(dct, named_objs) + result = strategy(dct) if result is not None: return result From 2a81273180d4f67ac7fdaec2ed5bd19a26f849ba Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 21 Mar 2021 01:43:58 +0100 Subject: [PATCH 027/251] CVs and tests (without named_objs) --- paths_cli/parsing/cvs.py | 75 +++++++++++++++++++++++++++++++++++ paths_cli/parsing/test_cvs.py | 42 ++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 paths_cli/parsing/cvs.py create mode 100644 paths_cli/parsing/test_cvs.py diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py new file mode 100644 index 00000000..c757ffbd --- /dev/null +++ b/paths_cli/parsing/cvs.py @@ -0,0 +1,75 @@ +import os +import importlib + +from .core import Parser, InstanceBuilder, custom_eval +from .topology import build_topology +from .errors import InputError + + +class AllowedPackageHandler: + def __init__(self, package): + self.package = package + + def __call__(self, source): + try: + pkg = importlib.import_module(self.package) + func = getattr(pkg, source) + except AttributeError: + raise InputError(f"No function called {source} in {self.package}") + # on ImportError, we leave the error unchanged + return func + +def cv_prepare_dict(dct): + kwargs = dct.pop('kwargs', {}) + dct.update(kwargs) + return dct + +# MDTraj-specific + +mdtraj_source = AllowedPackageHandler("mdtraj") +MDTRAJ_ATTRS = { + 'topology': build_topology, + 'func': mdtraj_source, + 'kwargs': lambda kwargs: {key: custom_eval(arg) + for key, arg in kwargs.items()}, +} + +MDTRAJ_SCHEMA = { + 'topology': { + 'type': 'string', + 'description': 'topology from file or engine name', + }, + 'func': { + 'type': 'string', + 'description': 'MDTraj function, e.g., ``compute_distances``', + }, + 'kwargs': { + 'type': 'object', + 'description': 'keyword arguments for ``func``', + }, +} + +build_mdtraj_function_cv = InstanceBuilder( + module='openpathsampling.experimental.storage.collective_variables', + builder='MDTrajFunctionCV', + attribute_table=MDTRAJ_ATTRS, + remapper = cv_prepare_dict, +) + +# Mock for integration testing +def mock_cv_builder(dct): + from mock import Mock + from openpathsampling.experimental.storage.collective_varibles import \ + FunctionCV + mock = Mock(return_value=dct['return_value']) + return FunctionCV(mock) + + + +# Main CV parser + +TYPE_MAPPING = { + 'mdtraj': build_mdtraj_function_cv, +} + +cv_parser = Parser(TYPE_MAPPING, label="CV") diff --git a/paths_cli/parsing/test_cvs.py b/paths_cli/parsing/test_cvs.py new file mode 100644 index 00000000..77d4573f --- /dev/null +++ b/paths_cli/parsing/test_cvs.py @@ -0,0 +1,42 @@ +import pytest +import yaml + +from openpathsampling.tests.test_helpers import data_filename +import numpy.testing as npt + +from paths_cli.parsing.cvs import * +from paths_cli.parsing.errors import InputError +import openpathsampling as paths +from openpathsampling.experimental.storage.collective_variables \ + import MDTrajFunctionCV +import mdtraj as md + + +class TestMDTrajFunctionCV: + def setup(self): + self.ad_pdb = data_filename("ala_small_traj.pdb") + self.yml = "\n".join([ + "name: phi", "type: mdtraj", "topology: " + self.ad_pdb, + "period_min: -np.pi", "period_max: np.pi", + "func: {func}", + "kwargs:", " {kwargs}", + ]) + self.kwargs = "indices: [[4, 6, 8, 14]]" + + def test_build_mdtraj_function_cv(self): + yml = self.yml.format(kwargs=self.kwargs, func="compute_dihedrals") + dct = yaml.load(yml, Loader=yaml.FullLoader) + cv = build_mdtraj_function_cv(dct) + assert isinstance(cv, MDTrajFunctionCV) + assert cv.func == md.compute_dihedrals + md_trj = md.load(self.ad_pdb) + ops_trj = paths.engines.openmm.tools.ops_load_trajectory(self.ad_pdb) + expected = md.compute_dihedrals(md_trj, indices=[[4,6,8,14]]) + npt.assert_array_almost_equal(cv(ops_trj).reshape(expected.shape), + expected) + + def test_bad_mdtraj_function_name(self): + yml = self.yml.format(kwargs=self.kwargs, func="foo") + dct = yaml.load(yml, Loader=yaml.FullLoader) + with pytest.raises(InputError): + cv = build_mdtraj_function_cv(dct) From 3be9450d7616e9a08f944ea245e691ecee85f9ab Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 21 Mar 2021 01:52:14 +0100 Subject: [PATCH 028/251] volumes and partial tests for volumes --- paths_cli/parsing/test_volumes.py | 68 +++++++++++++++++++++++++++++++ paths_cli/parsing/volumes.py | 55 +++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 paths_cli/parsing/test_volumes.py create mode 100644 paths_cli/parsing/volumes.py diff --git a/paths_cli/parsing/test_volumes.py b/paths_cli/parsing/test_volumes.py new file mode 100644 index 00000000..5e6abb2c --- /dev/null +++ b/paths_cli/parsing/test_volumes.py @@ -0,0 +1,68 @@ +import pytest +import mock + +import yaml +import openpathsampling as paths + +from paths_cli.parsing.volumes import * + +class TestBuildCVVolume: + def setup(self): + self.yml = "\n".join(["type: cv-volume", "cv: {func}", + "lambda_min: 0", "lambda_max: 1"]) + + self.mock_cv = mock.Mock(return_value=0.5) + self.named_objs_dict = { + 'foo': {'name': 'foo', + 'type': 'bar', + 'func': 'foo_func'} + } + mock_named_objs = mock.MagicMock() + mock_named_objs.__getitem__ = mock.Mock(return_value=self.mock_cv) + mock_named_objs.descriptions = self.named_objs_dict + + self.named_objs = { + 'inline': ..., + 'external': mock_named_objs + } + self.func = { + 'inline': "\n ".join(["name: foo", "type: mdtraj"]), # TODO + 'external': 'foo' + } + + def create_inputs(self, inline, periodic): + yml = "\n".join(["type: cv-volume", "cv: {func}", + "lambda_min: 0", "lambda_max: 1"]) + + def set_periodic(self, periodic): + if periodic == 'periodic': + self.named_objs_dict['foo']['period_max'] = 'np.pi' + self.named_objs_dict['foo']['period_min'] = '-np.pi' + + @pytest.mark.parametrize('inline', ['external', 'external']) + @pytest.mark.parametrize('periodic', ['periodic', 'nonperiodic']) + def test_build_cv_volume(self, inline, periodic): + self.set_periodic(periodic) + yml = self.yml.format(func=self.func[inline]) + dct = yaml.load(yml, Loader=yaml.FullLoader) + if inline =='external': + patchloc = 'paths_cli.parsing.volumes.cv_parser.named_objs' + with mock.patch.dict(patchloc, {'foo': self.mock_cv}): + vol = build_cv_volume(dct) + elif inline == 'internal': + vol = build_cv_volume(dct) + assert vol.collectivevariable(1) == 0.5 + expected_class = { + 'nonperiodic': paths.CVDefinedVolume, + 'periodic': paths.PeriodicCVDefinedVolume + }[periodic] + assert isinstance(vol, expected_class) + + +class TestBuildIntersectionVolume: + def setup(self): + self.yml = "\n".join([ + 'type: intersection', 'name: inter', 'subvolumes:', + ' - type: cv-volume', + ]) + pass diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py new file mode 100644 index 00000000..be1d1bcb --- /dev/null +++ b/paths_cli/parsing/volumes.py @@ -0,0 +1,55 @@ +import operator +import functools + +from .core import Parser, InstanceBuilder, custom_eval +from .cvs import cv_parser + +class CVVolumeInstanceBuilder(InstanceBuilder): + # subclass to handle periodic cv voluAmes + def select_builder(self, dct): + import openpathsampling as paths + cv = dct['cv'] + builder = paths.CVDefinedVolume + if cv.period_min is not None: + builder = paths.PeriodicCVDefinedVolume + if cv.period_max is not None: + builder = paths.PeriodicCVDefinedVolume + return builder + +def cv_volume_remapper(dct): + dct['collectivevariable'] = dct.pop('cv') + return dct + +build_cv_volume = CVVolumeInstanceBuilder( + builder=None, + attribute_table={ + 'cv': cv_parser, + 'lambda_min': custom_eval, + 'lambda_max': custom_eval, + }, + remapper=cv_volume_remapper, +) + +def _use_parser(dct): + # this is a hack to get around circular definitions + return volume_parser(dct) + +build_intersection_volume = InstanceBuilder( + builder=lambda subvolumes: functools.reduce(operator.__and__, + subvolumes), + attribute_table={'subvolumes': _use_parser}, +) + +build_union_volume = InstanceBuilder( + builder=lambda subvolumes: functools.reduce(operator.__or__, + subvolumes), + attribute_table={'subvolumes': _use_parser}, +) + +TYPE_MAPPING = { + 'cv-volume': build_cv_volume, + 'intersection': build_intersection_volume, +} + +volume_parser = Parser(TYPE_MAPPING, label="volume") + From dfbf36cb86bd935452903f2cdece51ddd84100ec Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 21 Mar 2021 02:40:36 +0100 Subject: [PATCH 029/251] Moved tests over to testing dir --- paths_cli/parsing/core.py | 2 +- paths_cli/{ => tests}/parsing/test_core.py | 0 paths_cli/{ => tests}/parsing/test_cvs.py | 0 paths_cli/{ => tests}/parsing/test_engines.py | 0 paths_cli/{ => tests}/parsing/test_topology.py | 0 paths_cli/{ => tests}/parsing/test_volumes.py | 0 6 files changed, 1 insertion(+), 1 deletion(-) rename paths_cli/{ => tests}/parsing/test_core.py (100%) rename paths_cli/{ => tests}/parsing/test_cvs.py (100%) rename paths_cli/{ => tests}/parsing/test_engines.py (100%) rename paths_cli/{ => tests}/parsing/test_topology.py (100%) rename paths_cli/{ => tests}/parsing/test_volumes.py (100%) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index f359a697..95fb27e5 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -110,8 +110,8 @@ def __call__(self, dct): new_dct = {} for attr, func in self.attribute_table.items(): new_dct[attr] = func(dct[attr]) + builder = self.select_builder(new_dct) ops_dct = self.remapper(new_dct) - builder = self.select_builder(dct) return builder(**ops_dct) diff --git a/paths_cli/parsing/test_core.py b/paths_cli/tests/parsing/test_core.py similarity index 100% rename from paths_cli/parsing/test_core.py rename to paths_cli/tests/parsing/test_core.py diff --git a/paths_cli/parsing/test_cvs.py b/paths_cli/tests/parsing/test_cvs.py similarity index 100% rename from paths_cli/parsing/test_cvs.py rename to paths_cli/tests/parsing/test_cvs.py diff --git a/paths_cli/parsing/test_engines.py b/paths_cli/tests/parsing/test_engines.py similarity index 100% rename from paths_cli/parsing/test_engines.py rename to paths_cli/tests/parsing/test_engines.py diff --git a/paths_cli/parsing/test_topology.py b/paths_cli/tests/parsing/test_topology.py similarity index 100% rename from paths_cli/parsing/test_topology.py rename to paths_cli/tests/parsing/test_topology.py diff --git a/paths_cli/parsing/test_volumes.py b/paths_cli/tests/parsing/test_volumes.py similarity index 100% rename from paths_cli/parsing/test_volumes.py rename to paths_cli/tests/parsing/test_volumes.py From e1a778f771651a91df5c8051dc882f2634f7dcb4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 24 Mar 2021 07:54:30 +0100 Subject: [PATCH 030/251] remove named objs; still seems to work --- paths_cli/parsing/core.py | 111 +++++++++++++++++++------------------- 1 file changed, 55 insertions(+), 56 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 95fb27e5..90689f16 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -20,63 +20,62 @@ def unlistify(obj, listified): obj = obj[0] return obj +# class NamedObjects(abc.MutableMapping): + # """Class to track named objects and their descriptions""" + # def __init__(self, global_dct): + # names, dcts = self.all_name_descriptions(global_dct) + # self.objects = {name: None for name in names} + # self.descriptions = {name: dct for name, dct in zip(names, dcts)} + + # def __getitem__(self, key): + # return self.objects[key] + + # def __setitem__(self, key, value): + # self.objects[key] = value + + # def __delitem__(self, key): + # del self.objects[key] + + # def __iter__(self): + # return iter(self.objects) + + # def __len__(self): + # return len(self.objects) + + # @staticmethod + # def all_name_descriptions(dct): + # """Find all the named objets and their dict descriptions + + # Parameters + # ---------- + # dct : dict + # output from loading YAML + # """ + # names = [] + # dcts = [] + # name = None + # if isinstance(dct, list): + # for item in dct: + # name_list, dct_list = \ + # NamedObjects.all_name_descriptions(item) + # names.extend(name_list) + # dcts.extend(dct_list) + # elif isinstance(dct, dict): + # for k, v in dct.items(): + # if isinstance(v, (dict, list)): + # name_list, dct_list = \ + # NamedObjects.all_name_descriptions(v) + # names.extend(name_list) + # dcts.extend(dct_list) + # try: + # name = dct['name'] + # except KeyError: + # pass + # else: + # names.append(name) + # dcts.append(dct) -class NamedObjects(abc.MutableMapping): - """Class to track named objects and their descriptions""" - def __init__(self, global_dct): - names, dcts = self.all_name_descriptions(global_dct) - self.objects = {name: None for name in names} - self.descriptions = {name: dct for name, dct in zip(names, dcts)} - - def __getitem__(self, key): - return self.objects[key] - - def __setitem__(self, key, value): - self.objects[key] = value - - def __delitem__(self, key): - del self.objects[key] - - def __iter__(self): - return iter(self.objects) - - def __len__(self): - return len(self.objects) - - @staticmethod - def all_name_descriptions(dct): - """Find all the named objets and their dict descriptions - - Parameters - ---------- - dct : dict - output from loading YAML - """ - names = [] - dcts = [] - name = None - if isinstance(dct, list): - for item in dct: - name_list, dct_list = \ - NamedObjects.all_name_descriptions(item) - names.extend(name_list) - dcts.extend(dct_list) - elif isinstance(dct, dict): - for k, v in dct.items(): - if isinstance(v, (dict, list)): - name_list, dct_list = \ - NamedObjects.all_name_descriptions(v) - names.extend(name_list) - dcts.extend(dct_list) - try: - name = dct['name'] - except KeyError: - pass - else: - names.append(name) - dcts.append(dct) - - return names, dcts + # return names, dcts class InstanceBuilder: From b3edf0409b28dda4a27882090923743a2128604e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 24 Mar 2021 09:26:00 +0100 Subject: [PATCH 031/251] Seems to work up to networks now! --- paths_cli/parsing/core.py | 16 +++++++- paths_cli/parsing/networks.py | 66 ++++++++++++++++++++++++++++++++ paths_cli/parsing/root_parser.py | 22 +++++++++++ paths_cli/parsing/volumes.py | 3 ++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 paths_cli/parsing/networks.py create mode 100644 paths_cli/parsing/root_parser.py diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 90689f16..0d8315e7 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -4,6 +4,8 @@ from collections import namedtuple, abc +import logging + from .errors import InputError from .tools import custom_eval @@ -92,6 +94,7 @@ def __init__(self, builder, attribute_table, defaults=None, module=None, if defaults is None: defaults = {} self.defaults = defaults + self.logger = logging.getLogger(f"parser.InstanceBuilder[{builder}]") def select_builder(self, dct): if self.module is not None: @@ -108,10 +111,15 @@ def __call__(self, dct): # for attr, func in self.attribute_table.items()} new_dct = {} for attr, func in self.attribute_table.items(): + self.logger.debug(f"{attr}: {dct[attr]}") new_dct[attr] = func(dct[attr]) builder = self.select_builder(new_dct) ops_dct = self.remapper(new_dct) - return builder(**ops_dct) + self.logger.debug("Building...") + self.logger.debug(ops_dct) + obj = builder(**ops_dct) + self.logger.debug(obj) + return obj class Parser: @@ -120,8 +128,11 @@ def __init__(self, type_dispatch, label): self.type_dispatch = type_dispatch self.label = label self.named_objs = {} + logger_name = f"parser.Parser[{label}]" + self.logger = logging.getLogger(logger_name) def _parse_str(self, name): + self.logger.debug(f"Looking for '{name}'") try: return self.named_objs[name] except KeyError as e: @@ -144,13 +155,14 @@ def _parse_dict(self, dct): dct = dct.copy() # make a local copy name = dct.pop('name', None) type_name = dct.pop('type') + self.logger.info(f"Creating {type_name} named {name}") obj = self.type_dispatch[type_name](dct) - # return obj.named(name) if name is not None: if name in self.named_objs: raise RuntimeError("Same name twice") # TODO improve obj = obj.named(name) self.named_objs[name] = obj + return obj.named(name) def parse(self, dct): diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py new file mode 100644 index 00000000..9f10298a --- /dev/null +++ b/paths_cli/parsing/networks.py @@ -0,0 +1,66 @@ +from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.tools import custom_eval +from paths_cli.parsing.volumes import volume_parser +from paths_cli.parsing.cvs import cv_parser + +build_interface_set = InstanceBuilder( + module='openpathsampling', + builder='VolumeInterfaceSet', + attribute_table={ + 'cv': cv_parser, + 'min_lambdas': custom_eval, + 'max_lambdas': custom_eval, + } +) + +def mistis_trans_info(dct): + dct = dct.copy() + transitions = dct.pop(transitions) + trans_info = [ + tuple(volume_parser(trans['initial_state']), + build_interface_set(trans['interface_set']), + volume_parser(trans['final_state'])) + for trans in transitions + ] + dct['trans_info'] = transitions + return dct + +def tis_trans_info(dct): + # remap TIS into MISTIS format + dct = dct.copy() + initial_state = dct.pop('initial_state') + final_state = dct.pop('final_state') + interface_set = dct.pop('interface_set') + dct['transitions'] = [{'initial_state': initial_state, + 'final_state': final_state, + 'interface_set': interface_set}] + return mistis_remapper(dct) + +build_tps_network = InstanceBuilder( + module='openpathsampling', + builder='TPSNetwork', + attribute_table={ + 'initial_states': volume_parser, + 'final_states': volume_parser, + } +) + +build_mistis_network = InstanceBuilder( + module='openpathsampling', + builder='MISTISNetwork', + attribute_table={'trans_info': mistis_trans_info}, +) + +build_tis_network = InstanceBuilder( + module='openpathsampling', + builder='MISTISNetwork', + attribute_table={'trans_info': tis_trans_info}, +) + +TYPE_MAPPING = { + 'tps': build_tps_network, + 'tis': build_tis_network, + 'mistis': build_mistis_network, +} + +network_parser = Parser(TYPE_MAPPING, label="network") diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py new file mode 100644 index 00000000..8631930a --- /dev/null +++ b/paths_cli/parsing/root_parser.py @@ -0,0 +1,22 @@ +from paths_cli.parsing.core import Parser +from paths_cli.parsing.engines import engine_parser +from paths_cli.parsing.cvs import cv_parser +from paths_cli.parsing.volumes import volume_parser +from paths_cli.parsing.networks import network_parser + + +TYPE_MAPPING = { + 'engines': engine_parser, + 'cvs': cv_parser, + 'volumes': volume_parser, + 'states': volume_parser, + 'networks': network_parser, +} + +def parse(dct): + objs = [] + for category, func in TYPE_MAPPING.items(): + yaml_objs = dct.get(category, []) + new = [func(obj) for obj in yaml_objs] + objs.extend(new) + return objs diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index be1d1bcb..2fac05be 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -30,6 +30,9 @@ def cv_volume_remapper(dct): remapper=cv_volume_remapper, ) +def parse_subvolumes(dct): + return [volumes_parser(d) for d in dct] + def _use_parser(dct): # this is a hack to get around circular definitions return volume_parser(dct) From 2ed6990de1b29529bc526b4671abfe597550d32e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 25 Mar 2021 04:20:04 +0100 Subject: [PATCH 032/251] Seems to work through TPS schemes! --- paths_cli/commands/contents.py | 4 ++ paths_cli/parsing/core.py | 100 ++++++++++--------------------- paths_cli/parsing/root_parser.py | 2 + paths_cli/parsing/schemes.py | 76 +++++++++++++++++++++++ paths_cli/parsing/shooting.py | 31 ++++++++++ paths_cli/parsing/strategies.py | 97 ++++++++++++++++++++++++++++++ 6 files changed, 242 insertions(+), 68 deletions(-) create mode 100644 paths_cli/parsing/schemes.py create mode 100644 paths_cli/parsing/shooting.py create mode 100644 paths_cli/parsing/strategies.py diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index cf316f5a..68bf336a 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -19,6 +19,9 @@ 'Snapshots': 'snapshots' } +import logging +logger = logging.getLogger(__name__) + @click.command( 'contents', short_help="list named objects from an OPS .nc file", @@ -48,6 +51,7 @@ def contents(input_file, table): def get_section_string(label, store): attr = NAME_TO_ATTR.get(label, label.lower()) + logger.debug(f"Working on {attr}") if attr in UNNAMED_SECTIONS: string = get_unnamed_section_string(label, store) elif attr in ['tag', 'tags']: diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 0d8315e7..53377fce 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -22,71 +22,17 @@ def unlistify(obj, listified): obj = obj[0] return obj -# class NamedObjects(abc.MutableMapping): - # """Class to track named objects and their descriptions""" - # def __init__(self, global_dct): - # names, dcts = self.all_name_descriptions(global_dct) - # self.objects = {name: None for name in names} - # self.descriptions = {name: dct for name, dct in zip(names, dcts)} - - # def __getitem__(self, key): - # return self.objects[key] - - # def __setitem__(self, key, value): - # self.objects[key] = value - - # def __delitem__(self, key): - # del self.objects[key] - - # def __iter__(self): - # return iter(self.objects) - - # def __len__(self): - # return len(self.objects) - - # @staticmethod - # def all_name_descriptions(dct): - # """Find all the named objets and their dict descriptions - - # Parameters - # ---------- - # dct : dict - # output from loading YAML - # """ - # names = [] - # dcts = [] - # name = None - # if isinstance(dct, list): - # for item in dct: - # name_list, dct_list = \ - # NamedObjects.all_name_descriptions(item) - # names.extend(name_list) - # dcts.extend(dct_list) - # elif isinstance(dct, dict): - # for k, v in dct.items(): - # if isinstance(v, (dict, list)): - # name_list, dct_list = \ - # NamedObjects.all_name_descriptions(v) - # names.extend(name_list) - # dcts.extend(dct_list) - # try: - # name = dct['name'] - # except KeyError: - # pass - # else: - # names.append(name) - # dcts.append(dct) - - # return names, dcts - - class InstanceBuilder: # TODO: add schema as an input so we can autogenerate our JSON schema! - def __init__(self, builder, attribute_table, defaults=None, module=None, - remapper=None): + def __init__(self, builder, attribute_table, optional_attributes=None, + defaults=None, module=None, remapper=None): self.module = module self.builder = builder + self.builder_name = str(self.builder) self.attribute_table = attribute_table + if optional_attributes is None: + optional_attributes = {} + self.optional_attributes = optional_attributes # TODO use none_to_default if remapper is None: remapper = lambda x: x @@ -94,7 +40,7 @@ def __init__(self, builder, attribute_table, defaults=None, module=None, if defaults is None: defaults = {} self.defaults = defaults - self.logger = logging.getLogger(f"parser.InstanceBuilder[{builder}]") + self.logger = logging.getLogger(f"parser.InstanceBuilder.{builder}") def select_builder(self, dct): if self.module is not None: @@ -103,16 +49,30 @@ def select_builder(self, dct): builder = self.builder return builder - - def __call__(self, dct): + def _parse_attrs(self, dct): # TODO: support aliases in dct[attr] - input_dct = self.defaults.copy().update(dct) - # new_dct = {attr: func(dct[attr], named_objs) - # for attr, func in self.attribute_table.items()} + input_dct = self.defaults.copy() + self.logger.debug(f"defaults: {input_dct}") + input_dct.update(dct) + self.logger.debug(f"effective input: {input_dct}") + new_dct = {} for attr, func in self.attribute_table.items(): - self.logger.debug(f"{attr}: {dct[attr]}") - new_dct[attr] = func(dct[attr]) + try: + value = input_dct[attr] + except KeyError: + raise InputError(f"'{self.builder_name}' missing required " + f"parameter '{attr}'") + self.logger.debug(f"{attr}: {input_dct[attr]}") + new_dct[attr] = func(input_dct[attr]) + + optionals = set(self.optional_attributes) & set(dct) + for attr in optionals: + new_dct[attr] = self.attribute_table[attr](dct[attr]) + + return new_dct + + def _build(self, new_dct): builder = self.select_builder(new_dct) ops_dct = self.remapper(new_dct) self.logger.debug("Building...") @@ -121,6 +81,10 @@ def __call__(self, dct): self.logger.debug(obj) return obj + def __call__(self, dct): + new_dct = self._parse_attrs(dct) + return self._build(new_dct) + class Parser: """Generic parse class; instances for each category""" diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 8631930a..3e930390 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -3,6 +3,7 @@ from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.networks import network_parser +from paths_cli.parsing.schemes import scheme_parser TYPE_MAPPING = { @@ -11,6 +12,7 @@ 'volumes': volume_parser, 'states': volume_parser, 'networks': network_parser, + 'moveschemes': scheme_parser, } def parse(dct): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py new file mode 100644 index 00000000..3cdb01c0 --- /dev/null +++ b/paths_cli/parsing/schemes.py @@ -0,0 +1,76 @@ +from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.tools import custom_eval +from paths_cli.parsing.shooting import shooting_selector_parser +from paths_cli.parsing.engines import engine_parser +from paths_cli.parsing.networks import network_parser +from paths_cli.parsing.strategies import strategy_parser + +build_spring_shooting_scheme = InstanceBuilder( + module='openpathsampling', + builder='SpringShootingMoveScheme', + attribute_table={ + 'network': network_parser, + 'k_spring': custom_eval, + 'delta_max': custom_eval, + 'engine': engine_parser, + } +) + +class StrategySchemeInstanceBuilder(InstanceBuilder): + """ + Variant of the InstanceBuilder that appends strategies to a MoveScheme + """ + def __init__(self, builder, attribute_table, defaults=None, module=None, + remapper=None, default_global_strategy=False): + from openpathsampling import strategies + super().__init__(builder, attribute_table, defaults=defaults, + module=module, remapper=remapper) + if default_global_strategy is True: + self.default_global = [strategies.OrganizeByMoveGroupStrategy()] + elif default_global_strategy is False: + self.default_global = [] + else: + self.default_global= [default_global_strategy] + + def __call__(self, dct): + new_dct = self._parse_attrs(dct) + strategies = new_dct.pop('strategies') + scheme = self._build(new_dct) + for strat in strategies + self.default_global: + scheme.append(strat) + + self.logger.debug(f"strategies: {scheme.strategies}") + return scheme + + +build_one_way_shooting_scheme = StrategySchemeInstanceBuilder( + module='openpathsampling', + builder='OneWayShootingMoveScheme', + attribute_table={ + 'network': network_parser, + 'selector': shooting_selector_parser, + 'engine': engine_parser, + 'strategies': strategy_parser, + } +) + +build_scheme = StrategySchemeInstanceBuilder( + module='openpathsampling', + builder='MoveScheme', + attribute_table={ + 'network': network_parser, + 'strategies': strategy_parser, + }, + default_global_strategy=True, +) + +scheme_parser = Parser( + type_dispatch={ + 'one-way-shooting': build_one_way_shooting_scheme, + 'spring-shooting': build_spring_shooting_scheme, + 'scheme': build_scheme, + 'default-tis': ..., + }, + label='movescheme' +) + diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py new file mode 100644 index 00000000..3137d098 --- /dev/null +++ b/paths_cli/parsing/shooting.py @@ -0,0 +1,31 @@ +from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.cvs import cv_parser +from paths_cli.parsing.tools import custom_eval +import numpy as np + +build_uniform_selector = InstanceBuilder( + module='openpathsampling', + builder='UniformSelector', + attribute_table={} +) + +def remapping_gaussian_stddev(dct): + dct['alpha'] = 0.5 / dct.pop('stddev')**2 + return dct + +build_gaussian_selector = InstanceBuilder( + module='openpathsampling', + builder='GaussianSelector', + attribute_table={'cv': cv_parser, + 'mean': custom_eval, + 'stddev': custom_eval}, + remapper=remapping_gaussian_stddev +) + +shooting_selector_parser = Parser( + type_dispatch={ + 'uniform': build_uniform_selector, + 'gaussian': build_gaussian_selector, + }, + label='shooting_selector' +) diff --git a/paths_cli/parsing/strategies.py b/paths_cli/parsing/strategies.py new file mode 100644 index 00000000..7846df93 --- /dev/null +++ b/paths_cli/parsing/strategies.py @@ -0,0 +1,97 @@ +from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.shooting import shooting_selector_parser +from paths_cli.parsing.engines import engine_parser + +build_one_way_shooting_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='OneWayShootingStrategy', + attribute_table={ + 'selector': shooting_selector_parser, + 'engine': engine_parser, + }, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +build_two_way_shooting_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='TwoWayShootingStrategy', + attribute_table={ + 'modifier': ..., + 'selector': shooting_selector_parser, + 'engine': engine_parser, + }, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +build_nearest_neighbor_repex_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='NearestNeighborRepExStrategy', + attribute_table={}, + optional_attributes={ + 'group': str, + 'replace': bool, + }, +) + +build_all_set_repex_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='AllSetRepExStrategy', + attribute_table={}, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +build_path_reversal_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='PathReversalStrategy', + attribute_table={}, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +build_minus_move_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='MinusMoveStrategy', + attribute_table={ + 'engine': engine_parser, + }, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +build_single_replica_minus_move_strategy = InstanceBuilder( + module='openpathsampling.strategies', + builder='SingleReplicaMinusMoveStrategy', + attribute_table={ + 'engine': engine_parser, + }, + optional_attributes={ + 'group': str, + 'replace': bool, + } +) + +strategy_parser = Parser( + type_dispatch={ + 'one-way-shooting': build_one_way_shooting_strategy, + 'two-way-shooting': build_two_way_shooting_strategy, + 'nearest-neighbor-repex': build_nearest_neighbor_repex_strategy, + 'all-set-repex': build_all_set_repex_strategy, + 'path-reversal': build_path_reversal_strategy, + 'minus': build_minus_move_strategy, + 'single-rep-minux': build_single_replica_minus_move_strategy, + }, + label="strategy" +) From 3c45b8724852def65ddd75ce671179466f0d77d3 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Mar 2021 20:37:40 +0100 Subject: [PATCH 033/251] steps toward parsing simulations --- paths_cli/parsing/core.py | 56 ++++++++++++++++++++++++------------ paths_cli/parsing/schemes.py | 21 ++++++++------ 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 53377fce..eb63eb9e 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -49,7 +49,20 @@ def select_builder(self, dct): builder = self.builder return builder - def _parse_attrs(self, dct): + def parse_attrs(self, dct): + """Parse the user input dictionary to mapping of name to object. + + Parameters + ---------- + dct: Dict + User input dictionary (from YAML, JSON, etc.) + + Returns + ------- + Dict : + Mapping with the keys relevant to the input dictionary, but + values are now appropriate inputs for the builder. + """ # TODO: support aliases in dct[attr] input_dct = self.defaults.copy() self.logger.debug(f"defaults: {input_dct}") @@ -72,7 +85,20 @@ def _parse_attrs(self, dct): return new_dct - def _build(self, new_dct): + def build(self, new_dct): + """Build the object from a dictionary with objects as values. + + Parameters + ---------- + new_dct : Dict + The output of :method:`.parse_attrs`. This is a mapping of the + relevant keys to instantiated objects. + + Returns + ------- + Any : + The instance for this dictionary. + """ builder = self.select_builder(new_dct) ops_dct = self.remapper(new_dct) self.logger.debug("Building...") @@ -82,8 +108,8 @@ def _build(self, new_dct): return obj def __call__(self, dct): - new_dct = self._parse_attrs(dct) - return self._build(new_dct) + new_dct = self.parse_attrs(dct) + return self.build(new_dct) class Parser: @@ -92,6 +118,7 @@ def __init__(self, type_dispatch, label): self.type_dispatch = type_dispatch self.label = label self.named_objs = {} + self.all_objs = [] logger_name = f"parser.Parser[{label}]" self.logger = logging.getLogger(logger_name) @@ -102,31 +129,24 @@ def _parse_str(self, name): except KeyError as e: raise e # TODO: replace with better error - # def _parse_str(self, name, named_objs): - # obj = named_objs[name] - # if obj is None: - # try: - # definition = named_objs.definitions[name] - # except KeyError: - # raise InputError.unknown_name(self.label, name) - # else: - # obj = self(definition, named_objects) - # named_objs[name] = obj - - # return obj - def _parse_dict(self, dct): dct = dct.copy() # make a local copy name = dct.pop('name', None) type_name = dct.pop('type') self.logger.info(f"Creating {type_name} named {name}") obj = self.type_dispatch[type_name](dct) + obj = self.register(obj, name) + return obj + + def register(self, obj, name): if name is not None: if name in self.named_objs: raise RuntimeError("Same name twice") # TODO improve obj = obj.named(name) self.named_objs[name] = obj - return obj.named(name) + obj = obj.named(name) + self.all_objs.append(obj) + return obj def parse(self, dct): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index 3cdb01c0..997bf6be 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -20,11 +20,14 @@ class StrategySchemeInstanceBuilder(InstanceBuilder): """ Variant of the InstanceBuilder that appends strategies to a MoveScheme """ - def __init__(self, builder, attribute_table, defaults=None, module=None, - remapper=None, default_global_strategy=False): + def __init__(self, builder, attribute_table, optional_attributes=None, + defaults=None, module=None, remapper=None, + default_global_strategy=False): from openpathsampling import strategies - super().__init__(builder, attribute_table, defaults=defaults, - module=module, remapper=remapper) + super().__init__(builder, attribute_table, + optional_attributes=optional_attributes, + defaults=defaults, module=module, + remapper=remapper) if default_global_strategy is True: self.default_global = [strategies.OrganizeByMoveGroupStrategy()] elif default_global_strategy is False: @@ -33,9 +36,9 @@ def __init__(self, builder, attribute_table, defaults=None, module=None, self.default_global= [default_global_strategy] def __call__(self, dct): - new_dct = self._parse_attrs(dct) - strategies = new_dct.pop('strategies') - scheme = self._build(new_dct) + new_dct = self.parse_attrs(dct) + strategies = new_dct.pop('strategies', []) + scheme = self.build(new_dct) for strat in strategies + self.default_global: scheme.append(strat) @@ -50,8 +53,10 @@ def __call__(self, dct): 'network': network_parser, 'selector': shooting_selector_parser, 'engine': engine_parser, + }, + optional_attributes={ 'strategies': strategy_parser, - } + }, ) build_scheme = StrategySchemeInstanceBuilder( From 35c9ca18ce330b6871e8d3b01d7c8e77266bdf1b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Mar 2021 20:41:45 +0100 Subject: [PATCH 034/251] Update for parsing.tools.custom_eval --- paths_cli/wizard/shooting.py | 2 +- paths_cli/wizard/wizard.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 0a96033e..97927b10 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -3,7 +3,7 @@ from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.engines import engines from paths_cli.wizard.cvs import cvs -from paths_cli.parsing.core import custom_eval +from paths_cli.parsing.tools import custom_eval import numpy as np diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index f679d0e7..b1427fcb 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -9,7 +9,7 @@ FILE_LOADING_ERROR_MSG, RestartObjectException ) from paths_cli.wizard.joke import name_joke -from paths_cli.parsing.core import custom_eval +from paths_cli.parsing.tools import custom_eval import shutil From 97189a6ac0eacd607da36bdc6d4ba89853552ee5 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 26 Mar 2021 20:46:54 +0100 Subject: [PATCH 035/251] split off _do_one in wizard --- paths_cli/tests/wizard/test_wizard.py | 7 ++++-- paths_cli/wizard/wizard.py | 31 +++++++++++++++------------ 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 4eaf1815..1dd0b966 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -197,7 +197,7 @@ def test_storage_description_line(self, count): assert line == expected def test_save_to_file(self): - pass + pytest.skip() @pytest.mark.parametrize('req,count,expected', [ (('cvs', 1, 1), 0, (True, True)), @@ -222,5 +222,8 @@ def test_ask_do_another(self, inputs, expected): if len(inputs) > 1: assert "Sorry" in console.log_text + def test_do_one(self): + pytest.skip() + def test_run_wizard(self): - pass + pytest.skip() diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index b1427fcb..6a35342b 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -242,6 +242,22 @@ def _ask_do_another(self, obj_type): self.bad_input("Sorry, I didn't understand that.") return do_another + def _do_one(self, step, req): + try: + obj = step.func(self) + except RestartObjectException: + self.say("Okay, let's try that again.") + return True + self.register(obj, step.display_name, step.store_name) + requires_another, allows_another = self._req_do_another(req) + if requires_another: + do_another = True + elif not requires_another and allows_another: + do_another = self._ask_do_another(step.display_name) + else: + do_another = False + return do_another + def run_wizard(self): self.start("Hi! I'm the OpenPathSampling Wizard.") # TODO: next line is only temporary @@ -251,20 +267,7 @@ def run_wizard(self): req = step.store_name, step.minimum, step.maximum do_another = True while do_another: - try: - obj = step.func(self) - except RestartObjectException: - self.say("Okay, let's try that again.") - continue - self.register(obj, step.display_name, step.store_name) - requires_another, allows_another = self._req_do_another(req) - if requires_another: - do_another = True - elif not requires_another and allows_another: - do_another = self._ask_do_another(step.display_name) - else: - do_another = False - + do_another = self._do_one(step, req) storage = self.get_storage() self.save_to_file(storage) From 248cef061f510f2922e58655a2fedceed17b985a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 8 Apr 2021 17:08:59 +0200 Subject: [PATCH 036/251] Add compile command --- paths_cli/commands/compile.py | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 paths_cli/commands/compile.py diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py new file mode 100644 index 00000000..346c7b84 --- /dev/null +++ b/paths_cli/commands/compile.py @@ -0,0 +1,58 @@ +import click + +from paths_cli.parsing.root_parser import parse +from paths_cli.parameters import OUTPUT_FILE + +def import_module(module_name): + try: + mod = __import__(module_name) + except ImportError: + # TODO: better error handling + raise + return mod + +def load_yaml(f): + yaml = import_module('yaml') + return yaml.load(f.read(), Loader=yaml.FullLoader) + +def load_json(f): + json = import_module('json') # this should never fail... std lib! + return json.loads(f.read()) + +def load_toml(f): + toml = import_module('toml') + return toml.loads(f.read()) + +EXTENSIONS = { + 'yaml': load_yaml, + 'yml': load_yaml, + 'json': load_json, + 'jsn': load_json, + 'toml': load_toml, +} + +def select_loader(filename): + ext = filename.split('.')[-1] + try: + return EXTENSIONS[ext] + except KeyError: + raise RuntimeError(f"Unknown file extension: {ext}") + +@click.command( + 'compile', +) +@click.argument('input_file') +@OUTPUT_FILE.clicked(required=True) +def compile_(input_file, output_file): + loader = select_loader(input_file) + with open(input_file, mode='r') as f: + dct = loader(f) + + objs = parse(dct) + # print(objs) + storage = OUTPUT_FILE.get(output_file) + storage.save(objs) + + +CLI = compile_ +SECTION = "Debug" From 5d005c3fb3811006ca25c6f4f7e73e3ea76a7dd7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 28 Apr 2021 17:24:25 +0200 Subject: [PATCH 037/251] Bump version to 0.2.2.dev0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 2d22a318..f3abdcd1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = openpathsampling-cli -version = 0.2.1 +version = 0.2.2.dev0 # version should end in .dev0 if this isn't to be released description = Command line tool for OpenPathSampling long_description = file: README.md From 53ebf97248d0e91874b890c88b68dc540f801474 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 28 Apr 2021 19:13:07 +0200 Subject: [PATCH 038/251] outline of ideas for new plugin approach --- paths_cli/commands/pathsampling.py | 4 +++ paths_cli/plugin_management.py | 39 ++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/paths_cli/commands/pathsampling.py b/paths_cli/commands/pathsampling.py index 41a75c6f..e667246b 100644 --- a/paths_cli/commands/pathsampling.py +++ b/paths_cli/commands/pathsampling.py @@ -40,3 +40,7 @@ def pathsampling_main(output_storage, scheme, init_conds, n_steps): CLI = pathsampling SECTION = "Simulation" REQUIRES_OPS = (1, 0) + +# pathsampling_plugin = paths_cli.CommandPlugin(command=pathsampling, + # section="Simulation", + # requires_ops=(1, 0)) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index fc8baa7d..df9ed4e4 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -7,6 +7,45 @@ "OPSPlugin", ['name', 'location', 'func', 'section', 'plugin_type'] ) + +class Plugin(object): + """Generic OPS plugin object""" + def __init__(self, requires_ops, requires_cli): + self.requires_ops = requires_ops + self.requires_cli = requires_cli + + +class CommandPlugin(Plugin): + """Plugin for subcommands to the OPS CLI""" + def __init__(self, command, section, requires_ops=(1, 0), + requires_cli=(0, 1)): + self.command = command + self.section = section + super().__init__(requires_ops, requires_cli) + + @property + def name(self): + return self.command.name + + +class ParserPlugin(Plugin): + """Plugin to add a new Parser (top-level stage in YAML parsing""" + def __init__(self, parser, requires_ops=(1,0), requires_cli=(0,3)): + self.parser = parser + super().__init__(requires_ops, requires_cli) + + +class InstanceBuilderPlugin(Plugin): + """ + Plugin to add a new object type (InstanceBuilder) to YAML parsing. + """ + def __init__(self, yaml_name, instance_builder, requires_ops=(1,0), + requires_cli=(0,3)): + self.yaml_name = yaml_name + self.instance_builder = instance_builder + super().__init__(requires_ops, requires_cli) + + class CLIPluginLoader(object): """Abstract object for CLI plugins From f932fce870236522fdb0cb5920f0fe0681f57d2e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 16:15:20 -0400 Subject: [PATCH 039/251] Add remaining tests for wizard --- paths_cli/tests/wizard/test_wizard.py | 106 ++++++++++++++++++++++++-- paths_cli/wizard/wizard.py | 2 +- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 1dd0b966..bf73dbc4 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -11,6 +11,49 @@ from paths_cli.wizard.wizard import * from paths_cli.wizard.steps import SINGLE_ENGINE_STEP +import openpathsampling as paths + +class MockStore: + def __init__(self): + self.all_entries = [] + self.name_dict = {} + + def register(self, obj): + self.all_entries.append(obj) + if obj.is_named: + self.name_dict[obj.name] = obj + + def __getitem__(self, key): + if isinstance(key, int): + return self.all_entries[key] + else: + return self.name_dict[key] + + def __len__(self): + return len(self.all_entries) + +class MockStorage: + def __init__(self): + self.engines = MockStore() + self.volumes = MockStore() + self.cvs = MockStore() + self.networks = MockStore() + self.schemes = MockStore() + + def save(self, obj): + class_to_store = { + paths.engines.DynamicsEngine: self.engines, + paths.Volume: self.volumes, + CoordinateFunctionCV: self.cvs, + paths.TransitionNetwork: self.networks, + paths.MoveScheme: self.schemes, + } + for cls in class_to_store: + if isinstance(obj, cls): + store = class_to_store[cls] + break + + store.register(obj) class TestWizard: @@ -196,8 +239,17 @@ def test_storage_description_line(self, count): line = self.wizard._storage_description_line('cvs') assert line == expected - def test_save_to_file(self): - pytest.skip() + def test_save_to_file(self, toy_engine): + console = MockConsole([]) + self.wizard.console = console + self.wizard.register(toy_engine, 'Engine', 'engines') + storage = MockStorage() + self.wizard.save_to_file(storage) + assert len(storage.cvs) == len(storage.volumes) == 0 + assert len(storage.networks) == len(storage.schemes) == 0 + assert len(storage.engines) == 1 + assert storage.engines[toy_engine.name] == toy_engine + assert "Everything has been stored" in self.wizard.console.log_text @pytest.mark.parametrize('req,count,expected', [ (('cvs', 1, 1), 0, (True, True)), @@ -222,8 +274,48 @@ def test_ask_do_another(self, inputs, expected): if len(inputs) > 1: assert "Sorry" in console.log_text - def test_do_one(self): - pytest.skip() - - def test_run_wizard(self): - pytest.skip() + @pytest.mark.parametrize('min_max,do_another', [ + ((1, 1), False), ((2, float('inf')), True), ((1, 2), 'asked') + ]) + def test_do_one(self, toy_engine, min_max, do_another): + step = mock.Mock( + func=mock.Mock(return_value=toy_engine), + display_name='Engine', + store_name='engines' + ) + req = ('engines', *min_max) + # mock user interaction with response 'asked' + with mock.patch.object(Wizard, '_ask_do_another', + return_value='asked'): + result = self.wizard._do_one(step, req) + + assert result == do_another + assert self.wizard.engines[toy_engine.name] == toy_engine + + def test_do_one_restart(self): + step = mock.Mock( + func=mock.Mock(side_effect=RestartObjectException()), + display_name='Engine', store_name='engines' + ) + req = ('engines', 1, 1) + result = self.wizard._do_one(step, req) + assert result is True + assert len(self.wizard.engines) == 0 + + def test_run_wizard(self, toy_engine): + step = mock.Mock( + func=mock.Mock(return_value=toy_engine), + display_name='Engine', + store_name='engines', + minimum=1, + maximum=1 + ) + self.wizard.steps = [step] + storage = MockStorage() + with mock.patch.object(Wizard, 'get_storage', + mock.Mock(return_value=storage)): + self.wizard.run_wizard() + assert len(storage.cvs) == len(storage.volumes) == 0 + assert len(storage.networks) == len(storage.schemes) == 0 + assert len(storage.engines) == 1 + assert storage.engines[toy_engine.name] == toy_engine diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 6a35342b..28aa70a7 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -217,7 +217,7 @@ def save_to_file(self, storage): for obj in store.values(): storage.save(obj) - self.say("Success! Everthing has been stored in your file.") + self.say("Success! Everything has been stored in your file.") def _req_do_another(self, req): store, min_, max_ = req From a3c7b4bf9e0f14b09b1e50fa9a316641edd56142 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 16:19:08 -0400 Subject: [PATCH 040/251] from unittest import mock... --- paths_cli/tests/wizard/test_core.py | 2 +- paths_cli/tests/wizard/test_cvs.py | 2 +- paths_cli/tests/wizard/test_engines.py | 2 +- paths_cli/tests/wizard/test_errors.py | 2 +- paths_cli/tests/wizard/test_load_from_ops.py | 2 +- paths_cli/tests/wizard/test_openmm.py | 2 +- paths_cli/tests/wizard/test_shooting.py | 2 +- paths_cli/tests/wizard/test_tools.py | 2 +- paths_cli/tests/wizard/test_tps.py | 2 +- paths_cli/tests/wizard/test_two_state_tps.py | 2 +- paths_cli/tests/wizard/test_volumes.py | 2 +- paths_cli/tests/wizard/test_wizard.py | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/paths_cli/tests/wizard/test_core.py b/paths_cli/tests/wizard/test_core.py index 31b3bc99..d69a6c8d 100644 --- a/paths_cli/tests/wizard/test_core.py +++ b/paths_cli/tests/wizard/test_core.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV diff --git a/paths_cli/tests/wizard/test_cvs.py b/paths_cli/tests/wizard/test_cvs.py index 69bea11a..c2043d02 100644 --- a/paths_cli/tests/wizard/test_cvs.py +++ b/paths_cli/tests/wizard/test_cvs.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock import numpy as np from functools import partial diff --git a/paths_cli/tests/wizard/test_engines.py b/paths_cli/tests/wizard/test_engines.py index 40655fcd..a11cec2d 100644 --- a/paths_cli/tests/wizard/test_engines.py +++ b/paths_cli/tests/wizard/test_engines.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_errors.py b/paths_cli/tests/wizard/test_errors.py index a502acc5..1f6c7f76 100644 --- a/paths_cli/tests/wizard/test_errors.py +++ b/paths_cli/tests/wizard/test_errors.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index 38601ad1..be6f3964 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock import openpathsampling as paths diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 348d06bf..b8739bf2 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_shooting.py b/paths_cli/tests/wizard/test_shooting.py index 8c3c97b0..d3baf175 100644 --- a/paths_cli/tests/wizard/test_shooting.py +++ b/paths_cli/tests/wizard/test_shooting.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from functools import partial from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_tools.py b/paths_cli/tests/wizard/test_tools.py index b8dbff3e..9700aa3e 100644 --- a/paths_cli/tests/wizard/test_tools.py +++ b/paths_cli/tests/wizard/test_tools.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.wizard.tools import * diff --git a/paths_cli/tests/wizard/test_tps.py b/paths_cli/tests/wizard/test_tps.py index 83b51f7b..6f1569a4 100644 --- a/paths_cli/tests/wizard/test_tps.py +++ b/paths_cli/tests/wizard/test_tps.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from functools import partial diff --git a/paths_cli/tests/wizard/test_two_state_tps.py b/paths_cli/tests/wizard/test_two_state_tps.py index 22d55c98..769564b2 100644 --- a/paths_cli/tests/wizard/test_two_state_tps.py +++ b/paths_cli/tests/wizard/test_two_state_tps.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 1b8495f4..56fa044b 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index bf73dbc4..945bc3b4 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import ( MockConsole, make_mock_wizard, make_mock_retry_wizard ) From a7fefd2d4aee1d90e7062663014e2579c81f3bff Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 16:22:05 -0400 Subject: [PATCH 041/251] missed one --- paths_cli/tests/wizard/mock_wizard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py index a2b0a4b9..a35d07a4 100644 --- a/paths_cli/tests/wizard/mock_wizard.py +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -1,5 +1,5 @@ from paths_cli.wizard.wizard import Wizard -import mock +from unittest import mock def make_mock_wizard(inputs): wizard = Wizard([]) From 9259acab4b21b1b83b270e4d88bba94bc89cf38a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 16:54:58 -0400 Subject: [PATCH 042/251] fix for tests locally --- paths_cli/tests/test_parameters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/test_parameters.py b/paths_cli/tests/test_parameters.py index b55c3714..4bd2651f 100644 --- a/paths_cli/tests/test_parameters.py +++ b/paths_cli/tests/test_parameters.py @@ -38,10 +38,10 @@ def undo_monkey_patch(stored_functions): import importlib importlib.reload(paths.netcdfplus) importlib.reload(paths.collectivevariable) + importlib.reload(paths.collectivevariables) importlib.reload(paths) - class ParameterTest(object): def test_parameter(self): # this is just a smoke test to contrast with the ValueError case From 10b676d5272a61549611c0f92c5752c1a2dc1c08 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 17:27:02 -0400 Subject: [PATCH 043/251] try ignoring wizard (does that fix test errors?) --- .github/workflows/test-suite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index ce27c612..cccb2080 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -57,8 +57,10 @@ jobs: fi python autorelease_check.py --branch $BRANCH --even ${EVENT} - name: "Unit tests" + env: + PY_COLORS: "1" run: | python -c "import paths_cli" - py.test -vv --cov --cov-report xml:cov.xml + py.test -vv --cov --cov-report xml:cov.xml --ignore paths_cli/tests/wizard - name: "Report coverage" run: bash <(curl -s https://codecov.io/bash) From 8b7c8b8d17b934c61288bef0785995b152ac94af Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 17:30:55 -0400 Subject: [PATCH 044/251] re-include wizard tests --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index cccb2080..08dbe5f1 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -61,6 +61,6 @@ jobs: PY_COLORS: "1" run: | python -c "import paths_cli" - py.test -vv --cov --cov-report xml:cov.xml --ignore paths_cli/tests/wizard + py.test -vv --cov --cov-report xml:cov.xml - name: "Report coverage" run: bash <(curl -s https://codecov.io/bash) From 6c37dce94df2305c74bd6c05a26b3b2d745cf4e1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 18 Jun 2021 17:53:41 -0400 Subject: [PATCH 045/251] don't actually monkeypatch when testing Wizard --- paths_cli/tests/wizard/test_wizard.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 945bc3b4..7c897405 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -303,6 +303,9 @@ def test_do_one_restart(self): assert len(self.wizard.engines) == 0 def test_run_wizard(self, toy_engine): + # skip patching the wizard; we never actually use the saving + # mechanisms and don't want to unpatch after + self.wizard._patched = True step = mock.Mock( func=mock.Mock(return_value=toy_engine), display_name='Engine', From 266432c1cc2289fbd7a176510f769731b1201f24 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 4 Jul 2021 16:08:28 -0400 Subject: [PATCH 046/251] First draft of new plugin infrastructure --- paths_cli/__init__.py | 3 ++ paths_cli/commands/contents.py | 8 ++++ paths_cli/plugin_management.py | 88 +++++++++++++++++++++++++++------- 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/paths_cli/__init__.py b/paths_cli/__init__.py index ab7f0aaf..b21a25b9 100644 --- a/paths_cli/__init__.py +++ b/paths_cli/__init__.py @@ -1,3 +1,6 @@ +from .plugin_management import ( + OPSCommandPlugin, +) from .cli import OpenPathSamplingCLI from . import commands from . import version diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index cf316f5a..f67b7f40 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -1,5 +1,6 @@ import click from paths_cli.parameters import INPUT_FILE +from paths_cli import OPSCommandPlugin UNNAMED_SECTIONS = ['steps', 'movechanges', 'samplesets', 'trajectories', 'snapshots'] @@ -115,3 +116,10 @@ def get_section_string_nameable(section, store, get_named): CLI = contents SECTION = "Miscellaneous" REQUIRES_OPS = (1, 0) + +plugin = OPSCommandPlugin( + command=contents, + section="Miscellaneous", + requires_ops=(1, 0), + requires_cli=(0, 4) +) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index df9ed4e4..edb6c147 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -3,22 +3,43 @@ import importlib import os +# TODO: this should be removed OPSPlugin = collections.namedtuple( "OPSPlugin", ['name', 'location', 'func', 'section', 'plugin_type'] ) +class PluginRegistrationError(RuntimeError): + pass +# TODO: make more generic than OPS (requires_ops => requires_lib) class Plugin(object): """Generic OPS plugin object""" def __init__(self, requires_ops, requires_cli): self.requires_ops = requires_ops self.requires_cli = requires_cli + self.location = None + self.plugin_type = None + + def attach_metadata(self, location, plugin_type): + # error is already registered and data doesn't match + error_condition = ( + (self.location is not None or self.plugin_type is not None) + and (self.location != location + or self.plugin_type != plugin_type) + ) + if error_condition: # -no-cov- + raise PluginRegistrationError( + "The plugin " + repr(self) + "has been previously " + "registered with different metadata." + ) + self.location = location + self.plugin_type = plugin_type -class CommandPlugin(Plugin): +class OPSCommandPlugin(Plugin): """Plugin for subcommands to the OPS CLI""" def __init__(self, command, section, requires_ops=(1, 0), - requires_cli=(0, 1)): + requires_cli=(0, 4)): self.command = command self.section = section super().__init__(requires_ops, requires_cli) @@ -27,20 +48,29 @@ def __init__(self, command, section, requires_ops=(1, 0), def name(self): return self.command.name + @property + def func(self): + # TODO: this is temporary to minimally change the API + # (this is what calling functions ask for + return self.command + + def __repr__(self): + return "OPSCommandPlugin(" + self.name + ")" + -class ParserPlugin(Plugin): +class OPSParserPlugin(Plugin): """Plugin to add a new Parser (top-level stage in YAML parsing""" - def __init__(self, parser, requires_ops=(1,0), requires_cli=(0,3)): + def __init__(self, parser, requires_ops=(1, 0), requires_cli=(0, 4)): self.parser = parser super().__init__(requires_ops, requires_cli) -class InstanceBuilderPlugin(Plugin): +class OPSInstanceBuilderPlugin(Plugin): """ Plugin to add a new object type (InstanceBuilder) to YAML parsing. """ - def __init__(self, yaml_name, instance_builder, requires_ops=(1,0), - requires_cli=(0,3)): + def __init__(self, yaml_name, instance_builder, requires_ops=(1, 0), + requires_cli=(0, 3)): self.yaml_name = yaml_name self.instance_builder = instance_builder super().__init__(requires_ops, requires_cli) @@ -60,10 +90,12 @@ class CLIPluginLoader(object): Details on steps 1, 2, and 4 differ based on whether this is a filesystem-based plugin or a namespace-based plugin. """ - def __init__(self, plugin_type, search_path): + def __init__(self, plugin_type, search_path, plugin_class=Plugin): self.plugin_type = plugin_type self.search_path = search_path + self.plugin_class = plugin_class + # TODO: this should be _find_candidate_modules def _find_candidates(self): raise NotImplementedError() @@ -71,6 +103,7 @@ def _find_candidates(self): def _make_nsdict(candidate): raise NotImplementedError() + # TODO: this should validate with an isinstance of the plugin class @staticmethod def _validate(nsdict): for attr in ['CLI', 'SECTION']: @@ -81,6 +114,7 @@ def _validate(nsdict): def _get_command_name(self, candidate): raise NotImplementedError() + # TODO: this should return the actual plugin objects def _find_valid(self): candidates = self._find_candidates() namespaces = {cand: self._make_nsdict(cand) for cand in candidates} @@ -88,17 +122,35 @@ def _find_valid(self): if self._validate(ns)} return valid + def _find_candidate_namespaces(self): + candidates = self._find_candidates() + namespaces = {cand: self._make_nsdict(cand) for cand in candidates} + return namespaces + + def _is_my_plugin(self, obj): + return isinstance(obj, self.plugin_class) + + def _find_plugins(self, namespaces): + for loc, ns in namespaces.items(): + for obj in ns.values(): + if self._is_my_plugin(obj): + obj.attach_metadata(loc, self.plugin_type) + yield obj + def __call__(self): - valid = self._find_valid() - plugins = [ - OPSPlugin(name=self._get_command_name(cand), - location=cand, - func=ns['CLI'], - section=ns['SECTION'], - plugin_type=self.plugin_type) - for cand, ns in valid.items() - ] + namespaces = self._find_candidate_namespaces() + plugins = list(self._find_plugins(namespaces)) return plugins + # valid = self._find_valid() + # plugins = [ + # OPSPlugin(name=self._get_command_name(cand), + # location=cand, + # func=ns['CLI'], + # section=ns['SECTION'], + # plugin_type=self.plugin_type) + # for cand, ns in valid.items() + # ] + # return plugins class FilePluginLoader(CLIPluginLoader): @@ -175,8 +227,8 @@ def _make_nsdict(candidate): return vars(candidate) def _get_command_name(self, candidate): - # +1 for the dot command_name = candidate.__name__ + # +1 for the dot command_name = command_name[len(self.search_path) + 1:] command_name = command_name.replace('_', '-') # commands use - return command_name From d444895b81fb7a67196fe7e9e4db8cb36860c77c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 6 Jul 2021 16:54:12 -0400 Subject: [PATCH 047/251] fix deprecation warnings for OPS 1.5 --- paths_cli/tests/wizard/conftest.py | 2 +- paths_cli/tests/wizard/test_cvs.py | 3 +-- paths_cli/tests/wizard/test_openmm.py | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index df6a6679..a4803c90 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -35,7 +35,7 @@ def ad_openmm(tmpdir): def ad_engine(ad_openmm): with ad_openmm.as_cwd(): pdb = md.load('ad.pdb') - topology = paths.engines.openmm.topology.MDTrajTopology( + topology = paths.engines.MDTrajTopology( pdb.topology ) engine = paths.engines.openmm.Engine( diff --git a/paths_cli/tests/wizard/test_cvs.py b/paths_cli/tests/wizard/test_cvs.py index c2043d02..10bb8252 100644 --- a/paths_cli/tests/wizard/test_cvs.py +++ b/paths_cli/tests/wizard/test_cvs.py @@ -30,8 +30,7 @@ def mock_register(obj, obj_type, store_name): patch_loc = 'paths_cli.wizard.engines.engines' with mock.patch(patch_loc, new=mock_engines): topology = _get_topology(wizard) - assert isinstance(topology, - paths.engines.openmm.topology.MDTrajTopology) + assert isinstance(topology, paths.engines.MDTrajTopology) @pytest.mark.parametrize('inputs', [ (['[[1, 2]]']), (['1, 2']), diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index b8739bf2..01d5bfad 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -42,7 +42,7 @@ def test_load_topology(ad_openmm, setup): with ad_openmm.as_cwd(): top = _load_topology(wizard) - assert isinstance(top, paths.engines.openmm.topology.MDTrajTopology) + assert isinstance(top, paths.engines.MDTrajTopology) assert wizard.console.input_call_count == len(inputs) assert expected_text in wizard.console.log_text From d84a9fb705db86047d0c314b4c769dd9bdf94547 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:16:01 -0400 Subject: [PATCH 048/251] Fix tests for moved CVs in OPS 1.5 --- paths_cli/tests/test_parameters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/paths_cli/tests/test_parameters.py b/paths_cli/tests/test_parameters.py index b55c3714..20570533 100644 --- a/paths_cli/tests/test_parameters.py +++ b/paths_cli/tests/test_parameters.py @@ -38,6 +38,7 @@ def undo_monkey_patch(stored_functions): import importlib importlib.reload(paths.netcdfplus) importlib.reload(paths.collectivevariable) + importlib.reload(paths.collectivevariables) importlib.reload(paths) From 47106e6ef599bec29eaec25957c6d06ac0134d98 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:37:33 -0400 Subject: [PATCH 049/251] Add Wizard (skeleton form) to docs --- docs/index.rst | 1 + docs/wizard.rst | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 docs/wizard.rst diff --git a/docs/index.rst b/docs/index.rst index cc120bd8..bd1daced 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -60,6 +60,7 @@ wrappers around well-tested OPS code. plugins parameters workflows + wizard full_cli api/index diff --git a/docs/wizard.rst b/docs/wizard.rst new file mode 100644 index 00000000..0bce467a --- /dev/null +++ b/docs/wizard.rst @@ -0,0 +1,8 @@ +.. _wizard: + +Writing tools for the Wizard +============================ + +The Wizard API is still rapidly in flux, and we don't recommend developing +custom Wizard tools at this time. However, once its API is more stable, the +Wizard will be extendable by outside developers. From e9dd8158f4d93d7152fb0ae6925a34acbcfe2ced Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:50:26 -0400 Subject: [PATCH 050/251] Add test job with openmm etc integrations --- .github/workflows/test-suite.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 08dbe5f1..9a7d19de 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -28,6 +28,10 @@ jobs: - 3.8 - 3.7 - 3.6 + INTEGRATIONS: [""] + include: + CONDA_PY: 3.9 + INTEGRATIONS: ['all-optionals'] steps: - uses: actions/checkout@v2 @@ -38,6 +42,9 @@ jobs: python-version: ${{ matrix.CONDA_PY }} - name: "Install testing tools" run: python -m pip install -r ./devtools/tests_require.txt + - name: "Install integrations" + if: matrinx.INTEGRATIONS == 'all-optionals' + run: conda install -c conda-forge -y openmm openmmtools mdtraj - name: "Install" run: | conda install pip From ea29ac49c902cdb764d6ef5ab64ce68f53f9539a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:52:14 -0400 Subject: [PATCH 051/251] fix whitespace in yaml --- .github/workflows/test-suite.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 9a7d19de..57d1b1dd 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -28,10 +28,10 @@ jobs: - 3.8 - 3.7 - 3.6 - INTEGRATIONS: [""] - include: - CONDA_PY: 3.9 - INTEGRATIONS: ['all-optionals'] + INTEGRATIONS: [""] + include: + CONDA_PY: 3.9 + INTEGRATIONS: ['all-optionals'] steps: - uses: actions/checkout@v2 From b52c1efaa9a8e4b255b6120d958152601d6866f6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:53:41 -0400 Subject: [PATCH 052/251] fix include in yaml --- .github/workflows/test-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 57d1b1dd..f105d010 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -30,8 +30,8 @@ jobs: - 3.6 INTEGRATIONS: [""] include: - CONDA_PY: 3.9 - INTEGRATIONS: ['all-optionals'] + - CONDA_PY: 3.9 + INTEGRATIONS: ['all-optionals'] steps: - uses: actions/checkout@v2 From 3b64ce7112d6cf871731a5a1cbad8fd76684f9c5 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 03:54:39 -0400 Subject: [PATCH 053/251] fix typo in workflow --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index f105d010..b685aca4 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -43,7 +43,7 @@ jobs: - name: "Install testing tools" run: python -m pip install -r ./devtools/tests_require.txt - name: "Install integrations" - if: matrinx.INTEGRATIONS == 'all-optionals' + if: matrix.INTEGRATIONS == 'all-optionals' run: conda install -c conda-forge -y openmm openmmtools mdtraj - name: "Install" run: | From 8dcd07d3484da34c30ad01bbb520ea65be46663e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 04:04:36 -0400 Subject: [PATCH 054/251] fix workflow --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index b685aca4..d999376c 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -31,7 +31,7 @@ jobs: INTEGRATIONS: [""] include: - CONDA_PY: 3.9 - INTEGRATIONS: ['all-optionals'] + INTEGRATIONS: 'all-optionals' steps: - uses: actions/checkout@v2 From e774caace4e91d9724e0b95cc6eeb6e4976525c7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 04:09:45 -0400 Subject: [PATCH 055/251] increase fetch-depth to help codecov --- .github/workflows/test-suite.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d999376c..455dda54 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -36,6 +36,8 @@ jobs: steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 + with: + fetch-depth: 2 - uses: conda-incubator/setup-miniconda@v2 with: auto-update-conda: true From d07699ea9590312646e003ae4d7cfac9a800dcef Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 7 Jul 2021 04:18:58 -0400 Subject: [PATCH 056/251] ... helps is fetch-depth is in the right heading --- .github/workflows/test-suite.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 455dda54..e629db78 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -35,9 +35,9 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v2 with: fetch-depth: 2 + - uses: actions/setup-python@v2 - uses: conda-incubator/setup-miniconda@v2 with: auto-update-conda: true From bbe0f85a3bb8dad18273ec557e2174f459515d23 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 09:19:54 -0400 Subject: [PATCH 057/251] starting on the wizard rewrite I think I'll merge the first draft of the wizard, then finish the YAML parsing, then come back to the this. The correct way to manage this is to make the Wizard wrap around the YAML parser as much as possible -- just reuse the InstanceBuilders from that. The we can finish the pluggable version of the wizard. --- paths_cli/wizard/cvs.py | 17 +++++ paths_cli/wizard/helper.py | 49 +++++++++++++ paths_cli/wizard/openmm.py | 103 ++++++++++++++++++++++++++- paths_cli/wizard/parameters.py | 126 +++++++++++++++++++++++++++++++++ paths_cli/wizard/volumes.py | 2 +- 5 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 paths_cli/wizard/helper.py create mode 100644 paths_cli/wizard/parameters.py diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index c6345878..bd5ced27 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -14,6 +14,23 @@ else: HAS_MDTRAJ = True +_ATOM_INDICES_HELP_STR = ( + "You should specify atom indicies enclosed in double brackets, e.g. " + "[{list_range_natoms}]" +) + +def _atom_indices_parameter(kwarg_name, cv_does_str, cv_uses_str, n_atoms): + return Parameter( + name=kwarg_name, + ask=f"Which atoms do you want to {cv_user_str}?", + loader=..., + helper=_ATOM_INDICES_HELP_STR.format( + list_range_natoms=str(list(range(n_atoms))) + ), + error="Sorry, I didn't understand '{user_str}'.", + autohelp=True + ) + def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov wizard.say("You should specify atom indices enclosed in double " "brackets, e.g, [" + str(list(range(n_atoms))) + "]") diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py new file mode 100644 index 00000000..e8464259 --- /dev/null +++ b/paths_cli/wizard/helper.py @@ -0,0 +1,49 @@ +class QuitWizard(BaseException): + pass + +def raise_quit(cmd, ctx): + raise QuitWizard() + +def raise_restart(cmd, ctx): + raise RestartObjectException() + +def force_exit(cmd, ctx): + print("Exiting...") + exit() + +HELPER_COMMANDS = { + 'q': raise_quit, + 'quit': raise_quit, + '!q': force_exit, + 'restart': raise_restart, + 'fuck': raise_restart, # easter egg ;-) + # TODO: add ls, perhaps others? +} + + +class Helper: + def __init__(self, help_func): + # TODO: generalize to get help on specific aspects? + if isinstance(help_func, str): + text = str(help_func) + help_func = lambda args, ctx: text + self.helper = help_func + self.commands = HELPER_COMMANDS.copy() # allows per-instance custom + + def run_command(self, command, context): + cmd_split = command.split() + key = cmd_split[0] + args = " ".join(cmd_split[1:]) + return self.commands[key](args, context) + + def get_help(self, help_args, context): + return self.helper(help_args, context) + + def __call__(self, user_input, context=None): + starter = user_input[0] + args = user_input[1:] + func = {'?': self.get_help, + '!': self.run_command}[starter] + return func(args, context) + + diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index c56515f6..b914dc85 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,5 +1,6 @@ from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed from paths_cli.wizard.core import get_object + try: from simtk import openmm as mm import mdtraj as md @@ -9,6 +10,100 @@ HAS_OPENMM = True +from paths_cli.wizard.parameters import ( + SimpleParameter, InstanceBuilder, load_custom_eval, CUSTOM_EVAL_ERROR +) + +### TOPOLOGY + +def _topology_loader(filename): + import openpathsampling as paths + return paths.engines.openmm.snapshot_from_pdb(filename).topology + +topology_parameter = SimpleParameter( + name='topology', + ask="Where is a PDB file describing your system?", + loader=_topology_loader, + helper=None, # TODO + error=FILE_LOADING_ERROR_MSG, +) + +### INTEGRATOR/SYSTEM (XML FILES) + +_where_is_xml = "Where is the XML file for your OpenMM {obj_type}?" +_xml_help = ( + "You can write OpenMM objects like systems and integrators to XML " + "files using the XMLSerializer class. Learn more here:\n" + "http://docs.openmm.org/latest/api-python/generated/" + "simtk.openmm.openmm.XmlSerializer.html" +) +# TODO: switch to using load_openmm_xml from input file setup +def _openmm_xml_loader(xml): + with open(xml, 'r') as xml_f: + data = xml_f.read() + return mm.XmlSerializer.deserialize(data) + +integrator_parameter = SimpleParameter( + name='integrator', + ask=_where_is_xml.format(obj_type='integrator'), + loader=_openmm_xml_loader, + helper=_xml_help, + error=FILE_LOADING_ERROR_MSG +) + +system_parameter = SimpleParameter( + name='system', + ask=_where_is_xml.format(obj_type='system'), + loader=_openmm_xml_loader, + helper=_xml_help, + error=FILE_LOADING_ERROR_MSG +) + +# these two are generic, and should be kept somewhere where they can be +# reused +n_steps_per_frame_parameter = SimpleParameter( + name="n_steps_per_frame", + ask="How many MD steps per saved frame?", + loader=load_custom_eval(int), + error=CUSTOM_EVAL_ERROR, +) + +n_frames_max_parameter = SimpleParameter( + name='n_frames_max', + ask="How many frames before aborting a trajectory?", + loader=load_custom_eval(int), + error=CUSTOM_EVAL_ERROR, +) + +# this is taken directly from the input files setup; should find a universal +# place for this (and probably other loaders) +def openmm_options(dct): + n_steps_per_frame = dct.pop('n_steps_per_frame') + n_frames_max = dct.pop('n_frames_max') + options = {'n_steps_per_frame': n_steps_per_frame, + 'n_frames_max': n_frames_max} + dct['options'] = options + return dct + +openmm_builder = InstanceBuilder( + parameters=[ + topology_parameter, + system_parameter, + integrator_parameter, + n_steps_per_frame_parameter, + n_frames_max_parameter, + ], + category='engine', + cls='openpathsampling.engines.openmm.Engine', + intro="You're doing an OpenMM engine", + help_str=None, + remapper=openmm_options +) + + + +##################################################################### + def _openmm_serialization_helper(wizard, user_input): # no-cov wizard.say("You can write OpenMM objects like systems and integrators " "to XML files using the XMLSerializer class. Learn more " @@ -77,4 +172,10 @@ def openmm(wizard): ) return engine -SUPPORTED = {"OpenMM": openmm} if HAS_OPENMM else {} +SUPPORTED = {"OpenMM": openmm_builder} if HAS_OPENMM else {} + +if __name__ == "__main__": + from paths_cli.wizard.wizard import Wizard + wizard = Wizard([]) + engine = openmm_builder(wizard) + print(engine) diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py new file mode 100644 index 00000000..134a1cfc --- /dev/null +++ b/paths_cli/wizard/parameters.py @@ -0,0 +1,126 @@ +from paths_cli.parsing.tools import custom_eval +import importlib + +from paths_cli.wizard.helper import Helper + +NO_DEFAULT = object() + +NO_PARAMETER_LOADED = object() + +CUSTOM_EVAL_ERROR = "Sorry, I couldn't understand the input '{user_str}'" + +def do_import(fully_qualified_name): + dotted = fully_qualified_name.split('.') + thing = dotted[-1] + module = ".".join(dotted[:-1]) + # stole this from SimStore + mod = importlib.import_module(module) + result = getattr(mod, thing) + return result + + +class Parameter: + def __init__(self, name, load_method): + self.name = name + self.load_method = load_method + + def __call__(self, wizard): + result = NO_PARAMETER_LOADED + while result is NO_PARAMETER_LOADED: + result = self.load_method(wizard) + return result + + +class SimpleParameter(Parameter): + def __init__(self, name, ask, loader, helper=None, error=None, + default=NO_DEFAULT, autohelp=False): + super().__init__(name, self._load_method) + self.ask = ask + self.loader = loader + if helper is None: + helper = Helper("Sorry, no help is available for this " + "parameter.") + if not isinstance(helper, Helper): + helper = Helper(helper) + self.helper = helper + + if error is None: + error = "Something went wrong processing the input '{user_str}'" + self.error = error + self.default = default + self.autohelp = autohelp + + def _process_input(self, wizard, user_str): + obj = NO_PARAMETER_LOADED + if user_str[0] in ['?', '!']: + wizard.say(self.helper(user_str)) + return NO_PARAMETER_LOADED + + try: + obj = self.loader(user_str) + except Exception as e: + wizard.exception(self.error.format(user_str=user_str), e) + if self.autohelp: + wizard.say(self.helper("?")) + + return obj + + def _load_method(self, wizard): + user_str = wizard.ask(self.ask) + result = self._process_input(wizard, user_str) + return result + + +def load_custom_eval(type_=None): + if type_ is None: + type_ = lambda x: x + def parse(input_str): + return type_(custom_eval(input_str)) + + return parse + + +class InstanceBuilder: + """ + + Parameters + ---------- + parameters: List[:class:`.Parameter`] + category: str + cls: Union[str, type] + intro: str + help_str: str + remapper: Callable[dict, dict] + make_summary: Callable[dict, str] + Function to create an output string to summarize the object that has + been created. Optional. + """ + def __init__(self, parameters, category, cls, intro=None, help_str=None, + remapper=None, make_summary=None): + self.parameters = parameters + self.param_dict = {p.name: p for p in parameters} + self.category = category + self._cls = cls + self.intro = intro + self.help_str = help_str + if remapper is None: + remapper = lambda x: x + self.remapper = remapper + if make_summary is None: + make_summary = lambda dct: None + self.make_summary = make_summary + + @property + def cls(self): + # trick to delay slow imports + if isinstance(self._cls, str): + self._cls = do_import(self._cls) + return self._cls + + def __call__(self, wizard): + if self.intro is not None: + wizard.say(self.intro) + + param_dict = {p.name: p(wizard) for p in self.parameters} + kwargs = self.remapper(param_dict) + return self.cls(**kwargs) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index e64e7211..56d26c7f 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -49,7 +49,7 @@ def cv_defined_volume(wizard): import openpathsampling as paths wizard.say("A CV-defined volume allows an interval in a CV.") cv = wizard.obj_selector('cvs', "CV", cvs) - period_min = period_max = lambda_min = lambda_max = None + lambda_min = lambda_max = None is_periodic = cv.is_periodic volume_bound_str = ("What is the {bound} allowed value for " f"'{cv.name}' in this volume?") From 2ba6390a446c80835d027e6bac4bc8b8129e308a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 09:30:51 -0400 Subject: [PATCH 058/251] finish test coverage --- paths_cli/commands/wizard.py | 2 +- paths_cli/tests/wizard/test_wizard.py | 9 ++++++++- paths_cli/wizard/cvs.py | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/paths_cli/commands/wizard.py b/paths_cli/commands/wizard.py index e319b4d8..68bede3d 100644 --- a/paths_cli/commands/wizard.py +++ b/paths_cli/commands/wizard.py @@ -5,7 +5,7 @@ 'wizard', short_help="run wizard for setting up simulations", ) -def wizard(): +def wizard(): # no-cov TWO_STATE_TPS_WIZARD.run_wizard() CLI = wizard diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 7c897405..df343563 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -72,7 +72,14 @@ def test_ask(self): assert 'bar' in console.log_text def test_ask_help(self): - pass + console = MockConsole(['?helpme', 'foo']) + self.wizard.console = console + def helper(wizard, result): + wizard.say(f"You said: {result[1:]}") + + result = self.wizard.ask('bar', helper=helper) + assert result == 'foo' + assert 'You said: helpme' in console.log_text def _generic_speak_test(self, func_name): console = MockConsole() diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index c6345878..80adacfa 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -9,7 +9,7 @@ try: import mdtraj as md -except ImportError: +except ImportError: # no-cov HAS_MDTRAJ = False else: HAS_MDTRAJ = True From 59b6fbed0153e395b74f44a8d708319e18c95aae Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 09:42:46 -0400 Subject: [PATCH 059/251] Add test for bad MDTraj atom index input --- paths_cli/tests/parsing/test_tools.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 paths_cli/tests/parsing/test_tools.py diff --git a/paths_cli/tests/parsing/test_tools.py b/paths_cli/tests/parsing/test_tools.py new file mode 100644 index 00000000..39920f59 --- /dev/null +++ b/paths_cli/tests/parsing/test_tools.py @@ -0,0 +1,14 @@ +import pytest +import numpy.testing as npt + +from paths_cli.parsing.tools import * + +@pytest.mark.parametrize('expr,expected', [ + ('1+1', 2) +]) +def test_custom_eval(expr, expected): + npt.assert_allclose(custom_eval(expr), expected) + +def test_mdtraj_parse_atomlist_bad_input(): + with pytest.raises(TypeError, match="not integers"): + mdtraj_parse_atomlist("['a', 'b']", n_atoms=2) From 4f3e53aa2032efd5f4b8138776f1d032123b89e5 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 09:50:50 -0400 Subject: [PATCH 060/251] Add tests/wizard/__init__.py --- paths_cli/tests/wizard/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 paths_cli/tests/wizard/__init__.py diff --git a/paths_cli/tests/wizard/__init__.py b/paths_cli/tests/wizard/__init__.py new file mode 100644 index 00000000..e69de29b From 0b33b7938520138549892d6ab7989694c23ccb7e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 10:02:34 -0400 Subject: [PATCH 061/251] no-cov on joke --- paths_cli/wizard/joke.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index 4f0f6b25..5e1d97a0 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -16,26 +16,26 @@ "It would also be a good name for a death metal band.", ] -def _joke1(name, obj_type): +def _joke1(name, obj_type): # no-cov return (f"I probably would have named it something like " f"'{random.choice(_NAMES)}'.") -def _joke2(name, obj_type): +def _joke2(name, obj_type): # no-cov thing = random.choice(_THINGS) joke = (f"I had {a_an(thing)} {thing} named '{name}' " f"when I was young.") return joke -def _joke3(name, obj_type): +def _joke3(name, obj_type): # no-cov return (f"I wanted to name my {random.choice(_SPAWN)} '{name}', but my " f"wife wouldn't let me.") -def _joke4(name, obj_type): +def _joke4(name, obj_type): # no-cov a_an_thing = a_an(obj_type) + f" {obj_type}" return random.choice(_MISC).format(name=name, obj_type=obj_type, a_an_thing=a_an_thing) -def name_joke(name, obj_type): +def name_joke(name, obj_type): # no-cov rnd = random.random() if rnd < 0.25: joke = _joke1 From 45e77d682e9ee77aba8eb9622d037c71984a9c79 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 10:31:16 -0400 Subject: [PATCH 062/251] long forgotten stash with lots of useful stuff --- paths_cli/parsing/core.py | 24 +++------ paths_cli/parsing/cvs.py | 2 +- paths_cli/parsing/engines.py | 2 +- paths_cli/parsing/errors.py | 2 +- paths_cli/parsing/networks.py | 18 ++++--- paths_cli/parsing/root_parser.py | 4 ++ paths_cli/parsing/schemes.py | 2 +- paths_cli/parsing/shooting.py | 2 +- paths_cli/parsing/volumes.py | 5 +- paths_cli/tests/conftest.py | 8 ++- paths_cli/tests/parsing/test_networks.py | 65 ++++++++++++++++++++++++ 11 files changed, 101 insertions(+), 33 deletions(-) create mode 100644 paths_cli/tests/parsing/test_networks.py diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index eb63eb9e..05a47d26 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -135,10 +135,10 @@ def _parse_dict(self, dct): type_name = dct.pop('type') self.logger.info(f"Creating {type_name} named {name}") obj = self.type_dispatch[type_name](dct) - obj = self.register(obj, name) + obj = self.register_object(obj, name) return obj - def register(self, obj, name): + def register_object(self, obj, name): if name is not None: if name in self.named_objs: raise RuntimeError("Same name twice") # TODO improve @@ -148,6 +148,11 @@ def register(self, obj, name): self.all_objs.append(obj) return obj + def register_builder(self, builder, name): + if name in self.type_dispatch: + raise RuntimeError(f"'{builder.name}' is already registered " + f"with {self.label}") + self.type_dispatch[name] = builder def parse(self, dct): if isinstance(dct, str): @@ -160,18 +165,3 @@ def __call__(self, dct): objs = [self.parse(d) for d in dcts] results = unlistify(objs, listified) return results - - def add_type(self, type_name, type_function): - if type_name in self.type_dispatch: - raise RuntimeError("Already exists") - self.type_dispatch[type_name] = type_function - - - -CATEGORY_ALIASES = { - "cv": ["cvs"], - "volume": ["states", "initial_state", "final_state"], - "engine": ["engines"], -} - -CANONICAL_CATEGORY = {e: k for k, v in CATEGORY_ALIASES.items() for e in v} diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index c757ffbd..48a64c9a 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -72,4 +72,4 @@ def mock_cv_builder(dct): 'mdtraj': build_mdtraj_function_cv, } -cv_parser = Parser(TYPE_MAPPING, label="CV") +cv_parser = Parser(TYPE_MAPPING, label="CVs") diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index 703db5b2..10d195fa 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -45,4 +45,4 @@ def openmm_options(dct): 'openmm': build_openmm_engine, } -engine_parser = Parser(TYPE_MAPPING, label="engine") +engine_parser = Parser(TYPE_MAPPING, label="engines") diff --git a/paths_cli/parsing/errors.py b/paths_cli/parsing/errors.py index 6e05e080..ebac708b 100644 --- a/paths_cli/parsing/errors.py +++ b/paths_cli/parsing/errors.py @@ -10,5 +10,5 @@ def invalid_input(cls, value, attr, type_name=None, name=None): @classmethod def unknown_name(cls, type_name, name): - return cls(f"Unable to find a {type_name} named {name}") + return cls(f"Unable to find object named {name} in {type_name}") diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index 9f10298a..3c0bf5d1 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -8,21 +8,23 @@ builder='VolumeInterfaceSet', attribute_table={ 'cv': cv_parser, - 'min_lambdas': custom_eval, - 'max_lambdas': custom_eval, + 'minvals': custom_eval, + 'maxvals': custom_eval, } ) def mistis_trans_info(dct): dct = dct.copy() - transitions = dct.pop(transitions) + transitions = dct.pop('transitions') trans_info = [ - tuple(volume_parser(trans['initial_state']), - build_interface_set(trans['interface_set']), - volume_parser(trans['final_state'])) + ( + volume_parser(trans['initial_state']), + build_interface_set(trans['interface_set']), + volume_parser(trans['final_state']) + ) for trans in transitions ] - dct['trans_info'] = transitions + dct['trans_info'] = trans_info return dct def tis_trans_info(dct): @@ -63,4 +65,4 @@ def tis_trans_info(dct): 'mistis': build_mistis_network, } -network_parser = Parser(TYPE_MAPPING, label="network") +network_parser = Parser(TYPE_MAPPING, label="networks") diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 3e930390..4d84e1bb 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -15,6 +15,10 @@ 'moveschemes': scheme_parser, } +def register_builder(parser_name, name, builder): + parser = TYPE_MAPPING[parser_name] + parser.register_builder(builder, name) + def parse(dct): objs = [] for category, func in TYPE_MAPPING.items(): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index 997bf6be..012e534e 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -76,6 +76,6 @@ def __call__(self, dct): 'scheme': build_scheme, 'default-tis': ..., }, - label='movescheme' + label='move schemes' ) diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py index 3137d098..97710303 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/parsing/shooting.py @@ -27,5 +27,5 @@ def remapping_gaussian_stddev(dct): 'uniform': build_uniform_selector, 'gaussian': build_gaussian_selector, }, - label='shooting_selector' + label='shooting selectors' ) diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index 2fac05be..f2599aae 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -5,7 +5,8 @@ from .cvs import cv_parser class CVVolumeInstanceBuilder(InstanceBuilder): - # subclass to handle periodic cv voluAmes + # subclass to handle periodic cv volumes + # TODO: this will be removed after OPS 2.0 is released def select_builder(self, dct): import openpathsampling as paths cv = dct['cv'] @@ -54,5 +55,5 @@ def _use_parser(dct): 'intersection': build_intersection_volume, } -volume_parser = Parser(TYPE_MAPPING, label="volume") +volume_parser = Parser(TYPE_MAPPING, label="volumes") diff --git a/paths_cli/tests/conftest.py b/paths_cli/tests/conftest.py index 4e17cc52..1488c5bc 100644 --- a/paths_cli/tests/conftest.py +++ b/paths_cli/tests/conftest.py @@ -15,10 +15,16 @@ def flat_engine(): return engine @pytest.fixture -def tps_network_and_traj(): +def cv_and_states(): cv = paths.CoordinateFunctionCV("x", lambda s: s.xyz[0][0]) state_A = paths.CVDefinedVolume(cv, float("-inf"), 0).named("A") state_B = paths.CVDefinedVolume(cv, 1, float("inf")).named("B") + return cv, state_A, state_B + + +@pytest.fixture +def tps_network_and_traj(cv_and_states): + _, state_A, state_B = cv_and_states network = paths.TPSNetwork(state_A, state_B) init_traj = make_1d_traj([-0.1, 0.1, 0.3, 0.5, 0.7, 0.9, 1.1], [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]) diff --git a/paths_cli/tests/parsing/test_networks.py b/paths_cli/tests/parsing/test_networks.py new file mode 100644 index 00000000..9a1a23a6 --- /dev/null +++ b/paths_cli/tests/parsing/test_networks.py @@ -0,0 +1,65 @@ +import pytest +from unittest import mock +import numpy as np + +import yaml +import openpathsampling as paths + +from paths_cli.parsing.networks import * + +def test_mistis_trans_info(cv_and_states): + cv, state_A, state_B = cv_and_states + dct = { + 'transitions': [{ + 'initial_state': "A", + 'final_state': "B", + 'interface_set': { + 'cv': 'cv', + 'minvals': 'float("-inf")', + 'maxvals': "np.array([0, 0.1, 0.2]) * np.pi" + } + }] + } + patch_base = 'paths_cli.parsing.networks' + + with mock.patch.dict(f"{patch_base}.volume_parser.named_objs", + {"A": state_A, "B": state_B}), \ + mock.patch.dict(f"{patch_base}.cv_parser.named_objs", + {'cv': cv}): + results = mistis_trans_info(dct) + assert len(results) == 1 + trans_info = results['trans_info'] + assert len(trans_info) == 1 + assert len(trans_info[0]) == 3 + trans = trans_info[0] + assert isinstance(trans, tuple) + assert trans[0] == state_A + assert trans[2] == state_B + assert isinstance(trans[1], paths.VolumeInterfaceSet) + ifaces = trans[1] + assert ifaces.cv == cv + assert ifaces.minvals == float("-inf") + np.testing.assert_allclose(ifaces.maxvals, + [0, np.pi / 10.0, np.pi / 5.0]) + + +def test_tis_trans_info(): + pytest.skip() + +def test_build_tps_network(cv_and_states): + _, state_A, state_B = cv_and_states + yml = "\n".join(["initial_states:", " - A", "final_states:", " - B"]) + dct = yaml.load(yml, yaml.FullLoader) + patch_loc = 'paths_cli.parsing.networks.volume_parser.named_objs' + with mock.patch.dict(patch_loc, {"A": state_A, "B": state_B}): + network = build_tps_network(dct) + assert isinstance(network, paths.TPSNetwork) + assert len(network.initial_states) == len(network.final_states) == 1 + assert network.initial_states[0] == state_A + assert network.final_states[0] == state_B + +def test_build_mistis_network(): + pytest.skip() + +def test_build_tis_network(): + pytest.skip() From 728083c96892b6f0628205c8abb5b8381f89f0a4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 10:36:49 -0400 Subject: [PATCH 063/251] add tests/parsing/__init__.py --- paths_cli/tests/parsing/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 paths_cli/tests/parsing/__init__.py diff --git a/paths_cli/tests/parsing/__init__.py b/paths_cli/tests/parsing/__init__.py new file mode 100644 index 00000000..e69de29b From 87029ddd8fc7db8e50f43051ae13f572079c02c4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 14:03:48 -0400 Subject: [PATCH 064/251] tests for volumes combinations --- paths_cli/parsing/cvs.py | 15 +++--- paths_cli/tests/parsing/test_volumes.py | 70 ++++++++++++++++++++----- 2 files changed, 63 insertions(+), 22 deletions(-) diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 48a64c9a..4d0ef45d 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -56,14 +56,13 @@ def cv_prepare_dict(dct): remapper = cv_prepare_dict, ) -# Mock for integration testing -def mock_cv_builder(dct): - from mock import Mock - from openpathsampling.experimental.storage.collective_varibles import \ - FunctionCV - mock = Mock(return_value=dct['return_value']) - return FunctionCV(mock) - +# TODO: this should replace TYPE_MAPPING and cv_parser +# MDTRAJ_PLUGIN = CVParserPlugin( + # type_name='mdtraj', + # instance_builder=build_mdtraj_function_cv, + # requires_ops=(1, 0), + # requires_cli=(0, 4), +# ) # Main CV parser diff --git a/paths_cli/tests/parsing/test_volumes.py b/paths_cli/tests/parsing/test_volumes.py index 5e6abb2c..23951d8d 100644 --- a/paths_cli/tests/parsing/test_volumes.py +++ b/paths_cli/tests/parsing/test_volumes.py @@ -1,8 +1,9 @@ import pytest -import mock +from unittest import mock import yaml import openpathsampling as paths +from openpathsampling.tests.test_helpers import make_1d_traj from paths_cli.parsing.volumes import * @@ -17,14 +18,7 @@ def setup(self): 'type': 'bar', 'func': 'foo_func'} } - mock_named_objs = mock.MagicMock() - mock_named_objs.__getitem__ = mock.Mock(return_value=self.mock_cv) - mock_named_objs.descriptions = self.named_objs_dict - self.named_objs = { - 'inline': ..., - 'external': mock_named_objs - } self.func = { 'inline': "\n ".join(["name: foo", "type: mdtraj"]), # TODO 'external': 'foo' @@ -59,10 +53,58 @@ def test_build_cv_volume(self, inline, periodic): assert isinstance(vol, expected_class) -class TestBuildIntersectionVolume: +class TestBuildCombinationVolume: def setup(self): - self.yml = "\n".join([ - 'type: intersection', 'name: inter', 'subvolumes:', - ' - type: cv-volume', - ]) - pass + from openpathsampling.experimental.storage.collective_variables \ + import CollectiveVariable + self.cv = CollectiveVariable(lambda s: s.xyz[0][0]).named('foo') + + def _vol_and_yaml(self, lambda_min, lambda_max, name): + yml = ['- type: cv-volume', ' cv: foo', + f" lambda_min: {lambda_min}", + f" lambda_max: {lambda_max}"] + vol = paths.CVDefinedVolume(self.cv, lambda_min, lambda_max).named(name) + description = {'name': name, + 'type': 'cv-volume', + 'cv': 'foo', + 'lambda_min': lambda_min, + 'lambda_max': lambda_max} + return vol, yml, description + + @pytest.mark.parametrize('combo', ['union', 'intersection']) + @pytest.mark.parametrize('inline', [True, False]) + def test_build_combo_volume(self, combo, inline): + vol_A, yaml_A, desc_A = self._vol_and_yaml(0.0, 0.55, "A") + vol_B, yaml_B, desc_B = self._vol_and_yaml(0.45, 1.0, "B") + if inline: + named_volumes_dict = {} + descriptions = {} + subvol_yaml = [' ' + line for line in yaml_A + yaml_B] + else: + named_volumes_dict = {v.name: v for v in [vol_A, vol_B]} + descriptions = {"A": desc_A, "B": desc_B} + subvol_yaml = [' - A', ' - B'] + + yml = "\n".join(["type: {combo}", "name: bar", "subvolumes:"] + + subvol_yaml) + + combo_class = {'union': paths.UnionVolume, + 'intersection': paths.IntersectionVolume}[combo] + builder = {'union': build_union_volume, + 'intersection': build_intersection_volume}[combo] + + true_vol = combo_class(vol_A, vol_B) + dct = yaml.load(yml, yaml.FullLoader) + this_mod = 'paths_cli.parsing.volumes.' + patchloc = 'paths_cli.parsing.volumes.volume_parser.named_objs' + with \ + mock.patch.dict(this_mod + 'cv_parser.named_objs', + {'foo': self.cv}) as cv_patch, \ + mock.patch.dict(this_mod + 'volume_parser.named_objs', + named_volumes_dict) as vol_patch: + vol = builder(dct) + + traj = make_1d_traj([0.5, 2.0, 0.2]) + assert vol(traj[0]) + assert not vol(traj[1]) + assert vol(traj[2]) == (combo == 'union') From ef1f08611768dd09aaba5db7fcbed98751c5cdd9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 14:12:38 -0400 Subject: [PATCH 065/251] fix for location of MDTrajTopology --- paths_cli/parsing/topology.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py index dfd5844c..4201d53b 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/parsing/topology.py @@ -17,7 +17,7 @@ def get_topology_from_file(dct): import mdtraj as md import openpathsampling as paths trj = md.load(dct) - return paths.engines.openmm.topology.MDTrajTopology(trj.topology) + return paths.engines.MDTrajTopology(trj.topology) class MultiStrategyBuilder: From 55c2dbd38f39a7f8d35dc7f59ce398d258599df1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 16:03:14 -0400 Subject: [PATCH 066/251] improvements to testing --- paths_cli/parsing/networks.py | 8 ++-- paths_cli/parsing/topology.py | 6 ++- paths_cli/tests/parsing/test_cvs.py | 1 + paths_cli/tests/parsing/test_networks.py | 61 +++++++++++++++++------- paths_cli/tests/parsing/test_topology.py | 13 +++++ 5 files changed, 66 insertions(+), 23 deletions(-) diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index 3c0bf5d1..7aaa3d95 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -19,7 +19,7 @@ def mistis_trans_info(dct): trans_info = [ ( volume_parser(trans['initial_state']), - build_interface_set(trans['interface_set']), + build_interface_set(trans['interfaces']), volume_parser(trans['final_state']) ) for trans in transitions @@ -32,11 +32,11 @@ def tis_trans_info(dct): dct = dct.copy() initial_state = dct.pop('initial_state') final_state = dct.pop('final_state') - interface_set = dct.pop('interface_set') + interface_set = dct.pop('interfaces') dct['transitions'] = [{'initial_state': initial_state, 'final_state': final_state, - 'interface_set': interface_set}] - return mistis_remapper(dct) + 'interfaces': interface_set}] + return mistis_trans_info(dct) build_tps_network = InstanceBuilder( module='openpathsampling', diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py index 4201d53b..906f5f58 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/parsing/topology.py @@ -5,10 +5,12 @@ def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" from paths_cli.parsing.engines import engine_parser if dct in engine_parser.named_objs: - engine = enginer_parser.named_objs[dct] + engine = engine_parser.named_objs[dct] try: return engine.topology - except AttributeError: + except AttributeError: # no-cov + # how could this happen? passing is correct, to raise the + # InputError from MultiStrategyBuilder, but how to test? pass def get_topology_from_file(dct): diff --git a/paths_cli/tests/parsing/test_cvs.py b/paths_cli/tests/parsing/test_cvs.py index 77d4573f..00c4517a 100644 --- a/paths_cli/tests/parsing/test_cvs.py +++ b/paths_cli/tests/parsing/test_cvs.py @@ -24,6 +24,7 @@ def setup(self): self.kwargs = "indices: [[4, 6, 8, 14]]" def test_build_mdtraj_function_cv(self): + _ = pytest.importorskip('simtk.unit') yml = self.yml.format(kwargs=self.kwargs, func="compute_dihedrals") dct = yaml.load(yml, Loader=yaml.FullLoader) cv = build_mdtraj_function_cv(dct) diff --git a/paths_cli/tests/parsing/test_networks.py b/paths_cli/tests/parsing/test_networks.py index 9a1a23a6..ed214052 100644 --- a/paths_cli/tests/parsing/test_networks.py +++ b/paths_cli/tests/parsing/test_networks.py @@ -7,13 +7,31 @@ from paths_cli.parsing.networks import * + +def check_unidirectional_tis(results, state_A, state_B, cv): + assert len(results) == 1 + trans_info = results['trans_info'] + assert len(trans_info) == 1 + assert len(trans_info[0]) == 3 + trans = trans_info[0] + assert isinstance(trans, tuple) + assert trans[0] == state_A + assert trans[2] == state_B + assert isinstance(trans[1], paths.VolumeInterfaceSet) + ifaces = trans[1] + assert ifaces.cv == cv + assert ifaces.minvals == float("-inf") + np.testing.assert_allclose(ifaces.maxvals, + [0, np.pi / 10.0, np.pi / 5.0]) + + def test_mistis_trans_info(cv_and_states): cv, state_A, state_B = cv_and_states dct = { 'transitions': [{ 'initial_state': "A", 'final_state': "B", - 'interface_set': { + 'interfaces': { 'cv': 'cv', 'minvals': 'float("-inf")', 'maxvals': "np.array([0, 0.1, 0.2]) * np.pi" @@ -27,24 +45,33 @@ def test_mistis_trans_info(cv_and_states): mock.patch.dict(f"{patch_base}.cv_parser.named_objs", {'cv': cv}): results = mistis_trans_info(dct) - assert len(results) == 1 - trans_info = results['trans_info'] - assert len(trans_info) == 1 - assert len(trans_info[0]) == 3 - trans = trans_info[0] - assert isinstance(trans, tuple) - assert trans[0] == state_A - assert trans[2] == state_B - assert isinstance(trans[1], paths.VolumeInterfaceSet) - ifaces = trans[1] - assert ifaces.cv == cv - assert ifaces.minvals == float("-inf") - np.testing.assert_allclose(ifaces.maxvals, - [0, np.pi / 10.0, np.pi / 5.0]) + check_unidirectional_tis(results, state_A, state_B, cv) + paths.InterfaceSet._reset() + + +def test_tis_trans_info(cv_and_states): + cv, state_A, state_B = cv_and_states + dct = { + 'initial_state': "A", + 'final_state': "B", + 'interfaces': { + 'cv': 'cv', + 'minvals': 'float("-inf")', + 'maxvals': 'np.array([0, 0.1, 0.2]) * np.pi', + } + } + + patch_base = 'paths_cli.parsing.networks' + with mock.patch.dict(f"{patch_base}.volume_parser.named_objs", + {"A": state_A, "B": state_B}), \ + mock.patch.dict(f"{patch_base}.cv_parser.named_objs", + {'cv': cv}): + results = tis_trans_info(dct) + + check_unidirectional_tis(results, state_A, state_B, cv) + paths.InterfaceSet._reset() -def test_tis_trans_info(): - pytest.skip() def test_build_tps_network(cv_and_states): _, state_A, state_B = cv_and_states diff --git a/paths_cli/tests/parsing/test_topology.py b/paths_cli/tests/parsing/test_topology.py index f2392a2d..01897a3b 100644 --- a/paths_cli/tests/parsing/test_topology.py +++ b/paths_cli/tests/parsing/test_topology.py @@ -1,7 +1,9 @@ import pytest from openpathsampling.tests.test_helpers import data_filename +from unittest.mock import patch from paths_cli.parsing.topology import * +from paths_cli.parsing.errors import InputError class TestBuildTopology: def test_build_topology_file(self): @@ -9,3 +11,14 @@ def test_build_topology_file(self): topology = build_topology(ad_pdb) assert topology.n_spatial == 3 assert topology.n_atoms == 1651 + + def test_build_topology_engine(self, flat_engine): + patch_loc = 'paths_cli.parsing.engines.engine_parser.named_objs' + with patch.dict(patch_loc, {'flat': flat_engine}): + topology = build_topology('flat') + assert topology.n_spatial == 3 + assert topology.n_atoms == 1 + + def test_build_topology_fail(self): + with pytest.raises(InputError): + topology = build_topology('foo') From c83eea302b6077cdcfc9cba93f14116e7ee66001 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 16:47:39 -0400 Subject: [PATCH 067/251] update all commands to use new plugins --- paths_cli/commands/append.py | 10 +++++++--- paths_cli/commands/contents.py | 7 ++----- paths_cli/commands/equilibrate.py | 11 ++++++++--- paths_cli/commands/md.py | 10 +++++++--- paths_cli/commands/pathsampling.py | 14 +++++++------- paths_cli/commands/visit_all.py | 10 +++++++--- 6 files changed, 38 insertions(+), 24 deletions(-) diff --git a/paths_cli/commands/append.py b/paths_cli/commands/append.py index 849ff635..691b4e55 100644 --- a/paths_cli/commands/append.py +++ b/paths_cli/commands/append.py @@ -1,4 +1,5 @@ import click +from paths_cli import OPSCommandPlugin from paths_cli.parameters import ( INPUT_FILE, APPEND_FILE, MULTI_CV, MULTI_ENGINE, MULTI_VOLUME, MULTI_NETWORK, MULTI_SCHEME, MULTI_TAG @@ -55,6 +56,9 @@ def append(input_file, append_file, engine, cv, volume, network, scheme, storage.close() -CLI = append -SECTION = "Miscellaneous" -REQUIRES_OPS = (1, 0) +PLUGIN = OPSCommandPlugin( + command=append, + section="Miscellaneous", + requires_ops=(1, 0), + requires_cli=(0, 3) +) diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index f67b7f40..1e9f3164 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -113,13 +113,10 @@ def get_section_string_nameable(section, store, get_named): + _item_or_items(n_unnamed)) return out_str -CLI = contents -SECTION = "Miscellaneous" -REQUIRES_OPS = (1, 0) -plugin = OPSCommandPlugin( +PLUGIN = OPSCommandPlugin( command=contents, section="Miscellaneous", requires_ops=(1, 0), - requires_cli=(0, 4) + requires_cli=(0, 3) ) diff --git a/paths_cli/commands/equilibrate.py b/paths_cli/commands/equilibrate.py index 41b74676..a519428d 100644 --- a/paths_cli/commands/equilibrate.py +++ b/paths_cli/commands/equilibrate.py @@ -1,6 +1,7 @@ import click # import openpathsampling as paths +from paths_cli import OPSCommandPlugin from paths_cli.parameters import ( INPUT_FILE, OUTPUT_FILE, INIT_CONDS, SCHEME ) @@ -58,6 +59,10 @@ def equilibrate_main(output_storage, scheme, init_conds, multiplier, return simulation.sample_set, simulation -CLI = equilibrate -SECTION = "Simulation" -REQUIRES_OPS = (1, 2) +PLUGIN = OPSCommandPlugin( + command=equilibrate, + section="Simulation", + requires_ops=(1, 2), + requires_cli=(0, 3) +) + diff --git a/paths_cli/commands/md.py b/paths_cli/commands/md.py index 22f36240..a2c74bde 100644 --- a/paths_cli/commands/md.py +++ b/paths_cli/commands/md.py @@ -1,6 +1,7 @@ import click import paths_cli.utils +from paths_cli import OPSCommandPlugin from paths_cli.parameters import (INPUT_FILE, OUTPUT_FILE, ENGINE, MULTI_ENSEMBLE, INIT_SNAP) @@ -197,7 +198,10 @@ def md_main(output_storage, engine, ensembles, nsteps, initial_frame): 'final_conditions') return trajectory, None -CLI = md -SECTION = "Simulation" -REQUIRES_OPS = (1, 0) +PLUGIN = OPSCommandPlugin( + command=md, + section="Simulation", + requires_ops=(1, 0), + requires_cli=(0, 3) +) diff --git a/paths_cli/commands/pathsampling.py b/paths_cli/commands/pathsampling.py index e667246b..03a06f73 100644 --- a/paths_cli/commands/pathsampling.py +++ b/paths_cli/commands/pathsampling.py @@ -1,6 +1,7 @@ import click # import openpathsampling as paths +from paths_cli import OPSCommandPlugin from paths_cli.parameters import ( INPUT_FILE, OUTPUT_FILE, INIT_CONDS, SCHEME, N_STEPS_MC ) @@ -37,10 +38,9 @@ def pathsampling_main(output_storage, scheme, init_conds, n_steps): return simulation.sample_set, simulation -CLI = pathsampling -SECTION = "Simulation" -REQUIRES_OPS = (1, 0) - -# pathsampling_plugin = paths_cli.CommandPlugin(command=pathsampling, - # section="Simulation", - # requires_ops=(1, 0)) +PLUGIN = OPSCommandPlugin( + command=pathsampling, + section="Simulation", + requires_ops=(1, 0), + requires_cli=(0, 3) +) diff --git a/paths_cli/commands/visit_all.py b/paths_cli/commands/visit_all.py index 4b686b56..023015de 100644 --- a/paths_cli/commands/visit_all.py +++ b/paths_cli/commands/visit_all.py @@ -1,6 +1,7 @@ import click import paths_cli.utils +from paths_cli import OPSCommandPlugin from paths_cli.parameters import (INPUT_FILE, OUTPUT_FILE, ENGINE, STATES, INIT_SNAP) @@ -41,6 +42,9 @@ def visit_all_main(output_storage, states, engine, initial_frame): return trajectory, None # no simulation object to return here -CLI = visit_all -SECTION = "Simulation" -REQUIRES_OPS = (1, 0) +PLUGIN = OPSCommandPlugin( + command=visit_all, + section="Simulation", + requires_ops=(1, 0), + requires_cli=(0, 3) +) From 002555acb8e34faf71713e6cbc0af0459fd76add Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 17:06:37 -0400 Subject: [PATCH 068/251] fix up tests --- paths_cli/cli.py | 11 ++++++----- paths_cli/plugin_management.py | 10 ++++++---- paths_cli/tests/test_plugin_management.py | 11 ++++++----- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/paths_cli/cli.py b/paths_cli/cli.py index 18000850..67583c03 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -13,7 +13,8 @@ # import click_completion # click_completion.init() -from .plugin_management import FilePluginLoader, NamespacePluginLoader +from .plugin_management import (FilePluginLoader, NamespacePluginLoader, + OPSCommandPlugin) CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -31,10 +32,10 @@ def app_dir_plugins(posix): ).resolve() / 'cli-plugins') self.plugin_loaders = [ - FilePluginLoader(commands), - FilePluginLoader(app_dir_plugins(posix=False)), - FilePluginLoader(app_dir_plugins(posix=True)), - NamespacePluginLoader('paths_cli_plugins') + FilePluginLoader(commands, OPSCommandPlugin), + FilePluginLoader(app_dir_plugins(posix=False), OPSCommandPlugin), + FilePluginLoader(app_dir_plugins(posix=True), OPSCommandPlugin), + NamespacePluginLoader('paths_cli_plugins', OPSCommandPlugin) ] plugins = sum([loader() for loader in self.plugin_loaders], []) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index edb6c147..4dc14b70 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -161,8 +161,9 @@ class FilePluginLoader(CLIPluginLoader): search_path : str path to the directory that contains plugins (OS-dependent format) """ - def __init__(self, search_path): - super().__init__(plugin_type="file", search_path=search_path) + def __init__(self, search_path, plugin_class): + super().__init__(plugin_type="file", search_path=search_path, + plugin_class=plugin_class) def _find_candidates(self): def is_plugin(filename): @@ -202,8 +203,9 @@ class NamespacePluginLoader(CLIPluginLoader): search_path : str namespace (dot-separated) where plugins can be found """ - def __init__(self, search_path): - super().__init__(plugin_type="namespace", search_path=search_path) + def __init__(self, search_path, plugin_class): + super().__init__(plugin_type="namespace", search_path=search_path, + plugin_class=plugin_class) def _find_candidates(self): # based on https://packaging.python.org/guides/creating-and-discovering-plugins/#using-namespace-packages diff --git a/paths_cli/tests/test_plugin_management.py b/paths_cli/tests/test_plugin_management.py index c830e3fa..86e2c93f 100644 --- a/paths_cli/tests/test_plugin_management.py +++ b/paths_cli/tests/test_plugin_management.py @@ -61,8 +61,9 @@ def test_find_candidates(self, command): def test_make_nsdict(self, command): candidate = self._make_candidate(command) nsdict = self.loader._make_nsdict(candidate) - assert nsdict['SECTION'] == self.expected_section[command] - assert isinstance(nsdict['CLI'], click.Command) + plugin = nsdict['PLUGIN'] + assert plugin.section == self.expected_section[command] + assert isinstance(plugin.command, click.Command) @pytest.mark.parametrize('command', ['pathsampling', 'contents']) def test_call(self, command): @@ -82,7 +83,7 @@ def setup(self): # use our own commands dir as a file-based plugin cmds_init = pathlib.Path(paths_cli.commands.__file__).resolve() self.commands_dir = cmds_init.parent - self.loader = FilePluginLoader(self.commands_dir) + self.loader = FilePluginLoader(self.commands_dir, OPSCommandPlugin) self.plugin_type = 'file' def _make_candidate(self, command): @@ -99,7 +100,7 @@ class TestNamespacePluginLoader(PluginLoaderTest): def setup(self): super().setup() self.namespace = "paths_cli.commands" - self.loader = NamespacePluginLoader(self.namespace) + self.loader = NamespacePluginLoader(self.namespace, OPSCommandPlugin) self.plugin_type = 'namespace' def _make_candidate(self, command): @@ -107,7 +108,7 @@ def _make_candidate(self, command): return importlib.import_module(name) def test_get_command_name(self): - loader = NamespacePluginLoader('foo.bar') + loader = NamespacePluginLoader('foo.bar', OPSCommandPlugin) candidate = MagicMock(__name__="foo.bar.baz_qux") expected = "baz-qux" assert loader._get_command_name(candidate) == expected From 272087be3afa8127b0954ec3051cf601ff2f486d Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 19:15:13 -0400 Subject: [PATCH 069/251] Clean up to remove unused stuff --- paths_cli/plugin_management.py | 62 ----------------------- paths_cli/tests/test_plugin_management.py | 45 ---------------- 2 files changed, 107 deletions(-) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index 4dc14b70..76fcbe0a 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -57,25 +57,6 @@ def func(self): def __repr__(self): return "OPSCommandPlugin(" + self.name + ")" - -class OPSParserPlugin(Plugin): - """Plugin to add a new Parser (top-level stage in YAML parsing""" - def __init__(self, parser, requires_ops=(1, 0), requires_cli=(0, 4)): - self.parser = parser - super().__init__(requires_ops, requires_cli) - - -class OPSInstanceBuilderPlugin(Plugin): - """ - Plugin to add a new object type (InstanceBuilder) to YAML parsing. - """ - def __init__(self, yaml_name, instance_builder, requires_ops=(1, 0), - requires_cli=(0, 3)): - self.yaml_name = yaml_name - self.instance_builder = instance_builder - super().__init__(requires_ops, requires_cli) - - class CLIPluginLoader(object): """Abstract object for CLI plugins @@ -103,25 +84,6 @@ def _find_candidates(self): def _make_nsdict(candidate): raise NotImplementedError() - # TODO: this should validate with an isinstance of the plugin class - @staticmethod - def _validate(nsdict): - for attr in ['CLI', 'SECTION']: - if attr not in nsdict: - return False - return True - - def _get_command_name(self, candidate): - raise NotImplementedError() - - # TODO: this should return the actual plugin objects - def _find_valid(self): - candidates = self._find_candidates() - namespaces = {cand: self._make_nsdict(cand) for cand in candidates} - valid = {cand: ns for cand, ns in namespaces.items() - if self._validate(ns)} - return valid - def _find_candidate_namespaces(self): candidates = self._find_candidates() namespaces = {cand: self._make_nsdict(cand) for cand in candidates} @@ -141,16 +103,6 @@ def __call__(self): namespaces = self._find_candidate_namespaces() plugins = list(self._find_plugins(namespaces)) return plugins - # valid = self._find_valid() - # plugins = [ - # OPSPlugin(name=self._get_command_name(cand), - # location=cand, - # func=ns['CLI'], - # section=ns['SECTION'], - # plugin_type=self.plugin_type) - # for cand, ns in valid.items() - # ] - # return plugins class FilePluginLoader(CLIPluginLoader): @@ -188,12 +140,6 @@ def _make_nsdict(candidate): eval(code, ns, ns) return ns - def _get_command_name(self, candidate): - _, command_name = os.path.split(candidate) - command_name = command_name[:-3] # get rid of .py - command_name = command_name.replace('_', '-') # commands use - - return command_name - class NamespacePluginLoader(CLIPluginLoader): """Load namespace plugins (plugins for wide distribution) @@ -227,11 +173,3 @@ def iter_namespace(ns_pkg): @staticmethod def _make_nsdict(candidate): return vars(candidate) - - def _get_command_name(self, candidate): - command_name = candidate.__name__ - # +1 for the dot - command_name = command_name[len(self.search_path) + 1:] - command_name = command_name.replace('_', '-') # commands use - - return command_name - diff --git a/paths_cli/tests/test_plugin_management.py b/paths_cli/tests/test_plugin_management.py index 86e2c93f..2665fd9f 100644 --- a/paths_cli/tests/test_plugin_management.py +++ b/paths_cli/tests/test_plugin_management.py @@ -10,39 +10,6 @@ # need to check that CLI is assigned to correct type import click -class TestCLIPluginLoader(object): - def setup(self): - class MockPlugin(object): - def get_dict(self): - return { - 'CLI': self.foo, - 'SECTION': "FooSection" - } - - def foo(): - pass - - self.plugin = MockPlugin() - self.loader = CLIPluginLoader(plugin_type="test", search_path="foo") - self.loader._make_nsdict = MockPlugin.get_dict - self.loader._find_candidates = MagicMock(return_value=[self.plugin]) - - @pytest.mark.parametrize('contains', ([], ['cli'], ['sec'], - ['cli', 'sec'])) - def test_validate(self, contains): - expected = len(contains) == 2 # only case where we expect correct - dct = {'cli': self.plugin.foo, 'sec': "FooSection"} - fullnames = {'cli': "CLI", 'sec': "SECTION"} - nsdict = {fullnames[obj]: dct[obj] for obj in contains} - - assert CLIPluginLoader._validate(nsdict) == expected - - def test_find_valid(self): - # smoke test for the procedure - expected = {self.plugin: self.plugin.get_dict()} - assert self.loader._find_valid() == expected - - class PluginLoaderTest(object): def setup(self): self.expected_section = {'pathsampling': "Simulation", @@ -89,12 +56,6 @@ def setup(self): def _make_candidate(self, command): return self.commands_dir / (command + ".py") - def test_get_command_name(self): - # this may someday get parametrized, set it up to make it easy - filename = "/foo/bar/baz_qux.py" - expected = "baz-qux" - assert self.loader._get_command_name(filename) == expected - class TestNamespacePluginLoader(PluginLoaderTest): def setup(self): @@ -106,9 +67,3 @@ def setup(self): def _make_candidate(self, command): name = self.namespace + "." + command return importlib.import_module(name) - - def test_get_command_name(self): - loader = NamespacePluginLoader('foo.bar', OPSCommandPlugin) - candidate = MagicMock(__name__="foo.bar.baz_qux") - expected = "baz-qux" - assert loader._get_command_name(candidate) == expected From e9a9b59be3b9a93f3e1f80d71fface18e29c54d0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 20:58:05 -0400 Subject: [PATCH 070/251] update example plugins; update some plugin docs --- example_plugins/one_pot_tps.py | 10 ++++++++++ example_plugins/tps.py | 10 ++++++++++ paths_cli/plugin_management.py | 18 +++++++++++------- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/example_plugins/one_pot_tps.py b/example_plugins/one_pot_tps.py index 1e46b4b0..d489df63 100644 --- a/example_plugins/one_pot_tps.py +++ b/example_plugins/one_pot_tps.py @@ -1,4 +1,5 @@ import click +from paths_cli import OPSCommandPlugin from paths_cli.parameters import (INPUT_FILE, OUTPUT_FILE, ENGINE, STATES, N_STEPS_MC, INIT_SNAP) from paths_cli.commands.visit_all import visit_all_main @@ -44,6 +45,15 @@ def one_pot_tps_main(output_storage, states, engine, engine_hot, equil_multiplier, equil_extra) return pathsampling_main(output_storage, scheme, equil_set, nsteps) +# these lines enable this plugin to support OPS CLI < 0.3 CLI = one_pot_tps SECTION = "Workflow" REQUIRES_OPS = (1, 2) + +# these lines enable this plugin to support OPS CLI >= 0.3 +PLUGIN = OPSCommandPlugin( + command=one_pot_tps, + section="Workflow", + requires_ops=(1, 2), + requires_cli=(0, 3) +) diff --git a/example_plugins/tps.py b/example_plugins/tps.py index 8890cb52..bde7bf9c 100644 --- a/example_plugins/tps.py +++ b/example_plugins/tps.py @@ -1,4 +1,5 @@ import click +from paths_cli import OPSCommandPlugin from paths_cli.parameters import ( INPUT_FILE, OUTPUT_FILE, ENGINE, STATES, INIT_CONDS, N_STEPS_MC ) @@ -40,8 +41,17 @@ def tps_main(engine, states, init_traj, output_storage, n_steps): return simulation.sample_set, simulation +# these lines enable this plugin to support OPS CLI < 0.3 CLI = tps SECTION = "Simulation" +# these lines enable this plugin to support OPS CLI >= 0.3 +PLUGIN = OPSCommandPlugin( + command=tps, + section="Simulation" +) + + +# this allows you to use this as a script, independently of the OPS CLI if __name__ == "__main__": tps() diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index 76fcbe0a..3df1d365 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -60,16 +60,20 @@ def __repr__(self): class CLIPluginLoader(object): """Abstract object for CLI plugins - The overall approach involves 5 steps, each of which can be overridden: + The overall approach involves 3 steps: - 1. Find candidate plugins (which must be Python modules) + 1. Find modules in the relevant locations 2. Load the namespaces associated into a dict (nsdict) - 3. Based on those namespaces, validate that the module *is* a plugin - 4. Get the associated command name - 5. Return an OPSPlugin object for each plugin + 3. Find all objects in those namespaces that are plugins - Details on steps 1, 2, and 4 differ based on whether this is a - filesystem-based plugin or a namespace-based plugin. + Additionally, we attach metadata about where the plugin was found and + what mechamism it was loaded with. + + Details on steps 1 and 2 differ based on whether this is a + filesystem-based plugin or a namespace-based plugin, and details on step + 3 can depend on the specific instance created. By default, it looks for + instances of :class:`.Plugin` (given as ``plugin_class``) but the + ``isinstance`` check can be overridden in subclasses. """ def __init__(self, plugin_type, search_path, plugin_class=Plugin): self.plugin_type = plugin_type From f7d923a9a876fe6dfc95b0e0954928207f5e6a2d Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 21:30:53 -0400 Subject: [PATCH 071/251] fully remove old OPSPlugin from tests --- paths_cli/plugin_management.py | 5 ----- paths_cli/tests/null_command.py | 16 +++++++-------- paths_cli/tests/test_cli.py | 35 +++++++++++++++++++++------------ 3 files changed, 30 insertions(+), 26 deletions(-) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index 3df1d365..acab51d0 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -3,11 +3,6 @@ import importlib import os -# TODO: this should be removed -OPSPlugin = collections.namedtuple( - "OPSPlugin", ['name', 'location', 'func', 'section', 'plugin_type'] -) - class PluginRegistrationError(RuntimeError): pass diff --git a/paths_cli/tests/null_command.py b/paths_cli/tests/null_command.py index 9f68b520..539f0052 100644 --- a/paths_cli/tests/null_command.py +++ b/paths_cli/tests/null_command.py @@ -1,7 +1,7 @@ import logging import click -from paths_cli.plugin_management import FilePluginLoader, OPSPlugin +from paths_cli.plugin_management import FilePluginLoader, OPSCommandPlugin @click.command( 'null-command', @@ -11,17 +11,17 @@ def null_command(): logger = logging.getLogger(__name__) logger.info("Running null command") -CLI = null_command -SECTION = "Workflow" +PLUGIN = OPSCommandPlugin( + command=null_command, + section="Workflow" +) class NullCommandContext(object): """Context that registers/deregisters the null command (for tests)""" def __init__(self, cli): - self.plugin = OPSPlugin(name="null-command", - location=__file__, - func=CLI, - section=SECTION, - plugin_type="file") + self.plugin = PLUGIN + self.plugin.attach_metadata(location=__file__, + plugin_type='file') cli._register_plugin(self.plugin) self.cli = cli diff --git a/paths_cli/tests/test_cli.py b/paths_cli/tests/test_cli.py index 520c1b7f..e0b93969 100644 --- a/paths_cli/tests/test_cli.py +++ b/paths_cli/tests/test_cli.py @@ -3,31 +3,40 @@ from click.testing import CliRunner from paths_cli.cli import * -from paths_cli.plugin_management import OPSPlugin +# from paths_cli.plugin_management import OPSPlugin from .null_command import NullCommandContext class TestOpenPathSamplingCLI(object): def setup(self): - def make_mock(name, helpless=False): - mock = MagicMock(return_value=name) + def make_mock(name, helpless=False, return_val=None): + if return_val is None: + return_val = name + mock = MagicMock(return_value=return_val) + mock.name = name if helpless: mock.short_help = None else: mock.short_help = name + " help" return mock + foo_plugin = OPSCommandPlugin( + command=make_mock('foo'), + section="Simulation" + ) + foo_plugin.attach_metadata(location="foo.py", + plugin_type='file') + foobar_plugin = OPSCommandPlugin( + command=make_mock('foo-bar', helpless=True, + return_val='foobar'), + section="Miscellaneous" + ) + foobar_plugin.attach_metadata(location='foo_bar.py', + plugin_type='file') + self.plugin_dict = { - 'foo': OPSPlugin(name='foo', - location='foo.py', - func=make_mock('foo'), - section='Simulation', - plugin_type='file'), - 'foo-bar': OPSPlugin(name='foo-bar', - location='foo_bar.py', - func=make_mock('foobar', helpless=True), - section='Miscellaneous', - plugin_type="file") + 'foo': foo_plugin, + 'foo-bar': foobar_plugin, } self.plugins = list(self.plugin_dict.values()) self.cli = OpenPathSamplingCLI() From acc130469565b3d6dee08c4b1177274917d6a766 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 24 Jul 2021 21:45:11 -0400 Subject: [PATCH 072/251] plugin management docstrings --- paths_cli/plugin_management.py | 59 +++++++++++++++++++++++++++++++--- paths_cli/tests/test_cli.py | 1 - 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index acab51d0..1dbb8f96 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -8,9 +8,19 @@ class PluginRegistrationError(RuntimeError): # TODO: make more generic than OPS (requires_ops => requires_lib) class Plugin(object): - """Generic OPS plugin object""" - def __init__(self, requires_ops, requires_cli): - self.requires_ops = requires_ops + """Generic plugin object + + parameters + ---------- + requires_lib: tuple + tuple representing the minimum allowed version of the underlying + library + requires_cli: tuple + tuple representing hte minimum allowed version of the command line + interface application + """ + def __init__(self, requires_lib, requires_cli): + self.requires_lib = requires_lib self.requires_cli = requires_cli self.location = None self.plugin_type = None @@ -31,8 +41,33 @@ def attach_metadata(self, location, plugin_type): self.plugin_type = plugin_type +class OPSPlugin(Plugin): + """Generic OPS plugin object. + + Really just to rename ``requires_lib`` to ``requires_ops``. + """ + def __init__(self, requires_ops, requires_cli): + super(OPSPlugin, self).__init__(requires_ops, requires_cli) + + @property + def requires_ops(self): + return self.requires_lib + + class OPSCommandPlugin(Plugin): - """Plugin for subcommands to the OPS CLI""" + """Plugin for subcommands to the OPS CLI + + Parameters + ---------- + command: :class:`click.Command` + the ``click``-wrapped command for this command + section: str + the section of the help where this command should appear + requires_ops: tuple + the minimum allowed version of OPS + requires_cli: tuple + the minimum allowed version of the OPS CLI + """ def __init__(self, command, section, requires_ops=(1, 0), requires_cli=(0, 4)): self.command = command @@ -69,6 +104,16 @@ class CLIPluginLoader(object): 3 can depend on the specific instance created. By default, it looks for instances of :class:`.Plugin` (given as ``plugin_class``) but the ``isinstance`` check can be overridden in subclasses. + + Parameters + ---------- + plugin_type : Literal["file", "namespace"] + the type of file + search_path : str + the directory or namespace to search for plugins + plugin_class: type + plugins are identified as instances of this class (override in + ``_is_my_plugin``) """ def __init__(self, plugin_type, search_path, plugin_class=Plugin): self.plugin_type = plugin_type @@ -111,6 +156,9 @@ class FilePluginLoader(CLIPluginLoader): ---------- search_path : str path to the directory that contains plugins (OS-dependent format) + plugin_class: type + plugins are identified as instances of this class (override in + ``_is_my_plugin``) """ def __init__(self, search_path, plugin_class): super().__init__(plugin_type="file", search_path=search_path, @@ -147,6 +195,9 @@ class NamespacePluginLoader(CLIPluginLoader): ---------- search_path : str namespace (dot-separated) where plugins can be found + plugin_class: type + plugins are identified as instances of this class (override in + ``_is_my_plugin``) """ def __init__(self, search_path, plugin_class): super().__init__(plugin_type="namespace", search_path=search_path, diff --git a/paths_cli/tests/test_cli.py b/paths_cli/tests/test_cli.py index e0b93969..779cc4d6 100644 --- a/paths_cli/tests/test_cli.py +++ b/paths_cli/tests/test_cli.py @@ -3,7 +3,6 @@ from click.testing import CliRunner from paths_cli.cli import * -# from paths_cli.plugin_management import OPSPlugin from .null_command import NullCommandContext From 4b3ab8ef379e875b190e1aa41b32293315233d51 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 25 Jul 2021 00:28:28 -0400 Subject: [PATCH 073/251] update docs on plugins; fix tests --- docs/plugins.rst | 51 +++++++++++++---------- paths_cli/param_core.py | 2 +- paths_cli/plugin_management.py | 4 +- paths_cli/tests/test_plugin_management.py | 6 +++ 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 10a8bd95..2d73f05a 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -8,23 +8,33 @@ There are two possible ways to distribute plugins (file plugins and namespace plugins), but a given plugin script could be distributed either way. -Writing a plugin script ------------------------ - -An OPS plugin is simply a Python module that follows a few rules. - -* It must define a variable ``CLI`` that is the main CLI function is - assigned to. -* It must define a variable ``SECTION`` to determine where to show it in - help (what kind of command it is). Valid values are ``"Simulation"``, - ``"Analysis"``, ``"Miscellaneous"``, or ``"Workflow"``. If ``SECTION`` is - defined but doesn't have one of these values, it won't show in - ``openpathsampling --help``, but might still be usable. If your command - doesn't show in the help, carefully check your spelling of the ``SECTION`` - variable. -* The main CLI function must be decorated as a ``click.command``. -* (If distributed as a file plugin) It must be possible to ``exec`` it in an - empty namespace (mainly, this can mean no relative imports). +Writing a command plugin +------------------------ + +To write an OPS command plugin, you simply need to create an instance of +:class:`paths_cli.OPSCommandPlugin` and to install the module in a location +where the CLI knows to look for it. The input parameters to +``OPSCommandPlugin`` are: + +* ``command``: This is the main CLI function for the subcommand. It must be + decorated as a ``click.Command``. +* ``section``: This is a string to determine where to show it in help (what + kind of command it is). Valid values are ``"Simulation"``, + ``"Analysis"``, ``"Miscellaneous"``, or ``"Workflow"``. If ``section`` + doesn't have one of these values, it won't show in ``openpathsampling + --help``, but might still be usable. If your command doesn't show in the + help, carefully check your spelling of the ``section`` variable. +* ``requires_ops`` (optional, default ``(1, 0)``): Minimum allowed version + of OpenPathSampling. Note that this is currently informational only, and + has no effect on functionality. +* ``requires_cli`` (optional, default ``(0, 3)``): Minimum allowed version + of the OpenPathSampling CLI. Note that this is currently informational + only, and has no effect on functionality. + + +If you distribute your plugin as a file-based plugin, be aware that it must +be possible to ``exec`` it in an empty namespace (mainly, this can mean no +relative imports). As a suggestion, I (DWHS) tend to structure my plugins as follows: @@ -41,16 +51,15 @@ As a suggestion, I (DWHS) tend to structure my plugins as follows: ... return final_status, simulation - CLI = plugin - SECTION = "MySection" + PLUGIN = OPSCommandPlugin(command=plugin, section="MySection") The basic idea is that there's a ``plugin_main`` function that is based on pure OPS, using only inputs that OPS can immediately understand (no need to process the command line). This is easy to develop/test with OPS. Then there's a wrapper function whose sole purpose is to convert the command line parameters to something OPS can understand (using the ``get`` method). This -wrapper is the ``CLI`` variable. Give it an allowed ``SECTION``, and the -plugin is ready! +wrapper is the ``command`` in you ``OPSCommandPlugin``. Also provide an +allowed ``section``, and the plugin is ready! The result is that plugins are astonishingly easy to develop, once you have the scientific code implemented in a library. This structure also makes it diff --git a/paths_cli/param_core.py b/paths_cli/param_core.py index 1ec2540c..79d37643 100644 --- a/paths_cli/param_core.py +++ b/paths_cli/param_core.py @@ -269,7 +269,7 @@ class OPSStorageLoadSingle(AbstractLoader): parameter. Each should be a callable taking a storage and the string input from the CLI, and should return the desired object or None if it cannot be found. - none_strategies : List[Callable[:class:`openpathsampling.Storage, Any]] + none_strategies : List[Callable[:class:`openpathsampling.Storage`, Any]] The strategies to be used when the CLI does not provide a value for this parameter. Each should be a callable taking a storage, and returning the desired object or None if it cannot be found. diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index 1dbb8f96..ec6e3254 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -47,14 +47,14 @@ class OPSPlugin(Plugin): Really just to rename ``requires_lib`` to ``requires_ops``. """ def __init__(self, requires_ops, requires_cli): - super(OPSPlugin, self).__init__(requires_ops, requires_cli) + super().__init__(requires_ops, requires_cli) @property def requires_ops(self): return self.requires_lib -class OPSCommandPlugin(Plugin): +class OPSCommandPlugin(OPSPlugin): """Plugin for subcommands to the OPS CLI Parameters diff --git a/paths_cli/tests/test_plugin_management.py b/paths_cli/tests/test_plugin_management.py index 2665fd9f..8715d24b 100644 --- a/paths_cli/tests/test_plugin_management.py +++ b/paths_cli/tests/test_plugin_management.py @@ -10,6 +10,12 @@ # need to check that CLI is assigned to correct type import click +def test_ops_plugin(): + plugin = OPSPlugin(requires_ops=(1,2), requires_cli=(0,4)) + assert plugin.requires_ops == (1, 2) + assert plugin.requires_cli == (0, 4) + assert plugin.requires_lib == (1, 2) + class PluginLoaderTest(object): def setup(self): self.expected_section = {'pathsampling': "Simulation", From e3ecd1e4c409bb8617cf6612e8598a90f1c12cbc Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 25 Jul 2021 02:23:20 -0400 Subject: [PATCH 074/251] Add tests for shooting --- paths_cli/parsing/shooting.py | 4 ++- paths_cli/tests/parsing/test_shooting.py | 31 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 paths_cli/tests/parsing/test_shooting.py diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py index 97710303..03096085 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/parsing/shooting.py @@ -11,11 +11,13 @@ def remapping_gaussian_stddev(dct): dct['alpha'] = 0.5 / dct.pop('stddev')**2 + dct['collectivevariable'] = dct.pop('cv') + dct['l_0'] = dct.pop('mean') return dct build_gaussian_selector = InstanceBuilder( module='openpathsampling', - builder='GaussianSelector', + builder='GaussianBiasSelector', attribute_table={'cv': cv_parser, 'mean': custom_eval, 'stddev': custom_eval}, diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/parsing/test_shooting.py new file mode 100644 index 00000000..501af6e4 --- /dev/null +++ b/paths_cli/tests/parsing/test_shooting.py @@ -0,0 +1,31 @@ +import pytest + +from paths_cli.parsing.shooting import * +import openpathsampling as paths + +from mock import patch +from openpathsampling.tests.test_helpers import make_1d_traj + +def test_remapping_gaussian_stddev(cv_and_states): + cv, _, _ = cv_and_states + dct = {'cv': cv, 'mean': 1.0, 'stddev': 2.0} + expected = {'collectivevariable': cv, 'mean': 1.0, 'alpha': 0.125} + results = remapping_gaussian_stddev(dct) + assert results == expected + +def test_build_gaussian_selector(cv_and_states): + cv, _, _ = cv_and_states + dct = {'cv': 'x', 'mean': 1.0, 'stddev': 2.0} + with patch.dict('paths_cli.parsing.shooting.cv_parser.named_objs', + {'x': cv}): + sel = build_gaussian_selector(dct) + + assert isinstance(sel, paths.GaussianBiasSelector) + traj = make_1d_traj([1.0, 1.0, 1.0]) + assert sel.f(traj[1], traj) == 1.0 + +def test_build_uniform_selector(): + sel = build_uniform_selector({}) + assert isinstance(sel, paths.UniformSelector) + traj = make_1d_traj([1.0, 1.0, 1.0]) + assert sel.f(traj[1], traj) == 1.0 From b6254ecb8d4fa37c8d548da99e4b0e7a81ab295b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 25 Jul 2021 02:30:26 -0400 Subject: [PATCH 075/251] fix shooting tests --- paths_cli/parsing/volumes.py | 3 --- paths_cli/tests/parsing/test_shooting.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index f2599aae..c0c7cbb1 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -31,9 +31,6 @@ def cv_volume_remapper(dct): remapper=cv_volume_remapper, ) -def parse_subvolumes(dct): - return [volumes_parser(d) for d in dct] - def _use_parser(dct): # this is a hack to get around circular definitions return volume_parser(dct) diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/parsing/test_shooting.py index 501af6e4..15994082 100644 --- a/paths_cli/tests/parsing/test_shooting.py +++ b/paths_cli/tests/parsing/test_shooting.py @@ -9,7 +9,7 @@ def test_remapping_gaussian_stddev(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': cv, 'mean': 1.0, 'stddev': 2.0} - expected = {'collectivevariable': cv, 'mean': 1.0, 'alpha': 0.125} + expected = {'collectivevariable': cv, 'l_0': 1.0, 'alpha': 0.125} results = remapping_gaussian_stddev(dct) assert results == expected From 3597dd6ba69fc621bc2e835ebe825fe37df578ef Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 25 Jul 2021 23:47:55 -0400 Subject: [PATCH 076/251] first steps toward autodocumenting yaml structures --- paths_cli/parsing/core.py | 81 ++++++++++++++++++++++++++++++++++- paths_cli/parsing/cvs.py | 44 +++++++++---------- paths_cli/parsing/topology.py | 13 ++++-- paths_cli/tests/conftest.py | 20 +++++++-- 4 files changed, 125 insertions(+), 33 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 05a47d26..67296439 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -1,4 +1,5 @@ import os +import json import importlib import yaml @@ -22,14 +23,67 @@ def unlistify(obj, listified): obj = obj[0] return obj +class Parameter: + SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" + def __init__(self, name, loader, required=True, json_type=None, + description=None): + json_type = self._get_from_loader(loader, 'json_type', json_type) + description = self._get_from_loader(loader, 'description', + description) + if isinstance(json_type, str): + try: + json_type = json.loads(json_type) + except json.decoder.JSONDecodeError: + # TODO: maybe log this in case it represents an issue? + pass + + self.name = name + self.loader = loader + self.json_type = json_type + self.description = description + self.required = required + + @staticmethod + def _get_from_loader(loader, attr_name, attr): + if attr is None: + try: + attr = getattr(loader, attr_name) + except AttributeError: + pass + return attr + + + def __call__(self, *args, **kwargs): + # check correct call signature here + return self.loader(*args, **kwargs) + + def to_json_schema(self, schema_context=None): + dct = { + 'type': self.json_type, + 'description': self.description, + } + return self.name, dct + + class InstanceBuilder: - # TODO: add schema as an input so we can autogenerate our JSON schema! + SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" def __init__(self, builder, attribute_table, optional_attributes=None, - defaults=None, module=None, remapper=None): + defaults=None, module=None, remapper=None, parameters=None, + object_type=None, name=None): + # temporary apporach to override attribute_table + if attribute_table is None and parameters is not None: + attribute_table = {p.name: p.loader for p in parameters + if p.required} + if optional_attributes is None and parameters is not None: + optional_attributes = {p.name: p.loader for p in parameters + if not p.required} + self.object_type = object_type + self.name = name self.module = module self.builder = builder self.builder_name = str(self.builder) self.attribute_table = attribute_table + self.parameters = parameters if optional_attributes is None: optional_attributes = {} self.optional_attributes = optional_attributes @@ -42,6 +96,29 @@ def __init__(self, builder, attribute_table, optional_attributes=None, self.defaults = defaults self.logger = logging.getLogger(f"parser.InstanceBuilder.{builder}") + @property + def schema_name(self): + if not self.name.endswith(self.object_type): + schema_name = f"{self.name}-{self.object_type}" + else: + schema_name = name + return schema_name + + def to_json_schema(self, schema_context=None): + parameters = dict(p.to_json_schema() for p in self.parameters) + properties = { + 'name': {'type': 'string'}, + 'type': {'type': 'string', + 'enum': [self.name]}, + } + properties.update(parameters) + required = [p.name for p in self.parameters if p.required] + dct = { + 'properties': properties, + 'required': required, + } + return self.schema_name, dct + def select_builder(self, dct): if self.module is not None: builder = getattr(importlib.import_module(self.module), self.builder) diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 4d0ef45d..99ed5cf6 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -1,7 +1,7 @@ import os import importlib -from .core import Parser, InstanceBuilder, custom_eval +from .core import Parser, InstanceBuilder, custom_eval, Parameter from .topology import build_topology from .errors import InputError @@ -27,33 +27,31 @@ def cv_prepare_dict(dct): # MDTraj-specific mdtraj_source = AllowedPackageHandler("mdtraj") -MDTRAJ_ATTRS = { - 'topology': build_topology, - 'func': mdtraj_source, - 'kwargs': lambda kwargs: {key: custom_eval(arg) - for key, arg in kwargs.items()}, -} +# MDTRAJ_ATTRS = { + # 'topology': build_topology, + # 'func': mdtraj_source, + # 'kwargs': lambda kwargs: {key: custom_eval(arg) + # for key, arg in kwargs.items()}, +# } -MDTRAJ_SCHEMA = { - 'topology': { - 'type': 'string', - 'description': 'topology from file or engine name', - }, - 'func': { - 'type': 'string', - 'description': 'MDTraj function, e.g., ``compute_distances``', - }, - 'kwargs': { - 'type': 'object', - 'description': 'keyword arguments for ``func``', - }, -} +MDTRAJ_PARAMETERS = [ + Parameter('topology', build_topology), + Parameter('func', mdtraj_source, json_type='string', + description="MDTraj function, e.g., ``compute_distances``"), + Parameter('kwargs', lambda kwargs: {key: custom_eval(arg) + for key, arg in kwargs.items()}, + json_type='object', + description="keyword arguments for ``func``", + required=True), # TODO: bug in current: shoudl be req=False +] build_mdtraj_function_cv = InstanceBuilder( + attribute_table=None, # temp module='openpathsampling.experimental.storage.collective_variables', builder='MDTrajFunctionCV', - attribute_table=MDTRAJ_ATTRS, - remapper = cv_prepare_dict, + # attribute_table=MDTRAJ_ATTRS, + parameters=MDTRAJ_PARAMETERS, + remapper=cv_prepare_dict, ) # TODO: this should replace TYPE_MAPPING and cv_parser diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py index 906f5f58..dc50621b 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/parsing/topology.py @@ -24,9 +24,11 @@ def get_topology_from_file(dct): class MultiStrategyBuilder: # move to core - def __init__(self, strategies, label): + def __init__(self, strategies, label, description=None, json_type=None): self.strategies = strategies self.label = label + self.description = description + self.json_type = json_type def __call__(self, dct): for strategy in self.strategies: @@ -37,6 +39,9 @@ def __call__(self, dct): # only get here if we failed raise InputError.invalid_input(dct, self.label) -build_topology = MultiStrategyBuilder([get_topology_from_file, - get_topology_from_engine], - label='topology') +build_topology = MultiStrategyBuilder( + [get_topology_from_file, get_topology_from_engine], + label='topology', + description="topology from file or engine name", + json_type='string' +) diff --git a/paths_cli/tests/conftest.py b/paths_cli/tests/conftest.py index 1488c5bc..9a44610a 100644 --- a/paths_cli/tests/conftest.py +++ b/paths_cli/tests/conftest.py @@ -21,14 +21,17 @@ def cv_and_states(): state_B = paths.CVDefinedVolume(cv, 1, float("inf")).named("B") return cv, state_A, state_B +@pytest.fixture +def transition_traj(): + init_traj = make_1d_traj([-0.1, 0.1, 0.3, 0.5, 0.7, 0.9, 1.1], + [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]) + return init_traj @pytest.fixture -def tps_network_and_traj(cv_and_states): +def tps_network_and_traj(cv_and_states, transition_traj): _, state_A, state_B = cv_and_states network = paths.TPSNetwork(state_A, state_B) - init_traj = make_1d_traj([-0.1, 0.1, 0.3, 0.5, 0.7, 0.9, 1.1], - [2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0]) - return (network, init_traj) + return network, transition_traj @pytest.fixture def tps_fixture(flat_engine, tps_network_and_traj): @@ -38,3 +41,12 @@ def tps_fixture(flat_engine, tps_network_and_traj): engine=flat_engine) init_conds = scheme.initial_conditions_from_trajectories(traj) return (scheme, network, flat_engine, init_conds) + + +@pytest.fixture +def tis_network(cv_and_states): + cv, state_A, state_B = cv_and_states + interfaces = paths.VolumeInterfaceSet(cv, float("-inf"), + [0.0, 0.1, 0.2]) + network = paths.MISTISNetwork([(state_A, interfaces, state_B)]) + return network From 1b2bab0d695db2b48df12db99e4f245e48d3a482 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 26 Jul 2021 12:45:10 -0400 Subject: [PATCH 077/251] add json schema support to MDTraj CV --- paths_cli/parsing/cvs.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 99ed5cf6..2dd77c8c 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -52,6 +52,8 @@ def cv_prepare_dict(dct): # attribute_table=MDTRAJ_ATTRS, parameters=MDTRAJ_PARAMETERS, remapper=cv_prepare_dict, + name="mdtraj", + object_type="cv" ) # TODO: this should replace TYPE_MAPPING and cv_parser From 60eb119540e39642daa0313cc09b5ca507d8767b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 26 Jul 2021 13:25:23 -0400 Subject: [PATCH 078/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/tests/wizard/test_core.py | 1 - paths_cli/tests/wizard/test_load_from_ops.py | 2 -- paths_cli/wizard/core.py | 7 +++---- paths_cli/wizard/cvs.py | 8 ++++---- paths_cli/wizard/joke.py | 12 +++--------- paths_cli/wizard/load_from_ops.py | 2 +- paths_cli/wizard/tps.py | 2 +- paths_cli/wizard/volumes.py | 8 ++++---- 8 files changed, 16 insertions(+), 26 deletions(-) diff --git a/paths_cli/tests/wizard/test_core.py b/paths_cli/tests/wizard/test_core.py index d69a6c8d..62827286 100644 --- a/paths_cli/tests/wizard/test_core.py +++ b/paths_cli/tests/wizard/test_core.py @@ -48,4 +48,3 @@ def test_get_missing_object(length, expected): result = get_missing_object(wizard, dct, display_name='string', fallback_func=fallback) assert result == expected - pass diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index be6f3964..02ce4a2f 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -94,5 +94,3 @@ def test_load_from_ops(ops_file_fixture): assert isinstance(obj, NamedObj) assert obj.name == 'bar' - - pass diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index fdeee94e..a34eaa19 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -13,12 +13,12 @@ def interpret_req(req): return str(min_) if min_ >= 1: - string += "at least " + str(min_) + string += f"at least {min_}" if max_ < float("inf"): - if string != "": + if string: string += " and " - string += "at most " + str(max_) + string += f"at most {max_}" return string @@ -44,4 +44,3 @@ def inner(*args, **kwargs): return obj return inner - diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 80adacfa..41335794 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -80,10 +80,10 @@ def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, kwargs = {kwarg_name: indices} summary = ("Here's what we'll create:\n" - " Function: " + str(func.__name__) + "\n" - " Atoms: " + " ".join([str(topology.mdtraj.atom(i)) - for i in indices[0]]) + "\n" - " Topology: " + repr(topology.mdtraj)) + f" Function: {func.__name__}\n" + f" Atoms: {" ".join([str(topology.mdtraj.atom(i)) + for i in indices[0]])} \n" + f" Topology: {repr(topology.mdtraj))}" wizard.say(summary) return MDTrajFunctionCV(func, topology, period_min=period_min, diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index 5e1d97a0..52546ef2 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -36,15 +36,9 @@ def _joke4(name, obj_type): # no-cov a_an_thing=a_an_thing) def name_joke(name, obj_type): # no-cov - rnd = random.random() - if rnd < 0.25: - joke = _joke1 - elif rnd < 0.50: - joke = _joke2 - elif rnd < 0.65: - joke = _joke3 - else: - joke = _joke4 + jokes = [_joke1, _joke2, _joke3, _joke4] + weights = [5, 5, 3, 7] + joke = random.choices(jokes, weights=weights)[0] return joke(name, obj_type) if __name__ == "__main__": diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 98f19d77..8d48e7fa 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -9,7 +9,7 @@ def list_items(wizard, user_input): store = getattr(storage, store_name) names = [obj for obj in store if obj.is_named] outstr = "\n".join(['* ' + obj.name for obj in names]) - wizard.say("Here's what I found:\n\n" + outstr) + wizard.say(f"Here's what I found:\n\n{outstr}) return list_items diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 72d8b3de..03121eb0 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -6,7 +6,7 @@ from functools import partial def tps_network(wizard): - raise NotImplementedError("Still need to add other network choic") + raise NotImplementedError("Still need to add other network choices") def tps_scheme(wizard, network=None): diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index e64e7211..058a25ed 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -17,12 +17,12 @@ def _vol_intro(wizard, as_state): def _binary_func_volume(wizard, op): wizard.say("Let's make the first constituent volume:") vol1 = volumes(wizard) - wizard.say(f"The first volume is:\n{str(vol1)}") + wizard.say(f"The first volume is:\n{vol1}") wizard.say("Let's make the second constituent volume:") vol2 = volumes(wizard) - wizard.say(f"The second volume is:\n{str(vol2)}") + wizard.say(f"The second volume is:\n{vol2}") vol = op(vol1, vol2) - wizard.say(f"Created a volume:\n{str(vol)}") + wizard.say(f"Created a volume:\n{vol}") return vol def intersection_volume(wizard): @@ -42,7 +42,7 @@ def negated_volume(wizard): wizard.say("Let's make the subvolume.") subvol = volumes(wizard) vol = ~subvol - wizard.say(f"Created a volume:\n{str(vol)}") + wizard.say(f"Created a volume:\n{vol}") return vol def cv_defined_volume(wizard): From 3d92cd248060aac02acc0e9686408ef914e2d62b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 26 Jul 2021 14:43:57 -0400 Subject: [PATCH 079/251] misc review cleanup --- paths_cli/parsing/tools.py | 9 +++++++++ paths_cli/tests/parsing/test_tools.py | 6 +++++- paths_cli/tests/utils.py | 18 ++++++++++++++++++ paths_cli/tests/wizard/test_core.py | 20 -------------------- paths_cli/tests/wizard/test_openmm.py | 5 ++++- paths_cli/tests/wizard/test_tools.py | 2 +- paths_cli/wizard/cvs.py | 6 +++--- paths_cli/wizard/load_from_ops.py | 2 +- paths_cli/wizard/openmm.py | 8 +++++--- paths_cli/wizard/tools.py | 2 +- 10 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 paths_cli/tests/utils.py diff --git a/paths_cli/parsing/tools.py b/paths_cli/parsing/tools.py index 77b48380..1663f6d4 100644 --- a/paths_cli/parsing/tools.py +++ b/paths_cli/parsing/tools.py @@ -1,8 +1,17 @@ import numpy as np def custom_eval(obj, named_objs=None): + """Parse user input to allow simple math. + + This allows certain user input to be treated as a simplified subset of + Python. In particular, this is intended to allow simple arithmetic. It + allows use of the modules numpy (as ``np``) and math, which provide + potentially useful functions (e.g., ``cos``) as well as constants (e.g., + ``pi``). + """ string = str(obj) # TODO: check that the only attribute access comes from a whitelist + # (parse the AST for that) namespace = { 'np': __import__('numpy'), 'math': __import__('math'), diff --git a/paths_cli/tests/parsing/test_tools.py b/paths_cli/tests/parsing/test_tools.py index 39920f59..477c210b 100644 --- a/paths_cli/tests/parsing/test_tools.py +++ b/paths_cli/tests/parsing/test_tools.py @@ -1,10 +1,14 @@ import pytest import numpy.testing as npt +import numpy as np +import math from paths_cli.parsing.tools import * @pytest.mark.parametrize('expr,expected', [ - ('1+1', 2) + ('1+1', 2), + ('np.pi / 2', np.pi / 2), + ('math.cos(1.5)', math.cos(1.5)), ]) def test_custom_eval(expr, expected): npt.assert_allclose(custom_eval(expr), expected) diff --git a/paths_cli/tests/utils.py b/paths_cli/tests/utils.py new file mode 100644 index 00000000..7b1471aa --- /dev/null +++ b/paths_cli/tests/utils.py @@ -0,0 +1,18 @@ +import urllib.request + +try: + urllib.request.urlopen('https://www.google.com') +except: + HAS_INTERNET = False +else: + HAS_INTERNET = True + +def assert_url(url): + if not HAS_INTERNET: + pytest.skip("Internet connection seems faulty") + + # TODO: On a 404 this will raise a urllib.error.HTTPError. It would be + # nice to give some better output to the user here. + resp = urllib.request.urlopen(url) + assert resp.status == 200 + diff --git a/paths_cli/tests/wizard/test_core.py b/paths_cli/tests/wizard/test_core.py index 62827286..272267ed 100644 --- a/paths_cli/tests/wizard/test_core.py +++ b/paths_cli/tests/wizard/test_core.py @@ -10,26 +10,6 @@ make_mock_wizard, make_mock_retry_wizard ) -# def test_name(): - # wizard = make_mock_wizard('foo') - # cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) - # assert not cv.is_named - # result = name(wizard, cv, obj_type="CV", store_name="cvs") - # assert result is cv - # assert result.is_named - # assert result.name == 'foo' - -# def test_name_exists(): - # wizard = make_mock_retry_wizard(['foo', 'bar']) - # wizard.cvs['foo'] = 'placeholder' - # cv = CoordinateFunctionCV(lambda s: s.xyz[0][0]) - # assert not cv.is_named - # result = name(wizard, cv, obj_type="CV", store_name="cvs") - # assert result is cv - # assert result.is_named - # assert result.name == 'bar' - # assert wizard.console.input.call_count == 2 - @pytest.mark.parametrize('req,expected', [ (('foo', 2, 2), '2'), (('foo', 2, float('inf')), 'at least 2'), (('foo', 0, 2), 'at most 2'), diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 01d5bfad..5d785008 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -2,11 +2,14 @@ from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard +from paths_cli.tests.utils import assert_url from paths_cli.wizard.openmm import ( - _load_openmm_xml, _load_topology, openmm + _load_openmm_xml, _load_topology, openmm, OPENMM_SERIALIZATION_URL ) +def test_helper_url(): + assert_url(OPENMM_SERIALIZATION_URL) @pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) def test_load_openmm_xml(ad_openmm, obj_type): diff --git a/paths_cli/tests/wizard/test_tools.py b/paths_cli/tests/wizard/test_tools.py index 9700aa3e..acc1bcb9 100644 --- a/paths_cli/tests/wizard/test_tools.py +++ b/paths_cli/tests/wizard/test_tools.py @@ -12,7 +12,7 @@ def test_a_an(word, expected): @pytest.mark.parametrize('user_inp,expected', [ ('y', True), ('n', False), - # ('Y', True), ('N', False), ('yes', True), ('no', False) + ('Y', True), ('N', False), ('yes', True), ('no', False) ]) def test_yes_no(user_inp, expected): assert yes_no(user_inp) == expected diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 41335794..376dd085 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -78,12 +78,12 @@ def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms, cv_user_str=cv_user_prompt) kwargs = {kwarg_name: indices} + atoms_str = " ".join([str(topology.mdtraj.atom(i)) for i in indices[0]]) summary = ("Here's what we'll create:\n" f" Function: {func.__name__}\n" - f" Atoms: {" ".join([str(topology.mdtraj.atom(i)) - for i in indices[0]])} \n" - f" Topology: {repr(topology.mdtraj))}" + f" Atoms: {atoms_str}\n" + f" Topology: {repr(topology.mdtraj)}") wizard.say(summary) return MDTrajFunctionCV(func, topology, period_min=period_min, diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 8d48e7fa..a65ac313 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -9,7 +9,7 @@ def list_items(wizard, user_input): store = getattr(storage, store_name) names = [obj for obj in store if obj.is_named] outstr = "\n".join(['* ' + obj.name for obj in names]) - wizard.say(f"Here's what I found:\n\n{outstr}) + wizard.say(f"Here's what I found:\n\n{outstr}") return list_items diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index c56515f6..6ad3338a 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -8,13 +8,15 @@ else: HAS_OPENMM = True +OPENMM_SERIALIZATION_URL=( + "http://docs.openmm.org/latest/api-python/generated/" + "simtk.openmm.openmm.XmlSerializer.html" +) def _openmm_serialization_helper(wizard, user_input): # no-cov wizard.say("You can write OpenMM objects like systems and integrators " "to XML files using the XMLSerializer class. Learn more " - "here: \n" - "http://docs.openmm.org/latest/api-python/generated/" - "simtk.openmm.openmm.XmlSerializer.html") + f"here: \n{OPENMM_SERIALIZATION_URL}") @get_object diff --git a/paths_cli/wizard/tools.py b/paths_cli/wizard/tools.py index a1bc158b..058965fa 100644 --- a/paths_cli/wizard/tools.py +++ b/paths_cli/wizard/tools.py @@ -2,4 +2,4 @@ def a_an(obj): return "an" if obj[0].lower() in "aeiou" else "a" def yes_no(char): - return {'y': True, 'n': False}[char] + return {'yes': True, 'no': False, 'y': True, 'n': False}[char.lower()] From 47713498b414ce5223b9aaa0232a21a4448ee58c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 26 Jul 2021 15:05:11 -0400 Subject: [PATCH 080/251] Apply suggestions from code review Co-authored-by: Sander Roet --- docs/plugins.rst | 3 +-- paths_cli/plugin_management.py | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/plugins.rst b/docs/plugins.rst index 2d73f05a..72bfe569 100644 --- a/docs/plugins.rst +++ b/docs/plugins.rst @@ -17,7 +17,7 @@ where the CLI knows to look for it. The input parameters to ``OPSCommandPlugin`` are: * ``command``: This is the main CLI function for the subcommand. It must be - decorated as a ``click.Command``. + decorated as a ``click.command``. * ``section``: This is a string to determine where to show it in help (what kind of command it is). Valid values are ``"Simulation"``, ``"Analysis"``, ``"Miscellaneous"``, or ``"Workflow"``. If ``section`` @@ -104,4 +104,3 @@ namespace. .. _native namespace packages: https://packaging.python.org/guides/packaging-namespace-packages/#native-namespace-packages - diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index ec6e3254..eaabe0a5 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -34,7 +34,7 @@ def attach_metadata(self, location, plugin_type): ) if error_condition: # -no-cov- raise PluginRegistrationError( - "The plugin " + repr(self) + "has been previously " + f"The plugin {repr(self)} has been previously " "registered with different metadata." ) self.location = location @@ -85,7 +85,7 @@ def func(self): return self.command def __repr__(self): - return "OPSCommandPlugin(" + self.name + ")" + return f"OPSCommandPlugin({self.name})" class CLIPluginLoader(object): """Abstract object for CLI plugins From 28d37f7c4293da82dba8f3fb06783b3b16678273 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 27 Jul 2021 07:59:49 -0400 Subject: [PATCH 081/251] start using new parameters in volumes --- paths_cli/parsing/volumes.py | 49 ++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index c0c7cbb1..39013504 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -1,7 +1,7 @@ import operator import functools -from .core import Parser, InstanceBuilder, custom_eval +from .core import Parser, InstanceBuilder, custom_eval, Parameter from .cvs import cv_parser class CVVolumeInstanceBuilder(InstanceBuilder): @@ -23,28 +23,61 @@ def cv_volume_remapper(dct): build_cv_volume = CVVolumeInstanceBuilder( builder=None, - attribute_table={ - 'cv': cv_parser, - 'lambda_min': custom_eval, - 'lambda_max': custom_eval, - }, + # attribute_table={ + # 'cv': cv_parser, + # 'lambda_min': custom_eval, + # 'lambda_max': custom_eval, + # }, + attribute_table=None, remapper=cv_volume_remapper, + parameters=[ + Parameter('cv', cv_parser, + description="CV that defines this volume"), + Parameter('lambda_min', custom_eval, + description="Lower bound for this volume"), + Parameter('lambda_max', custom_eval, + description="Upper bound for this volume") + ], + name='cv-volume', + object_type='volume' ) def _use_parser(dct): # this is a hack to get around circular definitions return volume_parser(dct) +# jsonschema type for combination volumes +VOL_ARRAY_TYPE = { + 'type': 'array', + 'items': {"$ref": "#/definitions/volume_type"} +} + build_intersection_volume = InstanceBuilder( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), - attribute_table={'subvolumes': _use_parser}, + # attribute_table={'subvolumes': _use_parser}, + attribute_table=None, + parameters=[ + Parameter('subvolumes', _use_parser, + json_type=VOL_ARRAY_TYPE, + description="List of the volumes to intersect") + ], + name='intersection', + object_type='volume' ) build_union_volume = InstanceBuilder( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), - attribute_table={'subvolumes': _use_parser}, + # attribute_table={'subvolumes': _use_parser}, + attribute_table=None, + parameters=[ + Parameter('subvolumes', _use_parser, + json_type=VOL_ARRAY_TYPE, + description="List of the volumes to join into a union") + ], + name='union', + object_type='volume' ) TYPE_MAPPING = { From 76da8bc3d20caf00938f397eaff4ccb142e85691 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 27 Jul 2021 13:38:41 -0400 Subject: [PATCH 082/251] mock => unittest.mock --- paths_cli/tests/parsing/test_shooting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/parsing/test_shooting.py index 15994082..2e810877 100644 --- a/paths_cli/tests/parsing/test_shooting.py +++ b/paths_cli/tests/parsing/test_shooting.py @@ -3,7 +3,7 @@ from paths_cli.parsing.shooting import * import openpathsampling as paths -from mock import patch +from unittest.mock import patch from openpathsampling.tests.test_helpers import make_1d_traj def test_remapping_gaussian_stddev(cv_and_states): From 345b77c6b516009444d5d0ece1d14c2f35440a61 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 27 Jul 2021 18:15:47 -0400 Subject: [PATCH 083/251] Some more refactoring of sim-setup --- paths_cli/errors.py | 3 ++ paths_cli/parsing/core.py | 43 ++++++++++++++++++++--- paths_cli/parsing/cvs.py | 28 ++++++++++----- paths_cli/parsing/engines.py | 34 ++++++++++++++++--- paths_cli/parsing/networks.py | 64 ++++++++++++++++++++++++----------- paths_cli/parsing/tools.py | 4 +++ paths_cli/parsing/volumes.py | 21 ++++++++++-- paths_cli/utils.py | 10 ++++++ 8 files changed, 165 insertions(+), 42 deletions(-) create mode 100644 paths_cli/errors.py diff --git a/paths_cli/errors.py b/paths_cli/errors.py new file mode 100644 index 00000000..5135018f --- /dev/null +++ b/paths_cli/errors.py @@ -0,0 +1,3 @@ +class MissingIntegrationError(ImportError): + pass + diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 67296439..a0f836cd 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -9,6 +9,7 @@ from .errors import InputError from .tools import custom_eval +from paths_cli.utils import import_thing def listify(obj): listified = False @@ -23,10 +24,12 @@ def unlistify(obj, listified): obj = obj[0] return obj +REQUIRED_PARAMETER = object() + class Parameter: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" - def __init__(self, name, loader, required=True, json_type=None, - description=None): + def __init__(self, name, loader, *, json_type=None, description=None, + default=REQUIRED_PARAMETER): json_type = self._get_from_loader(loader, 'json_type', json_type) description = self._get_from_loader(loader, 'description', description) @@ -41,7 +44,11 @@ def __init__(self, name, loader, required=True, json_type=None, self.loader = loader self.json_type = json_type self.description = description - self.required = required + self.default = default + + @property + def required(self): + return self.default is REQUIRED_PARAMETER @staticmethod def _get_from_loader(loader, attr_name, attr): @@ -52,7 +59,6 @@ def _get_from_loader(loader, attr_name, attr): pass return attr - def __call__(self, *args, **kwargs): # check correct call signature here return self.loader(*args, **kwargs) @@ -65,6 +71,30 @@ def to_json_schema(self, schema_context=None): return self.name, dct +class Builder: + def __init__(self, builder, *, remapper=None, after_build=None): + if remapper is None: + remapper = lambda dct: dct + if after_build is None: + after_build = lambda obj, dct: obj + self.remapper = remapper + self.after_build = after_build + self.builder = builder + + def __call__(self, **dct): + # TODO: change this InstanceBuilder.build to make this better + if isinstance(self.builder, str): + module, _, func = self.builder.rpartition('.') + builder = import_thing(module, func) + else: + builder = self.builder + + ops_dct = self.remapper(dct.copy()) + obj = builder(**ops_dct) + after = self.after_build(obj, dct.copy()) + return after + + class InstanceBuilder: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" def __init__(self, builder, attribute_table, optional_attributes=None, @@ -77,6 +107,9 @@ def __init__(self, builder, attribute_table, optional_attributes=None, if optional_attributes is None and parameters is not None: optional_attributes = {p.name: p.loader for p in parameters if not p.required} + if defaults is None and parameters is not None: + defaults = {p.name: p.default for p in parameters + if not p.required} self.object_type = object_type self.name = name self.module = module @@ -158,7 +191,7 @@ def parse_attrs(self, dct): optionals = set(self.optional_attributes) & set(dct) for attr in optionals: - new_dct[attr] = self.attribute_table[attr](dct[attr]) + new_dct[attr] = self.optional_attributes[attr](dct[attr]) return new_dct diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 2dd77c8c..eca30d26 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -1,9 +1,10 @@ import os import importlib -from .core import Parser, InstanceBuilder, custom_eval, Parameter +from .core import Parser, InstanceBuilder, custom_eval, Parameter, Builder from .topology import build_topology from .errors import InputError +from paths_cli.utils import import_thing class AllowedPackageHandler: @@ -12,8 +13,7 @@ def __init__(self, package): def __call__(self, source): try: - pkg = importlib.import_module(self.package) - func = getattr(pkg, source) + func = import_thing(self.package, source) except AttributeError: raise InputError(f"No function called {source} in {self.package}") # on ImportError, we leave the error unchanged @@ -40,18 +40,28 @@ def cv_prepare_dict(dct): description="MDTraj function, e.g., ``compute_distances``"), Parameter('kwargs', lambda kwargs: {key: custom_eval(arg) for key, arg in kwargs.items()}, - json_type='object', - description="keyword arguments for ``func``", - required=True), # TODO: bug in current: shoudl be req=False + json_type='object', default=None, + description="keyword arguments for ``func``"), + Parameter('period_min', custom_eval, default=None, + description=("minimum value for a periodic function, None if " + "not periodic")), + Parameter('period_max', custom_eval, default=None, + description=("maximum value for a periodic function, None if " + "not periodic")), + ] +# TODO: actually, the InstanceBuilder should BE the plugin build_mdtraj_function_cv = InstanceBuilder( attribute_table=None, # temp - module='openpathsampling.experimental.storage.collective_variables', - builder='MDTrajFunctionCV', + # module='openpathsampling.experimental.storage.collective_variables', + # builder='MDTrajFunctionCV', # attribute_table=MDTRAJ_ATTRS, + builder=Builder('openpathsampling.experimental.storage.' + 'collective_variables.MDTrajFunctionCV', + remapper=cv_prepare_dict), parameters=MDTRAJ_PARAMETERS, - remapper=cv_prepare_dict, + # remapper=cv_prepare_dict, name="mdtraj", object_type="cv" ) diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index 10d195fa..f0a78fca 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -1,5 +1,8 @@ from .topology import build_topology -from .core import Parser, InstanceBuilder, custom_eval +from .core import Parser, InstanceBuilder, custom_eval, Builder +from .tools import custom_eval_int + +from paths_cli.errors import MissingIntegrationError try: from simtk import openmm as mm @@ -34,11 +37,32 @@ def openmm_options(dct): 'n_frames_max': int, } +from paths_cli.parsing.core import Parameter + +OPENMM_PARAMETERS = [ + Parameter('topology', build_topology, json_type='string', + description=("File describing the topoplogy of this system; " + "PDB recommended")), + Parameter('system', load_openmm_xml, json_type='string', + description="XML file with the OpenMM system"), + Parameter('integrator', load_openmm_xml, json_type='string', + description="XML file with the OpenMM integrator"), + Parameter('n_steps_per_frame', custom_eval_int, + description="number of MD steps per saved frame"), + Parameter("n_frames_max", custom_eval_int, + description=("maximum number of frames before aborting " + "trajectory")), +] + build_openmm_engine = InstanceBuilder( - module='openpathsampling.engines.openmm', - builder='Engine', - attribute_table=OPENMM_ATTRS, - remapper=openmm_options + # module='openpathsampling.engines.openmm', + # builder='Engine', + builder=Builder('openpathsampling.engines.openmm.Engine', + remapper=openmm_options), + attribute_table=None, + # attribute_table=OPENMM_ATTRS, + parameters=OPENMM_PARAMETERS, + # remapper=openmm_options ) TYPE_MAPPING = { diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index 7aaa3d95..e1080a20 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -1,16 +1,26 @@ -from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.core import ( + InstanceBuilder, Parser, Builder, Parameter +) from paths_cli.parsing.tools import custom_eval from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.cvs import cv_parser build_interface_set = InstanceBuilder( - module='openpathsampling', - builder='VolumeInterfaceSet', - attribute_table={ - 'cv': cv_parser, - 'minvals': custom_eval, - 'maxvals': custom_eval, - } + # module='openpathsampling', + # builder='VolumeInterfaceSet', + attribute_table=None, + builder=Builder('openpathsampling.VolumeInterfaceSet'), + parameters=[ + Parameter('cv', cv_parser, description="the collective variable " + "for this interface set"), + Parameter('minvals', custom_eval), # TODO fill in JSON types + Parameter('maxvals', custom_eval), # TODO fill in JSON types + ] + # attribute_table={ + # 'cv': cv_parser, + # 'minvals': custom_eval, + # 'maxvals': custom_eval, + # } ) def mistis_trans_info(dct): @@ -39,24 +49,38 @@ def tis_trans_info(dct): return mistis_trans_info(dct) build_tps_network = InstanceBuilder( - module='openpathsampling', - builder='TPSNetwork', - attribute_table={ - 'initial_states': volume_parser, - 'final_states': volume_parser, - } + # module='openpathsampling', + # builder='TPSNetwork', + builder=Builder('openpathsampling.TPSNetwork'), + attribute_table=None, + parameters=[ + Parameter('initial_states', volume_parser, + description="initial states for this transition"), + Parameter('final_states', volume_parser, + description="final states for this transition") + ] + # attribute_table={ + # 'initial_states': volume_parser, + # 'final_states': volume_parser, + # } ) build_mistis_network = InstanceBuilder( - module='openpathsampling', - builder='MISTISNetwork', - attribute_table={'trans_info': mistis_trans_info}, + # module='openpathsampling', + # builder='MISTISNetwork', + attribute_table=None, + parameters=[Parameter('trans_info', mistis_trans_info)], + builder=Builder('openpathsampling.MISTISNetwork'), + # attribute_table={'trans_info': mistis_trans_info}, ) build_tis_network = InstanceBuilder( - module='openpathsampling', - builder='MISTISNetwork', - attribute_table={'trans_info': tis_trans_info}, + # module='openpathsampling', + # builder='MISTISNetwork', + builder='openpathsampling.MISTISNetwork', + attribute_table=None, + parameters=[Parameter('trans_info', tis_trans_info)], + # attribute_table={'trans_info': tis_trans_info}, ) TYPE_MAPPING = { diff --git a/paths_cli/parsing/tools.py b/paths_cli/parsing/tools.py index 1663f6d4..55ec19bc 100644 --- a/paths_cli/parsing/tools.py +++ b/paths_cli/parsing/tools.py @@ -18,6 +18,10 @@ def custom_eval(obj, named_objs=None): } return eval(string, namespace) +def custom_eval_int(obj, named_objs=None): + val = custom_eval(obj, named_objs) + return int(val) + class UnknownAtomsError(RuntimeError): pass diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index 39013504..dcfe0a99 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -21,15 +21,30 @@ def cv_volume_remapper(dct): dct['collectivevariable'] = dct.pop('cv') return dct -build_cv_volume = CVVolumeInstanceBuilder( - builder=None, +# TODO: extra function for volumes should not be necessary as of OPS 2.0 +# TODO: things below should get rid of Builder and cv_volume_remapper +def cv_volume_build_func(**dct): + # TODO: this should take dict, not kwargs + import openpathsampling as paths + cv = dct['cv'] + builder = paths.CVDefinedVolume + if cv.period_min is not None or cv.period_max is not None: + builder = paths.PeriodicCVDefinedVolume + + dct['collectivevariable'] = dct.pop('cv') + # TODO: wrap this with some logging + return builder(**dct) + +build_cv_volume = InstanceBuilder( + # builder=None, # attribute_table={ # 'cv': cv_parser, # 'lambda_min': custom_eval, # 'lambda_max': custom_eval, # }, attribute_table=None, - remapper=cv_volume_remapper, + builder=cv_volume_build_func, + # remapper=cv_volume_remapper, parameters=[ Parameter('cv', cv_parser, description="CV that defines this volume"), diff --git a/paths_cli/utils.py b/paths_cli/utils.py index 5504e702..f2eb25a2 100644 --- a/paths_cli/utils.py +++ b/paths_cli/utils.py @@ -1,3 +1,6 @@ +import importlib + + def tag_final_result(result, storage, tag='final_conditions'): """Save results to a tag in storage. @@ -14,3 +17,10 @@ def tag_final_result(result, storage, tag='final_conditions'): print("Saving results to output file....") storage.save(result) storage.tags[tag] = result + + +def import_thing(module, obj=None): + result = importlib.import_module(module) + if obj is not None: + result = getattr(result, obj) + return result From f703605b9db80e401fcb1a75d8c3d84304f7b13e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 27 Jul 2021 19:20:43 -0400 Subject: [PATCH 084/251] switched everything over to parameter/builder This should be much easier to extend than the attribute tables --- paths_cli/parsing/core.py | 63 +++------------ paths_cli/parsing/cvs.py | 5 -- paths_cli/parsing/engines.py | 5 -- paths_cli/parsing/networks.py | 25 +----- paths_cli/parsing/root_parser.py | 4 +- paths_cli/parsing/schemes.py | 88 ++++++++++++++------- paths_cli/parsing/shooting.py | 22 +++--- paths_cli/parsing/strategies.py | 129 ++++++++++++++++--------------- paths_cli/parsing/volumes.py | 12 --- 9 files changed, 152 insertions(+), 201 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index a0f836cd..e9511908 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -97,36 +97,18 @@ def __call__(self, **dct): class InstanceBuilder: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" - def __init__(self, builder, attribute_table, optional_attributes=None, - defaults=None, module=None, remapper=None, parameters=None, - object_type=None, name=None): - # temporary apporach to override attribute_table - if attribute_table is None and parameters is not None: - attribute_table = {p.name: p.loader for p in parameters - if p.required} - if optional_attributes is None and parameters is not None: - optional_attributes = {p.name: p.loader for p in parameters - if not p.required} - if defaults is None and parameters is not None: - defaults = {p.name: p.default for p in parameters - if not p.required} + def __init__(self, builder, parameters, object_type=None, name=None): + self.attribute_table = {p.name: p.loader for p in parameters + if p.required} + self.optional_attributes = {p.name: p.loader for p in parameters + if not p.required} + self.defaults = {p.name: p.default for p in parameters + if not p.required} self.object_type = object_type self.name = name - self.module = module self.builder = builder self.builder_name = str(self.builder) - self.attribute_table = attribute_table self.parameters = parameters - if optional_attributes is None: - optional_attributes = {} - self.optional_attributes = optional_attributes - # TODO use none_to_default - if remapper is None: - remapper = lambda x: x - self.remapper = remapper - if defaults is None: - defaults = {} - self.defaults = defaults self.logger = logging.getLogger(f"parser.InstanceBuilder.{builder}") @property @@ -152,13 +134,6 @@ def to_json_schema(self, schema_context=None): } return self.schema_name, dct - def select_builder(self, dct): - if self.module is not None: - builder = getattr(importlib.import_module(self.module), self.builder) - else: - builder = self.builder - return builder - def parse_attrs(self, dct): """Parse the user input dictionary to mapping of name to object. @@ -195,32 +170,14 @@ def parse_attrs(self, dct): return new_dct - def build(self, new_dct): - """Build the object from a dictionary with objects as values. - - Parameters - ---------- - new_dct : Dict - The output of :method:`.parse_attrs`. This is a mapping of the - relevant keys to instantiated objects. - - Returns - ------- - Any : - The instance for this dictionary. - """ - builder = self.select_builder(new_dct) - ops_dct = self.remapper(new_dct) + def __call__(self, dct): + ops_dct = self.parse_attrs(dct) self.logger.debug("Building...") self.logger.debug(ops_dct) - obj = builder(**ops_dct) + obj = self.builder(**ops_dct) self.logger.debug(obj) return obj - def __call__(self, dct): - new_dct = self.parse_attrs(dct) - return self.build(new_dct) - class Parser: """Generic parse class; instances for each category""" diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index eca30d26..ceb13491 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -53,15 +53,10 @@ def cv_prepare_dict(dct): # TODO: actually, the InstanceBuilder should BE the plugin build_mdtraj_function_cv = InstanceBuilder( - attribute_table=None, # temp - # module='openpathsampling.experimental.storage.collective_variables', - # builder='MDTrajFunctionCV', - # attribute_table=MDTRAJ_ATTRS, builder=Builder('openpathsampling.experimental.storage.' 'collective_variables.MDTrajFunctionCV', remapper=cv_prepare_dict), parameters=MDTRAJ_PARAMETERS, - # remapper=cv_prepare_dict, name="mdtraj", object_type="cv" ) diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index f0a78fca..bc01b20a 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -55,14 +55,9 @@ def openmm_options(dct): ] build_openmm_engine = InstanceBuilder( - # module='openpathsampling.engines.openmm', - # builder='Engine', builder=Builder('openpathsampling.engines.openmm.Engine', remapper=openmm_options), - attribute_table=None, - # attribute_table=OPENMM_ATTRS, parameters=OPENMM_PARAMETERS, - # remapper=openmm_options ) TYPE_MAPPING = { diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index e1080a20..c03d05e6 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -6,9 +6,6 @@ from paths_cli.parsing.cvs import cv_parser build_interface_set = InstanceBuilder( - # module='openpathsampling', - # builder='VolumeInterfaceSet', - attribute_table=None, builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ Parameter('cv', cv_parser, description="the collective variable " @@ -16,11 +13,6 @@ Parameter('minvals', custom_eval), # TODO fill in JSON types Parameter('maxvals', custom_eval), # TODO fill in JSON types ] - # attribute_table={ - # 'cv': cv_parser, - # 'minvals': custom_eval, - # 'maxvals': custom_eval, - # } ) def mistis_trans_info(dct): @@ -49,38 +41,23 @@ def tis_trans_info(dct): return mistis_trans_info(dct) build_tps_network = InstanceBuilder( - # module='openpathsampling', - # builder='TPSNetwork', builder=Builder('openpathsampling.TPSNetwork'), - attribute_table=None, parameters=[ Parameter('initial_states', volume_parser, description="initial states for this transition"), Parameter('final_states', volume_parser, description="final states for this transition") ] - # attribute_table={ - # 'initial_states': volume_parser, - # 'final_states': volume_parser, - # } ) build_mistis_network = InstanceBuilder( - # module='openpathsampling', - # builder='MISTISNetwork', - attribute_table=None, parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), - # attribute_table={'trans_info': mistis_trans_info}, ) build_tis_network = InstanceBuilder( - # module='openpathsampling', - # builder='MISTISNetwork', - builder='openpathsampling.MISTISNetwork', - attribute_table=None, + builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], - # attribute_table={'trans_info': tis_trans_info}, ) TYPE_MAPPING = { diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 4d84e1bb..e5177a25 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -3,7 +3,7 @@ from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.networks import network_parser -from paths_cli.parsing.schemes import scheme_parser +# from paths_cli.parsing.schemes import scheme_parser TYPE_MAPPING = { @@ -12,7 +12,7 @@ 'volumes': volume_parser, 'states': volume_parser, 'networks': network_parser, - 'moveschemes': scheme_parser, + # 'moveschemes': scheme_parser, } def register_builder(parser_name, name, builder): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index 012e534e..a66d61e7 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -1,19 +1,30 @@ -from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.core import ( + InstanceBuilder, Parser, Builder, Parameter +) from paths_cli.parsing.tools import custom_eval from paths_cli.parsing.shooting import shooting_selector_parser from paths_cli.parsing.engines import engine_parser from paths_cli.parsing.networks import network_parser -from paths_cli.parsing.strategies import strategy_parser +from paths_cli.parsing.strategies import ( + strategy_parser, SP_SELECTOR_PARAMETER +) + + +NETWORK_PARAMETER = Parameter('network', network_parser) + +ENGINE_PARAMETER = Parameter('engine', engine_parser) # reuse elsewhere? + +STRATEGIES_PARAMETER = Parameter('strategies', strategy_parser, default=None) + build_spring_shooting_scheme = InstanceBuilder( - module='openpathsampling', - builder='SpringShootingMoveScheme', - attribute_table={ - 'network': network_parser, - 'k_spring': custom_eval, - 'delta_max': custom_eval, - 'engine': engine_parser, - } + builder=Builder('openpathsampling.SpringShootingMoveScheme'), + parameters=[ + NETWORK_PARAMETER, + Parameter('k_spring', custom_eval), + Parameter('delta_max', custom_eval), + ENGINE_PARAMETER + ] ) class StrategySchemeInstanceBuilder(InstanceBuilder): @@ -38,6 +49,8 @@ def __init__(self, builder, attribute_table, optional_attributes=None, def __call__(self, dct): new_dct = self.parse_attrs(dct) strategies = new_dct.pop('strategies', []) + if strategies is None: + strategies = [] scheme = self.build(new_dct) for strat in strategies + self.default_global: scheme.append(strat) @@ -45,28 +58,45 @@ def __call__(self, dct): self.logger.debug(f"strategies: {scheme.strategies}") return scheme +class BuildSchemeStrategy: + def __init__(self, scheme_class, default_global_strategy): + self.scheme_class = scheme_class + self.default_global_strategy = default_global_strategy -build_one_way_shooting_scheme = StrategySchemeInstanceBuilder( - module='openpathsampling', - builder='OneWayShootingMoveScheme', - attribute_table={ - 'network': network_parser, - 'selector': shooting_selector_parser, - 'engine': engine_parser, - }, - optional_attributes={ - 'strategies': strategy_parser, - }, + def __call__(self, dct): + from openpathsampling import strategies + if self.default_global_strategy: + global_strategy = [strategies.OrganizeByMoveGroupStrategy()] + else: + global_strategy = [] + + builder = Builder(self.scheme_class) + strategies = dct.pop('strategies', []) + global_strategy + scheme = builder(dct) + for strat in strategies: + scheme.append(strat) + # self.logger.debug(f"strategies: {scheme.strategies}") + return scheme + + +build_one_way_shooting_scheme = InstanceBuilder( + builder=BuildSchemeStrategy('openpathsampling.OneWayShootingMoveScheme', + default_global_strategy=False), + parameters=[ + NETWORK_PARAMETER, + SP_SELECTOR_PARAMETER, + ENGINE_PARAMETER, + STRATEGIES_PARAMETER, + ] ) -build_scheme = StrategySchemeInstanceBuilder( - module='openpathsampling', - builder='MoveScheme', - attribute_table={ - 'network': network_parser, - 'strategies': strategy_parser, - }, - default_global_strategy=True, +build_scheme = InstanceBuilder( + builder=BuildSchemeStrategy('openpathsampling.MoveScheme', + default_global_strategy=True), + parameters=[ + NETWORK_PARAMETER, + STRATEGIES_PARAMETER, + ] ) scheme_parser = Parser( diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py index 03096085..250ea0ba 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/parsing/shooting.py @@ -1,12 +1,13 @@ -from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.core import ( + InstanceBuilder, Parser, Builder, Parameter +) from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.tools import custom_eval import numpy as np build_uniform_selector = InstanceBuilder( - module='openpathsampling', - builder='UniformSelector', - attribute_table={} + builder=Builder('openpathsampling.UniformSelector'), + parameters=[] ) def remapping_gaussian_stddev(dct): @@ -16,12 +17,13 @@ def remapping_gaussian_stddev(dct): return dct build_gaussian_selector = InstanceBuilder( - module='openpathsampling', - builder='GaussianBiasSelector', - attribute_table={'cv': cv_parser, - 'mean': custom_eval, - 'stddev': custom_eval}, - remapper=remapping_gaussian_stddev + builder=Builder('openpathsampling.GaussianBiasSelector', + remapper=remapping_gaussian_stddev), + parameters=[ + Parameter('cv', cv_parser), + Parameter('mean', custom_eval), + Parameter('stddev', custom_eval), + ] ) shooting_selector_parser = Parser( diff --git a/paths_cli/parsing/strategies.py b/paths_cli/parsing/strategies.py index 7846df93..90e291ec 100644 --- a/paths_cli/parsing/strategies.py +++ b/paths_cli/parsing/strategies.py @@ -1,86 +1,93 @@ -from paths_cli.parsing.core import InstanceBuilder, Parser +from paths_cli.parsing.core import ( + InstanceBuilder, Parser, Builder, Parameter +) from paths_cli.parsing.shooting import shooting_selector_parser from paths_cli.parsing.engines import engine_parser +def _strategy_name(class_name): + return f"openpathsampling.strategies.{class_name}" + +def _group_parameter(group_name): + return Parameter('group', str, default=group_name, + description="the group name for these movers") + +# TODO: maybe this moves into shooting once we have the metadata? +SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_parser) + +ENGINE_PARAMETER = Parameter('engine', engine_parser, + description="the engine for moves of this " + "type") + +SHOOTING_GROUP_PARAMETER = _group_parameter('shooting') +REPEX_GROUP_PARAMETER = _group_parameter('repex') +MINUS_GROUP_PARAMETER = _group_parameter('minus') + +REPLACE_TRUE_PARAMETER = Parameter('replace', bool, default=True) +REPLACE_FALSE_PARAMETER = Parameter('replace', bool, default=False) + + + build_one_way_shooting_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='OneWayShootingStrategy', - attribute_table={ - 'selector': shooting_selector_parser, - 'engine': engine_parser, - }, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("OneWayShootingStrategy")), + parameters=[ + SP_SELECTOR_PARAMETER, + ENGINE_PARAMETER, + SHOOTING_GROUP_PARAMETER, + Parameter('replace', bool, default=True) + ], ) build_two_way_shooting_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='TwoWayShootingStrategy', - attribute_table={ - 'modifier': ..., - 'selector': shooting_selector_parser, - 'engine': engine_parser, - }, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("TwoWayShootingStrategy")), + parameters = [ + Parameter('modifier', ...), + SP_SELECTOR_PARAMETER, + ENGINE_PARAMETER, + SHOOTING_GROUP_PARAMETER, + REPLACE_TRUE_PARAMETER, + ], ) build_nearest_neighbor_repex_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='NearestNeighborRepExStrategy', - attribute_table={}, - optional_attributes={ - 'group': str, - 'replace': bool, - }, + builder=Builder(_strategy_name("NearestNeighborRepExStrategy")), + parameters=[ + REPEX_GROUP_PARAMETER, + REPLACE_TRUE_PARAMETER + ], ) build_all_set_repex_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='AllSetRepExStrategy', - attribute_table={}, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("AllSetRepExStrategy")), + parameters=[ + REPEX_GROUP_PARAMETER, + REPLACE_TRUE_PARAMETER + ], ) build_path_reversal_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='PathReversalStrategy', - attribute_table={}, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("PathReversalStrategy")), + parameters=[ + _group_parameter('pathreversal'), + REPLACE_TRUE_PARAMETER, + ] ) build_minus_move_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='MinusMoveStrategy', - attribute_table={ - 'engine': engine_parser, - }, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("MinusMoveStrategy")), + parameters=[ + ENGINE_PARAMETER, + MINUS_GROUP_PARAMETER, + REPLACE_TRUE_PARAMETER, + ], ) build_single_replica_minus_move_strategy = InstanceBuilder( - module='openpathsampling.strategies', - builder='SingleReplicaMinusMoveStrategy', - attribute_table={ - 'engine': engine_parser, - }, - optional_attributes={ - 'group': str, - 'replace': bool, - } + builder=Builder(_strategy_name("SingleReplicaMinusMoveStrategy")), + parameters=[ + ENGINE_PARAMETER, + MINUS_GROUP_PARAMETER, + REPLACE_TRUE_PARAMETER, + ], ) strategy_parser = Parser( diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index dcfe0a99..1fea5355 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -36,15 +36,7 @@ def cv_volume_build_func(**dct): return builder(**dct) build_cv_volume = InstanceBuilder( - # builder=None, - # attribute_table={ - # 'cv': cv_parser, - # 'lambda_min': custom_eval, - # 'lambda_max': custom_eval, - # }, - attribute_table=None, builder=cv_volume_build_func, - # remapper=cv_volume_remapper, parameters=[ Parameter('cv', cv_parser, description="CV that defines this volume"), @@ -70,8 +62,6 @@ def _use_parser(dct): build_intersection_volume = InstanceBuilder( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), - # attribute_table={'subvolumes': _use_parser}, - attribute_table=None, parameters=[ Parameter('subvolumes', _use_parser, json_type=VOL_ARRAY_TYPE, @@ -84,8 +74,6 @@ def _use_parser(dct): build_union_volume = InstanceBuilder( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), - # attribute_table={'subvolumes': _use_parser}, - attribute_table=None, parameters=[ Parameter('subvolumes', _use_parser, json_type=VOL_ARRAY_TYPE, From 548b389fd4772b80b711c4a8fc89f747662fc9f0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 27 Jul 2021 19:31:01 -0400 Subject: [PATCH 085/251] remove now-unused classes --- paths_cli/parsing/root_parser.py | 4 ++-- paths_cli/parsing/schemes.py | 31 ------------------------------- paths_cli/parsing/volumes.py | 18 ------------------ 3 files changed, 2 insertions(+), 51 deletions(-) diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index e5177a25..4d84e1bb 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -3,7 +3,7 @@ from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.networks import network_parser -# from paths_cli.parsing.schemes import scheme_parser +from paths_cli.parsing.schemes import scheme_parser TYPE_MAPPING = { @@ -12,7 +12,7 @@ 'volumes': volume_parser, 'states': volume_parser, 'networks': network_parser, - # 'moveschemes': scheme_parser, + 'moveschemes': scheme_parser, } def register_builder(parser_name, name, builder): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index a66d61e7..88549fd7 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -27,37 +27,6 @@ ] ) -class StrategySchemeInstanceBuilder(InstanceBuilder): - """ - Variant of the InstanceBuilder that appends strategies to a MoveScheme - """ - def __init__(self, builder, attribute_table, optional_attributes=None, - defaults=None, module=None, remapper=None, - default_global_strategy=False): - from openpathsampling import strategies - super().__init__(builder, attribute_table, - optional_attributes=optional_attributes, - defaults=defaults, module=module, - remapper=remapper) - if default_global_strategy is True: - self.default_global = [strategies.OrganizeByMoveGroupStrategy()] - elif default_global_strategy is False: - self.default_global = [] - else: - self.default_global= [default_global_strategy] - - def __call__(self, dct): - new_dct = self.parse_attrs(dct) - strategies = new_dct.pop('strategies', []) - if strategies is None: - strategies = [] - scheme = self.build(new_dct) - for strat in strategies + self.default_global: - scheme.append(strat) - - self.logger.debug(f"strategies: {scheme.strategies}") - return scheme - class BuildSchemeStrategy: def __init__(self, scheme_class, default_global_strategy): self.scheme_class = scheme_class diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index 1fea5355..89378650 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -4,25 +4,7 @@ from .core import Parser, InstanceBuilder, custom_eval, Parameter from .cvs import cv_parser -class CVVolumeInstanceBuilder(InstanceBuilder): - # subclass to handle periodic cv volumes - # TODO: this will be removed after OPS 2.0 is released - def select_builder(self, dct): - import openpathsampling as paths - cv = dct['cv'] - builder = paths.CVDefinedVolume - if cv.period_min is not None: - builder = paths.PeriodicCVDefinedVolume - if cv.period_max is not None: - builder = paths.PeriodicCVDefinedVolume - return builder - -def cv_volume_remapper(dct): - dct['collectivevariable'] = dct.pop('cv') - return dct - # TODO: extra function for volumes should not be necessary as of OPS 2.0 -# TODO: things below should get rid of Builder and cv_volume_remapper def cv_volume_build_func(**dct): # TODO: this should take dict, not kwargs import openpathsampling as paths From 8f9fe2e25b99735deace75bda658439858e9a16b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 28 Jul 2021 22:20:08 -0400 Subject: [PATCH 086/251] Start switch to plugins --- paths_cli/parsing/core.py | 14 ++++-- paths_cli/parsing/cvs.py | 67 +++++++++---------------- paths_cli/parsing/engines.py | 17 ++----- paths_cli/parsing/volumes.py | 10 ++-- paths_cli/tests/parsing/test_cvs.py | 4 +- paths_cli/tests/parsing/test_engines.py | 2 +- 6 files changed, 45 insertions(+), 69 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index e9511908..655151ec 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -10,6 +10,7 @@ from .errors import InputError from .tools import custom_eval from paths_cli.utils import import_thing +from paths_cli.plugin_management import OPSPlugin def listify(obj): listified = False @@ -29,7 +30,7 @@ def unlistify(obj, listified): class Parameter: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" def __init__(self, name, loader, *, json_type=None, description=None, - default=REQUIRED_PARAMETER): + default=REQUIRED_PARAMETER, aliases=None): json_type = self._get_from_loader(loader, 'json_type', json_type) description = self._get_from_loader(loader, 'description', description) @@ -95,16 +96,19 @@ def __call__(self, **dct): return after -class InstanceBuilder: +class InstanceBuilder(OPSPlugin): SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" - def __init__(self, builder, parameters, object_type=None, name=None): + parser_name = None + def __init__(self, builder, parameters, name=None, aliases=None, + requires_ops=(1, 0), requires_cli=(0, 3)): + super().__init__(requires_ops, requires_cli) + self.aliases = aliases self.attribute_table = {p.name: p.loader for p in parameters if p.required} self.optional_attributes = {p.name: p.loader for p in parameters if not p.required} self.defaults = {p.name: p.default for p in parameters if not p.required} - self.object_type = object_type self.name = name self.builder = builder self.builder_name = str(self.builder) @@ -113,7 +117,7 @@ def __init__(self, builder, parameters, object_type=None, name=None): @property def schema_name(self): - if not self.name.endswith(self.object_type): + if not self.name.endswith(self.parser_name): schema_name = f"{self.name}-{self.object_type}" else: schema_name = name diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index ceb13491..8866f3fb 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -5,6 +5,7 @@ from .topology import build_topology from .errors import InputError from paths_cli.utils import import_thing +from paths_cli.parsing.plugins import CVParserPlugin class AllowedPackageHandler: @@ -19,61 +20,41 @@ def __call__(self, source): # on ImportError, we leave the error unchanged return func -def cv_prepare_dict(dct): +def cv_kwargs_remapper(dct): kwargs = dct.pop('kwargs', {}) dct.update(kwargs) return dct -# MDTraj-specific - -mdtraj_source = AllowedPackageHandler("mdtraj") -# MDTRAJ_ATTRS = { - # 'topology': build_topology, - # 'func': mdtraj_source, - # 'kwargs': lambda kwargs: {key: custom_eval(arg) - # for key, arg in kwargs.items()}, -# } - -MDTRAJ_PARAMETERS = [ - Parameter('topology', build_topology), - Parameter('func', mdtraj_source, json_type='string', - description="MDTraj function, e.g., ``compute_distances``"), - Parameter('kwargs', lambda kwargs: {key: custom_eval(arg) - for key, arg in kwargs.items()}, - json_type='object', default=None, - description="keyword arguments for ``func``"), - Parameter('period_min', custom_eval, default=None, - description=("minimum value for a periodic function, None if " - "not periodic")), - Parameter('period_max', custom_eval, default=None, - description=("maximum value for a periodic function, None if " - "not periodic")), - -] -# TODO: actually, the InstanceBuilder should BE the plugin -build_mdtraj_function_cv = InstanceBuilder( +# MDTraj-specific +MDTRAJ_CV_PLUGIN = CVParserPlugin( builder=Builder('openpathsampling.experimental.storage.' 'collective_variables.MDTrajFunctionCV', - remapper=cv_prepare_dict), - parameters=MDTRAJ_PARAMETERS, - name="mdtraj", - object_type="cv" + remapper=cv_kwargs_remapper), + parameters=[ + Parameter('topology', build_topology), + Parameter('func', AllowedPackageHandler('mdtraj'), + json_type='string', description="MDTraj function, e.g., " + "``compute_distances``"), + Parameter('kwargs', lambda kwargs: {key: custom_eval(arg) + for key, arg in kwargs.items()}, + json_type='object', default=None, + description="keyword arguments for ``func``"), + Parameter('period_min', custom_eval, default=None, + description=("minimum value for a periodic function, " + "None if not periodic")), + Parameter('period_max', custom_eval, default=None, + description=("maximum value for a periodic function, " + "None if not periodic")), + + ], + name="mdtraj" ) -# TODO: this should replace TYPE_MAPPING and cv_parser -# MDTRAJ_PLUGIN = CVParserPlugin( - # type_name='mdtraj', - # instance_builder=build_mdtraj_function_cv, - # requires_ops=(1, 0), - # requires_cli=(0, 4), -# ) - - # Main CV parser TYPE_MAPPING = { - 'mdtraj': build_mdtraj_function_cv, + 'mdtraj': MDTRAJ_CV_PLUGIN, } cv_parser = Parser(TYPE_MAPPING, label="CVs") diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index bc01b20a..a8959f1f 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -1,6 +1,8 @@ from .topology import build_topology from .core import Parser, InstanceBuilder, custom_eval, Builder +from paths_cli.parsing.core import Parameter from .tools import custom_eval_int +from paths_cli.parsing.plugins import EngineParserPlugin from paths_cli.errors import MissingIntegrationError @@ -29,16 +31,6 @@ def openmm_options(dct): return dct -OPENMM_ATTRS = { - 'topology': build_topology, - 'system': load_openmm_xml, - 'integrator': load_openmm_xml, - 'n_steps_per_frame': int, - 'n_frames_max': int, -} - -from paths_cli.parsing.core import Parameter - OPENMM_PARAMETERS = [ Parameter('topology', build_topology, json_type='string', description=("File describing the topoplogy of this system; " @@ -54,14 +46,15 @@ def openmm_options(dct): "trajectory")), ] -build_openmm_engine = InstanceBuilder( +OPENMM_PLUGIN = EngineParserPlugin( builder=Builder('openpathsampling.engines.openmm.Engine', remapper=openmm_options), parameters=OPENMM_PARAMETERS, + name='openmm', ) TYPE_MAPPING = { - 'openmm': build_openmm_engine, + 'openmm': OPENMM_PLUGIN, } engine_parser = Parser(TYPE_MAPPING, label="engines") diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index 89378650..5f92af0f 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -3,6 +3,7 @@ from .core import Parser, InstanceBuilder, custom_eval, Parameter from .cvs import cv_parser +from paths_cli.parsing.plugins import VolumeParserPlugin # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): @@ -17,7 +18,7 @@ def cv_volume_build_func(**dct): # TODO: wrap this with some logging return builder(**dct) -build_cv_volume = InstanceBuilder( +build_cv_volume = VolumeParserPlugin( builder=cv_volume_build_func, parameters=[ Parameter('cv', cv_parser, @@ -28,7 +29,6 @@ def cv_volume_build_func(**dct): description="Upper bound for this volume") ], name='cv-volume', - object_type='volume' ) def _use_parser(dct): @@ -41,7 +41,7 @@ def _use_parser(dct): 'items': {"$ref": "#/definitions/volume_type"} } -build_intersection_volume = InstanceBuilder( +build_intersection_volume = VolumeParserPlugin( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), parameters=[ @@ -50,10 +50,9 @@ def _use_parser(dct): description="List of the volumes to intersect") ], name='intersection', - object_type='volume' ) -build_union_volume = InstanceBuilder( +build_union_volume = VolumeParserPlugin( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), parameters=[ @@ -62,7 +61,6 @@ def _use_parser(dct): description="List of the volumes to join into a union") ], name='union', - object_type='volume' ) TYPE_MAPPING = { diff --git a/paths_cli/tests/parsing/test_cvs.py b/paths_cli/tests/parsing/test_cvs.py index 00c4517a..d0f02f32 100644 --- a/paths_cli/tests/parsing/test_cvs.py +++ b/paths_cli/tests/parsing/test_cvs.py @@ -27,7 +27,7 @@ def test_build_mdtraj_function_cv(self): _ = pytest.importorskip('simtk.unit') yml = self.yml.format(kwargs=self.kwargs, func="compute_dihedrals") dct = yaml.load(yml, Loader=yaml.FullLoader) - cv = build_mdtraj_function_cv(dct) + cv = MDTRAJ_CV_PLUGIN(dct) assert isinstance(cv, MDTrajFunctionCV) assert cv.func == md.compute_dihedrals md_trj = md.load(self.ad_pdb) @@ -40,4 +40,4 @@ def test_bad_mdtraj_function_name(self): yml = self.yml.format(kwargs=self.kwargs, func="foo") dct = yaml.load(yml, Loader=yaml.FullLoader) with pytest.raises(InputError): - cv = build_mdtraj_function_cv(dct) + cv = MDTRAJ_CV_PLUGIN(dct) diff --git a/paths_cli/tests/parsing/test_engines.py b/paths_cli/tests/parsing/test_engines.py index 109b1542..38a21430 100644 --- a/paths_cli/tests/parsing/test_engines.py +++ b/paths_cli/tests/parsing/test_engines.py @@ -66,7 +66,7 @@ def test_build_openmm_engine(self, tmpdir): self._create_files(tmpdir) os.chdir(tmpdir) dct = yaml.load(self.yml, yaml.FullLoader) - engine = build_openmm_engine(dct) + engine = OPENMM_PLUGIN(dct) assert isinstance(engine, ops_openmm.Engine) snap = ops_openmm.tools.ops_load_trajectory('ad.pdb')[0] engine.current_snapshot = snap From 379e5f12f1a322d994d3101b085c52613e965ef4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 28 Jul 2021 22:26:23 -0400 Subject: [PATCH 087/251] add plugins files --- paths_cli/parsing/plugins.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 paths_cli/parsing/plugins.py diff --git a/paths_cli/parsing/plugins.py b/paths_cli/parsing/plugins.py new file mode 100644 index 00000000..2dab2ffa --- /dev/null +++ b/paths_cli/parsing/plugins.py @@ -0,0 +1,20 @@ +from paths_cli.parsing.core import InstanceBuilder + + +class EngineParserPlugin(InstanceBuilder): + parser_name = 'engine' + +class CVParserPlugin(InstanceBuilder): + parser_name = 'cv' + +class VolumeParserPlugin(InstanceBuilder): + parser_name = 'volume' + +class NetworkParserPlugin(InstanceBuilder): + parser_name = 'network' + +class SchemeParserPlugin(InstanceBuilder): + parser_name = 'scheme' + +class StrategyParserPlugin(InstanceBuilder): + parser_name = 'strategy' From e5136807452a6a589ff22f4ade85e0ac188cfad3 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 10 Aug 2021 15:37:38 -0400 Subject: [PATCH 088/251] Start toward registering parsing via plugins --- paths_cli/parsing/core.py | 42 +++++++++++++-- paths_cli/parsing/engines.py | 3 +- paths_cli/parsing/plugins.py | 18 +++++++ paths_cli/parsing/root_parser.py | 90 ++++++++++++++++++++++++++++++++ paths_cli/plugin_management.py | 9 +++- paths_cli/tests/utils.py | 1 + 6 files changed, 156 insertions(+), 7 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 655151ec..2ca2269f 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -31,9 +31,6 @@ class Parameter: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" def __init__(self, name, loader, *, json_type=None, description=None, default=REQUIRED_PARAMETER, aliases=None): - json_type = self._get_from_loader(loader, 'json_type', json_type) - description = self._get_from_loader(loader, 'description', - description) if isinstance(json_type, str): try: json_type = json.loads(json_type) @@ -43,14 +40,24 @@ def __init__(self, name, loader, *, json_type=None, description=None, self.name = name self.loader = loader - self.json_type = json_type - self.description = description + self._json_type = json_type + self._description = description self.default = default @property def required(self): return self.default is REQUIRED_PARAMETER + @property + def json_type(self): + return self._get_from_loader(self.loader, 'json_type', + self._json_type) + + @property + def description(self): + return self._get_from_loader(self.loader, 'description', + self._description) + @staticmethod def _get_from_loader(loader, attr_name, attr): if attr is None: @@ -73,6 +80,30 @@ def to_json_schema(self, schema_context=None): class Builder: + """Builder is a wrapper class to simplify writing builder functions. + + When the parsed parameters dictionary matches the kwargs for your class, + you can create a valid delayed builder function with + + .. code: + + builder = Builder('import_path.for_the.ClassToBuild') + + Additionally, this class provides hooks for functions that run before or + after the main builder function. This allows many objects to be built by + implementing simple functions and hooking themn together with Builder. + + Parameters + ---------- + builder : Union[Callable, str] + primary callable to build an object, or string representing the + fully-qualified path to a callable + remapper : Callable[[Dict], Dict], optional + callable to remap the the mapping of ??? + after_build : Callable[[Any, Dict], Any], optional + ccallable to update the created object with any additional + information from the original dictionary. + """ def __init__(self, builder, *, remapper=None, after_build=None): if remapper is None: remapper = lambda dct: dct @@ -99,6 +130,7 @@ def __call__(self, **dct): class InstanceBuilder(OPSPlugin): SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" parser_name = None + error_on_duplicate = False # TODO: temporary def __init__(self, builder, parameters, name=None, aliases=None, requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index a8959f1f..005174dc 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -2,7 +2,7 @@ from .core import Parser, InstanceBuilder, custom_eval, Builder from paths_cli.parsing.core import Parameter from .tools import custom_eval_int -from paths_cli.parsing.plugins import EngineParserPlugin +from paths_cli.parsing.plugins import EngineParserPlugin, ParserPlugin from paths_cli.errors import MissingIntegrationError @@ -57,4 +57,5 @@ def openmm_options(dct): 'openmm': OPENMM_PLUGIN, } +ENGINE_PARSER = ParserPlugin(EngineParserPlugin, aliases=['engines']) engine_parser = Parser(TYPE_MAPPING, label="engines") diff --git a/paths_cli/parsing/plugins.py b/paths_cli/parsing/plugins.py index 2dab2ffa..cd904046 100644 --- a/paths_cli/parsing/plugins.py +++ b/paths_cli/parsing/plugins.py @@ -1,4 +1,22 @@ from paths_cli.parsing.core import InstanceBuilder +from paths_cli.plugin_management import OPSPlugin + +class ParserPlugin(OPSPlugin): + """ + Parser plugins only need to be made for top-level + """ + error_on_duplicate = False # TODO: temporary + def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), + requires_cli=(0,4)): + super().__init__(requires_ops, requires_cli) + self.plugin_class = plugin_class + if aliases is None: + aliases = [] + self.aliases = aliases + + @property + def name(self): + return self.plugin_class.parser_name class EngineParserPlugin(InstanceBuilder): diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 4d84e1bb..6994643a 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -15,6 +15,96 @@ 'moveschemes': scheme_parser, } +_DEFAULT_PARSE_ORDER = [ + 'engine', + 'cv', + 'volume', + 'state', + 'network', + 'movescheme', +] + +PARSE_ORDER = _DEFAULT_PARSE_ORDER.copy() + +PARSERS = {} +ALIASES = {} + +def parser_for(parser_name): + """Delayed parser calling. + + Use this when you need to use a parser as the loader for a parameter. + + Parameters + ---------- + parser_name : str + the name of the parser to use + """ + def load_and_call_parser(*args, **kwargs): + return PARSER[parser_name](*args, **kwargs) + return load_and_call_parser + +def _get_parser(parser_name): + """ + _get_parser must only be used after the parsers have been registered + """ + parser = PARSERS.get(parser_name, None) + if parser is None: + parser = Parser(None, parse_name) + PARSERS[parser_name] = parser + return parser + +def _get_registration_names(plugin): + """This is a trick to ensure that the names appear in the desired order. + + We always want the plugin name first, followed by aliases in order + listed by the plugin creator. However, we only want each name to appear + once. + """ + ordered_names = [] + found_names = set([]) + for name in [plugin.name] + plugin.aliases: + if name not in found_names: + ordered_names.append(name) + found_names.add(name) + return ordered_names + +def register_plugins(plugins): + builders = [] + parsers = [] + for plugin in plugins: + if isinstance(plugin, InstanceBuilder): + builders.append(plugin) + elif isinstance(plugin, ParserPlugin): + parsers.append(plugin) + + for plugin in parsers: + _register_parser_plugin(plugin) + + for plugin in builders: + _register_builder_plugin(plugin) + +def _register_builder_plugin(plugin): + parser = _get_parser(plugin.parser_name) + for name in _get_registration_names(plugin): + parser.register_builder(plugin, name) + +def _register_parser_plugin(plugin): + DUPLICATE_ERROR = RuntimeError(f"The name {plugin.name} has been " + "reserved by another parser") + if plugin.name in PARSERS: + raise DUPLICATE_ERROR + + parser = _get_parser(plugin.name) + + # register aliases + new_aliases = set(plugin.aliases) - set([plugin.name]) + for alias in new_aliases: + if alias in PARSERS or alias in ALIASES: + raise DUPLICATE_ERROR + ALIASES[alias] = plugin.name + +### old versions + def register_builder(parser_name, name, builder): parser = TYPE_MAPPING[parser_name] parser.register_builder(builder, name) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index eaabe0a5..e8f7e152 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -1,6 +1,7 @@ import collections import pkgutil import importlib +import warnings import os class PluginRegistrationError(RuntimeError): @@ -19,6 +20,7 @@ class Plugin(object): tuple representing hte minimum allowed version of the command line interface application """ + error_on_duplicate = True def __init__(self, requires_lib, requires_cli): self.requires_lib = requires_lib self.requires_cli = requires_cli @@ -33,10 +35,15 @@ def attach_metadata(self, location, plugin_type): or self.plugin_type != plugin_type) ) if error_condition: # -no-cov- - raise PluginRegistrationError( + msg = ( f"The plugin {repr(self)} has been previously " "registered with different metadata." ) + if self.error_on_duplicate: + raise PluginRegistrationError(msg) + else: + warnings.warn(msg) + self.location = location self.plugin_type = plugin_type diff --git a/paths_cli/tests/utils.py b/paths_cli/tests/utils.py index 7b1471aa..014cdd62 100644 --- a/paths_cli/tests/utils.py +++ b/paths_cli/tests/utils.py @@ -1,4 +1,5 @@ import urllib.request +import pytest try: urllib.request.urlopen('https://www.google.com') From bdb1ab94400f5f287faf2c313a3743991770c996 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 10 Aug 2021 16:05:06 -0400 Subject: [PATCH 089/251] proof of principle for plugin registration --- paths_cli/parsing/core.py | 12 ++++++++++-- paths_cli/parsing/networks.py | 8 ++++++-- paths_cli/parsing/root_parser.py | 8 +++++--- paths_cli/parsing/schemes.py | 9 ++++++--- paths_cli/parsing/shooting.py | 6 ++++-- 5 files changed, 31 insertions(+), 12 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 2ca2269f..b7cc503a 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -4,6 +4,7 @@ import yaml from collections import namedtuple, abc +import warnings import logging @@ -217,7 +218,10 @@ def __call__(self, dct): class Parser: """Generic parse class; instances for each category""" + error_on_duplicate = False # TODO: temporary def __init__(self, type_dispatch, label): + if type_dispatch is None: + type_dispatch = {} self.type_dispatch = type_dispatch self.label = label self.named_objs = {} @@ -253,8 +257,12 @@ def register_object(self, obj, name): def register_builder(self, builder, name): if name in self.type_dispatch: - raise RuntimeError(f"'{builder.name}' is already registered " - f"with {self.label}") + msg = (f"'{builder.name}' is already registered " + f"with {self.label}") + if self.error_on_duplicate: + raise RuntimeError(msg) + else: + warnings.warn(msg) self.type_dispatch[name] = builder def parse(self, dct): diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index c03d05e6..a4a1a8f2 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -12,7 +12,8 @@ "for this interface set"), Parameter('minvals', custom_eval), # TODO fill in JSON types Parameter('maxvals', custom_eval), # TODO fill in JSON types - ] + ], + name='volume-interface-set' ) def mistis_trans_info(dct): @@ -47,17 +48,20 @@ def tis_trans_info(dct): description="initial states for this transition"), Parameter('final_states', volume_parser, description="final states for this transition") - ] + ], + name='tps' ) build_mistis_network = InstanceBuilder( parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), + name='mistis' ) build_tis_network = InstanceBuilder( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], + name='tis' ) TYPE_MAPPING = { diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 6994643a..93f3d45a 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -1,9 +1,10 @@ -from paths_cli.parsing.core import Parser +from paths_cli.parsing.core import Parser, InstanceBuilder from paths_cli.parsing.engines import engine_parser from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.networks import network_parser from paths_cli.parsing.schemes import scheme_parser +from paths_cli.parsing.plugins import ParserPlugin TYPE_MAPPING = { @@ -49,7 +50,7 @@ def _get_parser(parser_name): """ parser = PARSERS.get(parser_name, None) if parser is None: - parser = Parser(None, parse_name) + parser = Parser(None, parser_name) PARSERS[parser_name] = parser return parser @@ -62,7 +63,8 @@ def _get_registration_names(plugin): """ ordered_names = [] found_names = set([]) - for name in [plugin.name] + plugin.aliases: + aliases = [] if plugin.aliases is None else plugin.aliases + for name in [plugin.name] + aliases: if name not in found_names: ordered_names.append(name) found_names.add(name) diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index 88549fd7..c472358b 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -24,7 +24,8 @@ Parameter('k_spring', custom_eval), Parameter('delta_max', custom_eval), ENGINE_PARAMETER - ] + ], + name='spring-shooting', ) class BuildSchemeStrategy: @@ -56,7 +57,8 @@ def __call__(self, dct): SP_SELECTOR_PARAMETER, ENGINE_PARAMETER, STRATEGIES_PARAMETER, - ] + ], + name='one-way-shooting', ) build_scheme = InstanceBuilder( @@ -65,7 +67,8 @@ def __call__(self, dct): parameters=[ NETWORK_PARAMETER, STRATEGIES_PARAMETER, - ] + ], + name='scheme' ) scheme_parser = Parser( diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py index 250ea0ba..d7824e12 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/parsing/shooting.py @@ -7,7 +7,8 @@ build_uniform_selector = InstanceBuilder( builder=Builder('openpathsampling.UniformSelector'), - parameters=[] + parameters=[], + name='uniform', ) def remapping_gaussian_stddev(dct): @@ -23,7 +24,8 @@ def remapping_gaussian_stddev(dct): Parameter('cv', cv_parser), Parameter('mean', custom_eval), Parameter('stddev', custom_eval), - ] + ], + name='gaussian', ) shooting_selector_parser = Parser( From f31b1c929129932bba508915ad2a55e3c70862eb Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 10 Aug 2021 16:43:59 -0400 Subject: [PATCH 090/251] more updates to use the new plugin model --- paths_cli/parsing/cvs.py | 3 ++- paths_cli/parsing/engines.py | 2 +- paths_cli/parsing/networks.py | 10 ++++++---- paths_cli/parsing/plugins.py | 2 +- paths_cli/parsing/root_parser.py | 2 +- paths_cli/parsing/schemes.py | 8 +++++--- paths_cli/parsing/strategies.py | 27 ++++++++++++++++++--------- 7 files changed, 34 insertions(+), 20 deletions(-) diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 8866f3fb..361b8752 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -5,7 +5,7 @@ from .topology import build_topology from .errors import InputError from paths_cli.utils import import_thing -from paths_cli.parsing.plugins import CVParserPlugin +from paths_cli.parsing.plugins import CVParserPlugin, ParserPlugin class AllowedPackageHandler: @@ -57,4 +57,5 @@ def cv_kwargs_remapper(dct): 'mdtraj': MDTRAJ_CV_PLUGIN, } +CV_PARSER = ParserPlugin(CVParserPlugin, aliases=['cvs']) cv_parser = Parser(TYPE_MAPPING, label="CVs") diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index 005174dc..4dca0f85 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -1,5 +1,5 @@ from .topology import build_topology -from .core import Parser, InstanceBuilder, custom_eval, Builder +from .core import Parser, custom_eval, Builder from paths_cli.parsing.core import Parameter from .tools import custom_eval_int from paths_cli.parsing.plugins import EngineParserPlugin, ParserPlugin diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index a4a1a8f2..d3e3d224 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -4,6 +4,7 @@ from paths_cli.parsing.tools import custom_eval from paths_cli.parsing.volumes import volume_parser from paths_cli.parsing.cvs import cv_parser +from paths_cli.parsing.plugins import NetworkParserPlugin, ParserPlugin build_interface_set = InstanceBuilder( builder=Builder('openpathsampling.VolumeInterfaceSet'), @@ -13,7 +14,7 @@ Parameter('minvals', custom_eval), # TODO fill in JSON types Parameter('maxvals', custom_eval), # TODO fill in JSON types ], - name='volume-interface-set' + name='interface-set' ) def mistis_trans_info(dct): @@ -41,7 +42,7 @@ def tis_trans_info(dct): 'interfaces': interface_set}] return mistis_trans_info(dct) -build_tps_network = InstanceBuilder( +build_tps_network = NetworkParserPlugin( builder=Builder('openpathsampling.TPSNetwork'), parameters=[ Parameter('initial_states', volume_parser, @@ -52,13 +53,13 @@ def tis_trans_info(dct): name='tps' ) -build_mistis_network = InstanceBuilder( +build_mistis_network = NetworkParserPlugin( parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), name='mistis' ) -build_tis_network = InstanceBuilder( +build_tis_network = NetworkParserPlugin( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], name='tis' @@ -70,4 +71,5 @@ def tis_trans_info(dct): 'mistis': build_mistis_network, } +NETWORK_PARSER = ParserPlugin(NetworkParserPlugin, aliases=['networks']) network_parser = Parser(TYPE_MAPPING, label="networks") diff --git a/paths_cli/parsing/plugins.py b/paths_cli/parsing/plugins.py index cd904046..c6d36c43 100644 --- a/paths_cli/parsing/plugins.py +++ b/paths_cli/parsing/plugins.py @@ -3,7 +3,7 @@ class ParserPlugin(OPSPlugin): """ - Parser plugins only need to be made for top-level + Parser plugins only need to be made for top-level """ error_on_duplicate = False # TODO: temporary def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 93f3d45a..d8d61a4c 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -41,7 +41,7 @@ def parser_for(parser_name): the name of the parser to use """ def load_and_call_parser(*args, **kwargs): - return PARSER[parser_name](*args, **kwargs) + return PARSERS[parser_name](*args, **kwargs) return load_and_call_parser def _get_parser(parser_name): diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index c472358b..f1e79d77 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -8,6 +8,7 @@ from paths_cli.parsing.strategies import ( strategy_parser, SP_SELECTOR_PARAMETER ) +from paths_cli.parsing.plugins import SchemeParserPlugin, ParserPlugin NETWORK_PARAMETER = Parameter('network', network_parser) @@ -17,7 +18,7 @@ STRATEGIES_PARAMETER = Parameter('strategies', strategy_parser, default=None) -build_spring_shooting_scheme = InstanceBuilder( +build_spring_shooting_scheme = SchemeParserPlugin( builder=Builder('openpathsampling.SpringShootingMoveScheme'), parameters=[ NETWORK_PARAMETER, @@ -49,7 +50,7 @@ def __call__(self, dct): return scheme -build_one_way_shooting_scheme = InstanceBuilder( +build_one_way_shooting_scheme = SchemeParserPlugin( builder=BuildSchemeStrategy('openpathsampling.OneWayShootingMoveScheme', default_global_strategy=False), parameters=[ @@ -61,7 +62,7 @@ def __call__(self, dct): name='one-way-shooting', ) -build_scheme = InstanceBuilder( +build_scheme = SchemeParserPlugin( builder=BuildSchemeStrategy('openpathsampling.MoveScheme', default_global_strategy=True), parameters=[ @@ -71,6 +72,7 @@ def __call__(self, dct): name='scheme' ) +SCHEME_PARSER = ParserPlugin(SchemeParserPlugin, aliases=['schemes']) scheme_parser = Parser( type_dispatch={ 'one-way-shooting': build_one_way_shooting_scheme, diff --git a/paths_cli/parsing/strategies.py b/paths_cli/parsing/strategies.py index 90e291ec..75848f87 100644 --- a/paths_cli/parsing/strategies.py +++ b/paths_cli/parsing/strategies.py @@ -1,8 +1,9 @@ from paths_cli.parsing.core import ( - InstanceBuilder, Parser, Builder, Parameter + Parser, Builder, Parameter ) from paths_cli.parsing.shooting import shooting_selector_parser from paths_cli.parsing.engines import engine_parser +from paths_cli.parsing.plugins import StrategyParserPlugin, ParserPlugin def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" @@ -27,7 +28,7 @@ def _group_parameter(group_name): -build_one_way_shooting_strategy = InstanceBuilder( +build_one_way_shooting_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("OneWayShootingStrategy")), parameters=[ SP_SELECTOR_PARAMETER, @@ -35,9 +36,10 @@ def _group_parameter(group_name): SHOOTING_GROUP_PARAMETER, Parameter('replace', bool, default=True) ], + name='one-way-shooting', ) -build_two_way_shooting_strategy = InstanceBuilder( +build_two_way_shooting_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("TwoWayShootingStrategy")), parameters = [ Parameter('modifier', ...), @@ -46,50 +48,57 @@ def _group_parameter(group_name): SHOOTING_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER, ], + name='two-way-shooting', ) -build_nearest_neighbor_repex_strategy = InstanceBuilder( +build_nearest_neighbor_repex_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("NearestNeighborRepExStrategy")), parameters=[ REPEX_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER ], + name='nearest-neighbor=repex', ) -build_all_set_repex_strategy = InstanceBuilder( +build_all_set_repex_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("AllSetRepExStrategy")), parameters=[ REPEX_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER ], + name='all-set-repex', ) -build_path_reversal_strategy = InstanceBuilder( +build_path_reversal_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("PathReversalStrategy")), parameters=[ _group_parameter('pathreversal'), REPLACE_TRUE_PARAMETER, - ] + ], + name='path-reversal', ) -build_minus_move_strategy = InstanceBuilder( +build_minus_move_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("MinusMoveStrategy")), parameters=[ ENGINE_PARAMETER, MINUS_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER, ], + name='minus', ) -build_single_replica_minus_move_strategy = InstanceBuilder( +build_single_replica_minus_move_strategy = StrategyParserPlugin( builder=Builder(_strategy_name("SingleReplicaMinusMoveStrategy")), parameters=[ ENGINE_PARAMETER, MINUS_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER, ], + name='single-replica-minus', ) +STRATEGY_PARSER = ParserPlugin(StrategyParserPlugin, aliases=['strategies']) strategy_parser = Parser( type_dispatch={ 'one-way-shooting': build_one_way_shooting_strategy, From d4b3f298ef40943cbf27476c8c26e0bc91d844ef Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 11 Aug 2021 13:15:26 -0400 Subject: [PATCH 091/251] remove old parsing from engines, cvs, volumes --- paths_cli/parsing/cvs.py | 1 - paths_cli/parsing/engines.py | 1 - paths_cli/parsing/networks.py | 12 ++++----- paths_cli/parsing/root_parser.py | 33 ++++++++++++------------ paths_cli/parsing/schemes.py | 4 +-- paths_cli/parsing/shooting.py | 4 +-- paths_cli/parsing/strategies.py | 4 +-- paths_cli/parsing/topology.py | 4 ++- paths_cli/parsing/volumes.py | 15 +++-------- paths_cli/tests/parsing/test_networks.py | 32 ++++++++++++++--------- paths_cli/tests/parsing/test_shooting.py | 5 ++-- paths_cli/tests/parsing/test_topology.py | 18 +++++++++---- paths_cli/tests/parsing/test_volumes.py | 25 +++++++++++------- 13 files changed, 87 insertions(+), 71 deletions(-) diff --git a/paths_cli/parsing/cvs.py b/paths_cli/parsing/cvs.py index 361b8752..eb73c46a 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/parsing/cvs.py @@ -58,4 +58,3 @@ def cv_kwargs_remapper(dct): } CV_PARSER = ParserPlugin(CVParserPlugin, aliases=['cvs']) -cv_parser = Parser(TYPE_MAPPING, label="CVs") diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index 4dca0f85..dccc7bea 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -58,4 +58,3 @@ def openmm_options(dct): } ENGINE_PARSER = ParserPlugin(EngineParserPlugin, aliases=['engines']) -engine_parser = Parser(TYPE_MAPPING, label="engines") diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index d3e3d224..8066e0d6 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -2,15 +2,14 @@ InstanceBuilder, Parser, Builder, Parameter ) from paths_cli.parsing.tools import custom_eval -from paths_cli.parsing.volumes import volume_parser -from paths_cli.parsing.cvs import cv_parser from paths_cli.parsing.plugins import NetworkParserPlugin, ParserPlugin +from paths_cli.parsing.root_parser import parser_for build_interface_set = InstanceBuilder( builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ - Parameter('cv', cv_parser, description="the collective variable " - "for this interface set"), + Parameter('cv', parser_for('cv'), description="the collective " + "variable for this interface set"), Parameter('minvals', custom_eval), # TODO fill in JSON types Parameter('maxvals', custom_eval), # TODO fill in JSON types ], @@ -20,6 +19,7 @@ def mistis_trans_info(dct): dct = dct.copy() transitions = dct.pop('transitions') + volume_parser = parser_for('volume') trans_info = [ ( volume_parser(trans['initial_state']), @@ -45,9 +45,9 @@ def tis_trans_info(dct): build_tps_network = NetworkParserPlugin( builder=Builder('openpathsampling.TPSNetwork'), parameters=[ - Parameter('initial_states', volume_parser, + Parameter('initial_states', parser_for('volume'), description="initial states for this transition"), - Parameter('final_states', volume_parser, + Parameter('final_states', parser_for('volume'), description="final states for this transition") ], name='tps' diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index d8d61a4c..99206081 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -1,21 +1,7 @@ from paths_cli.parsing.core import Parser, InstanceBuilder -from paths_cli.parsing.engines import engine_parser -from paths_cli.parsing.cvs import cv_parser -from paths_cli.parsing.volumes import volume_parser -from paths_cli.parsing.networks import network_parser -from paths_cli.parsing.schemes import scheme_parser from paths_cli.parsing.plugins import ParserPlugin -TYPE_MAPPING = { - 'engines': engine_parser, - 'cvs': cv_parser, - 'volumes': volume_parser, - 'states': volume_parser, - 'networks': network_parser, - 'moveschemes': scheme_parser, -} - _DEFAULT_PARSE_ORDER = [ 'engine', 'cv', @@ -30,6 +16,21 @@ PARSERS = {} ALIASES = {} +class ParserProxy: + def __init__(self, parser_name): + self.parser_name = parser_name + + @property + def _proxy(self): + return PARSERS[self.parser_name] + + @property + def named_objs(self): + return self._proxy.named_objs + + def __call__(self, *args, **kwargs): + return self._proxy(*args, **kwargs) + def parser_for(parser_name): """Delayed parser calling. @@ -40,9 +41,7 @@ def parser_for(parser_name): parser_name : str the name of the parser to use """ - def load_and_call_parser(*args, **kwargs): - return PARSERS[parser_name](*args, **kwargs) - return load_and_call_parser + return ParserProxy(parser_name) def _get_parser(parser_name): """ diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index f1e79d77..58e3c02a 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -3,17 +3,17 @@ ) from paths_cli.parsing.tools import custom_eval from paths_cli.parsing.shooting import shooting_selector_parser -from paths_cli.parsing.engines import engine_parser from paths_cli.parsing.networks import network_parser from paths_cli.parsing.strategies import ( strategy_parser, SP_SELECTOR_PARAMETER ) from paths_cli.parsing.plugins import SchemeParserPlugin, ParserPlugin +from paths_cli.parsing.root_parser import parser_for NETWORK_PARAMETER = Parameter('network', network_parser) -ENGINE_PARAMETER = Parameter('engine', engine_parser) # reuse elsewhere? +ENGINE_PARAMETER = Parameter('engine', parser_for('engine')) # reuse elsewhere? STRATEGIES_PARAMETER = Parameter('strategies', strategy_parser, default=None) diff --git a/paths_cli/parsing/shooting.py b/paths_cli/parsing/shooting.py index d7824e12..1cd1b70c 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/parsing/shooting.py @@ -1,7 +1,7 @@ from paths_cli.parsing.core import ( InstanceBuilder, Parser, Builder, Parameter ) -from paths_cli.parsing.cvs import cv_parser +from paths_cli.parsing.root_parser import parser_for from paths_cli.parsing.tools import custom_eval import numpy as np @@ -21,7 +21,7 @@ def remapping_gaussian_stddev(dct): builder=Builder('openpathsampling.GaussianBiasSelector', remapper=remapping_gaussian_stddev), parameters=[ - Parameter('cv', cv_parser), + Parameter('cv', parser_for('cv')), Parameter('mean', custom_eval), Parameter('stddev', custom_eval), ], diff --git a/paths_cli/parsing/strategies.py b/paths_cli/parsing/strategies.py index 75848f87..66de42ab 100644 --- a/paths_cli/parsing/strategies.py +++ b/paths_cli/parsing/strategies.py @@ -2,8 +2,8 @@ Parser, Builder, Parameter ) from paths_cli.parsing.shooting import shooting_selector_parser -from paths_cli.parsing.engines import engine_parser from paths_cli.parsing.plugins import StrategyParserPlugin, ParserPlugin +from paths_cli.parsing.root_parser import parser_for def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" @@ -15,7 +15,7 @@ def _group_parameter(group_name): # TODO: maybe this moves into shooting once we have the metadata? SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_parser) -ENGINE_PARAMETER = Parameter('engine', engine_parser, +ENGINE_PARAMETER = Parameter('engine', parser_for('engine'), description="the engine for moves of this " "type") diff --git a/paths_cli/parsing/topology.py b/paths_cli/parsing/topology.py index dc50621b..4d32f11e 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/parsing/topology.py @@ -1,9 +1,11 @@ import os from .errors import InputError +from paths_cli.parsing.root_parser import parser_for def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" - from paths_cli.parsing.engines import engine_parser + # from paths_cli.parsing.engines import engine_parser + engine_parser = parser_for('engine') if dct in engine_parser.named_objs: engine = engine_parser.named_objs[dct] try: diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index 5f92af0f..e9e40bc4 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -2,8 +2,8 @@ import functools from .core import Parser, InstanceBuilder, custom_eval, Parameter -from .cvs import cv_parser from paths_cli.parsing.plugins import VolumeParserPlugin +from paths_cli.parsing.root_parser import parser_for # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): @@ -21,7 +21,7 @@ def cv_volume_build_func(**dct): build_cv_volume = VolumeParserPlugin( builder=cv_volume_build_func, parameters=[ - Parameter('cv', cv_parser, + Parameter('cv', parser_for('cv'), description="CV that defines this volume"), Parameter('lambda_min', custom_eval, description="Lower bound for this volume"), @@ -31,10 +31,6 @@ def cv_volume_build_func(**dct): name='cv-volume', ) -def _use_parser(dct): - # this is a hack to get around circular definitions - return volume_parser(dct) - # jsonschema type for combination volumes VOL_ARRAY_TYPE = { 'type': 'array', @@ -45,7 +41,7 @@ def _use_parser(dct): builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), parameters=[ - Parameter('subvolumes', _use_parser, + Parameter('subvolumes', parser_for('volume'), json_type=VOL_ARRAY_TYPE, description="List of the volumes to intersect") ], @@ -56,7 +52,7 @@ def _use_parser(dct): builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), parameters=[ - Parameter('subvolumes', _use_parser, + Parameter('subvolumes', parser_for('volume'), json_type=VOL_ARRAY_TYPE, description="List of the volumes to join into a union") ], @@ -67,6 +63,3 @@ def _use_parser(dct): 'cv-volume': build_cv_volume, 'intersection': build_intersection_volume, } - -volume_parser = Parser(TYPE_MAPPING, label="volumes") - diff --git a/paths_cli/tests/parsing/test_networks.py b/paths_cli/tests/parsing/test_networks.py index ed214052..533fe705 100644 --- a/paths_cli/tests/parsing/test_networks.py +++ b/paths_cli/tests/parsing/test_networks.py @@ -1,5 +1,6 @@ import pytest from unittest import mock +from paths_cli.tests.parsing.utils import mock_parser import numpy as np import yaml @@ -39,11 +40,13 @@ def test_mistis_trans_info(cv_and_states): }] } patch_base = 'paths_cli.parsing.networks' - - with mock.patch.dict(f"{patch_base}.volume_parser.named_objs", - {"A": state_A, "B": state_B}), \ - mock.patch.dict(f"{patch_base}.cv_parser.named_objs", - {'cv': cv}): + parser = { + 'cv': mock_parser('cv', named_objs={'cv': cv}), + 'volume': mock_parser('volume', named_objs={ + "A": state_A, "B": state_B + }), + } + with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): results = mistis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -62,11 +65,13 @@ def test_tis_trans_info(cv_and_states): } } - patch_base = 'paths_cli.parsing.networks' - with mock.patch.dict(f"{patch_base}.volume_parser.named_objs", - {"A": state_A, "B": state_B}), \ - mock.patch.dict(f"{patch_base}.cv_parser.named_objs", - {'cv': cv}): + parser = { + 'cv': mock_parser('cv', named_objs={'cv': cv}), + 'volume': mock_parser('volume', named_objs={ + "A": state_A, "B": state_B + }), + } + with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): results = tis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -77,8 +82,11 @@ def test_build_tps_network(cv_and_states): _, state_A, state_B = cv_and_states yml = "\n".join(["initial_states:", " - A", "final_states:", " - B"]) dct = yaml.load(yml, yaml.FullLoader) - patch_loc = 'paths_cli.parsing.networks.volume_parser.named_objs' - with mock.patch.dict(patch_loc, {"A": state_A, "B": state_B}): + parser = { + 'volume': mock_parser('volume', named_objs={"A": state_A, + "B": state_B}), + } + with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): network = build_tps_network(dct) assert isinstance(network, paths.TPSNetwork) assert len(network.initial_states) == len(network.final_states) == 1 diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/parsing/test_shooting.py index 2e810877..5cca9038 100644 --- a/paths_cli/tests/parsing/test_shooting.py +++ b/paths_cli/tests/parsing/test_shooting.py @@ -2,6 +2,7 @@ from paths_cli.parsing.shooting import * import openpathsampling as paths +from paths_cli.tests.parsing.utils import mock_parser from unittest.mock import patch from openpathsampling.tests.test_helpers import make_1d_traj @@ -16,8 +17,8 @@ def test_remapping_gaussian_stddev(cv_and_states): def test_build_gaussian_selector(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': 'x', 'mean': 1.0, 'stddev': 2.0} - with patch.dict('paths_cli.parsing.shooting.cv_parser.named_objs', - {'x': cv}): + parser = {'cv': mock_parser('cv', named_objs={'x': cv})} + with patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): sel = build_gaussian_selector(dct) assert isinstance(sel, paths.GaussianBiasSelector) diff --git a/paths_cli/tests/parsing/test_topology.py b/paths_cli/tests/parsing/test_topology.py index 01897a3b..e33e94ef 100644 --- a/paths_cli/tests/parsing/test_topology.py +++ b/paths_cli/tests/parsing/test_topology.py @@ -1,9 +1,12 @@ import pytest from openpathsampling.tests.test_helpers import data_filename -from unittest.mock import patch +from unittest.mock import patch, Mock from paths_cli.parsing.topology import * from paths_cli.parsing.errors import InputError +import paths_cli.parsing.root_parser +from paths_cli.tests.parsing.utils import mock_parser + class TestBuildTopology: def test_build_topology_file(self): @@ -13,12 +16,17 @@ def test_build_topology_file(self): assert topology.n_atoms == 1651 def test_build_topology_engine(self, flat_engine): - patch_loc = 'paths_cli.parsing.engines.engine_parser.named_objs' - with patch.dict(patch_loc, {'flat': flat_engine}): + patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + parser = mock_parser('engine', named_objs={'flat': flat_engine}) + parsers = {'engine': parser} + with patch.dict(patch_loc, parsers): topology = build_topology('flat') assert topology.n_spatial == 3 assert topology.n_atoms == 1 def test_build_topology_fail(self): - with pytest.raises(InputError): - topology = build_topology('foo') + patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + parsers = {'engine': mock_parser('engine')} + with patch.dict(patch_loc, parsers): + with pytest.raises(InputError): + topology = build_topology('foo') diff --git a/paths_cli/tests/parsing/test_volumes.py b/paths_cli/tests/parsing/test_volumes.py index 23951d8d..8e931784 100644 --- a/paths_cli/tests/parsing/test_volumes.py +++ b/paths_cli/tests/parsing/test_volumes.py @@ -1,5 +1,6 @@ import pytest from unittest import mock +from paths_cli.tests.parsing.utils import mock_parser import yaml import openpathsampling as paths @@ -40,8 +41,11 @@ def test_build_cv_volume(self, inline, periodic): yml = self.yml.format(func=self.func[inline]) dct = yaml.load(yml, Loader=yaml.FullLoader) if inline =='external': - patchloc = 'paths_cli.parsing.volumes.cv_parser.named_objs' - with mock.patch.dict(patchloc, {'foo': self.mock_cv}): + patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + parsers = { + 'cv': mock_parser('cv', named_objs={'foo': self.mock_cv}) + } + with mock.patch.dict(patch_loc, parsers): vol = build_cv_volume(dct) elif inline == 'internal': vol = build_cv_volume(dct) @@ -95,13 +99,16 @@ def test_build_combo_volume(self, combo, inline): true_vol = combo_class(vol_A, vol_B) dct = yaml.load(yml, yaml.FullLoader) - this_mod = 'paths_cli.parsing.volumes.' - patchloc = 'paths_cli.parsing.volumes.volume_parser.named_objs' - with \ - mock.patch.dict(this_mod + 'cv_parser.named_objs', - {'foo': self.cv}) as cv_patch, \ - mock.patch.dict(this_mod + 'volume_parser.named_objs', - named_volumes_dict) as vol_patch: + parser = { + 'cv': mock_parser('cv', named_objs={'foo': self.cv}), + 'volume': mock_parser( + 'volume', + type_dispatch={'cv-volume': build_cv_volume}, + named_objs=named_volumes_dict + ), + } + with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', + parser): vol = builder(dct) traj = make_1d_traj([0.5, 2.0, 0.2]) From c8bf0c37c26abefe8cc04dbb1308a3dc2a3a653b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 11 Aug 2021 13:18:52 -0400 Subject: [PATCH 092/251] add tests/parsing/utils --- paths_cli/tests/parsing/utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 paths_cli/tests/parsing/utils.py diff --git a/paths_cli/tests/parsing/utils.py b/paths_cli/tests/parsing/utils.py new file mode 100644 index 00000000..ed88364a --- /dev/null +++ b/paths_cli/tests/parsing/utils.py @@ -0,0 +1,9 @@ +from paths_cli.parsing.core import Parser + +def mock_parser(parser_name, type_dispatch=None, named_objs=None): + if type_dispatch is None: + type_dispatch = {} + parser = Parser(type_dispatch, parser_name) + if named_objs is not None: + parser.named_objs = named_objs + return parser From 8163cfe1d1452c47d53f18df9f1e5b93308c9772 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 11 Aug 2021 13:31:49 -0400 Subject: [PATCH 093/251] Remove old parsers: networks, schemes, strategies --- paths_cli/parsing/networks.py | 7 ------- paths_cli/parsing/schemes.py | 20 ++++---------------- paths_cli/parsing/strategies.py | 12 ------------ 3 files changed, 4 insertions(+), 35 deletions(-) diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index 8066e0d6..7c977c9a 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -65,11 +65,4 @@ def tis_trans_info(dct): name='tis' ) -TYPE_MAPPING = { - 'tps': build_tps_network, - 'tis': build_tis_network, - 'mistis': build_mistis_network, -} - NETWORK_PARSER = ParserPlugin(NetworkParserPlugin, aliases=['networks']) -network_parser = Parser(TYPE_MAPPING, label="networks") diff --git a/paths_cli/parsing/schemes.py b/paths_cli/parsing/schemes.py index 58e3c02a..425e8784 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/parsing/schemes.py @@ -3,19 +3,17 @@ ) from paths_cli.parsing.tools import custom_eval from paths_cli.parsing.shooting import shooting_selector_parser -from paths_cli.parsing.networks import network_parser -from paths_cli.parsing.strategies import ( - strategy_parser, SP_SELECTOR_PARAMETER -) +from paths_cli.parsing.strategies import SP_SELECTOR_PARAMETER from paths_cli.parsing.plugins import SchemeParserPlugin, ParserPlugin from paths_cli.parsing.root_parser import parser_for -NETWORK_PARAMETER = Parameter('network', network_parser) +NETWORK_PARAMETER = Parameter('network', parser_for('network')) ENGINE_PARAMETER = Parameter('engine', parser_for('engine')) # reuse elsewhere? -STRATEGIES_PARAMETER = Parameter('strategies', strategy_parser, default=None) +STRATEGIES_PARAMETER = Parameter('strategies', parser_for('strategy'), + default=None) build_spring_shooting_scheme = SchemeParserPlugin( @@ -73,13 +71,3 @@ def __call__(self, dct): ) SCHEME_PARSER = ParserPlugin(SchemeParserPlugin, aliases=['schemes']) -scheme_parser = Parser( - type_dispatch={ - 'one-way-shooting': build_one_way_shooting_scheme, - 'spring-shooting': build_spring_shooting_scheme, - 'scheme': build_scheme, - 'default-tis': ..., - }, - label='move schemes' -) - diff --git a/paths_cli/parsing/strategies.py b/paths_cli/parsing/strategies.py index 66de42ab..86d908df 100644 --- a/paths_cli/parsing/strategies.py +++ b/paths_cli/parsing/strategies.py @@ -99,15 +99,3 @@ def _group_parameter(group_name): ) STRATEGY_PARSER = ParserPlugin(StrategyParserPlugin, aliases=['strategies']) -strategy_parser = Parser( - type_dispatch={ - 'one-way-shooting': build_one_way_shooting_strategy, - 'two-way-shooting': build_two_way_shooting_strategy, - 'nearest-neighbor-repex': build_nearest_neighbor_repex_strategy, - 'all-set-repex': build_all_set_repex_strategy, - 'path-reversal': build_path_reversal_strategy, - 'minus': build_minus_move_strategy, - 'single-rep-minux': build_single_replica_minus_move_strategy, - }, - label="strategy" -) From 769d3f7f6b42a6c51b1e427ee79e7b5e4e44e4a0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 13 Aug 2021 10:15:50 -0400 Subject: [PATCH 094/251] skeleton of root_parser tests --- paths_cli/parsing/root_parser.py | 9 +- paths_cli/tests/parsing/test_root_parser.py | 96 +++++++++++++++++++++ 2 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 paths_cli/tests/parsing/test_root_parser.py diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 99206081..c586896c 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -104,15 +104,10 @@ def _register_parser_plugin(plugin): raise DUPLICATE_ERROR ALIASES[alias] = plugin.name -### old versions - -def register_builder(parser_name, name, builder): - parser = TYPE_MAPPING[parser_name] - parser.register_builder(builder, name) - def parse(dct): objs = [] - for category, func in TYPE_MAPPING.items(): + for category in PARSE_ORDER: + func = PARSERS[category] yaml_objs = dct.get(category, []) new = [func(obj) for obj in yaml_objs] objs.extend(new) diff --git a/paths_cli/tests/parsing/test_root_parser.py b/paths_cli/tests/parsing/test_root_parser.py new file mode 100644 index 00000000..f73a0792 --- /dev/null +++ b/paths_cli/tests/parsing/test_root_parser.py @@ -0,0 +1,96 @@ +import pytest +from paths_cli.parsing.root_parser import * +from paths_cli.parsing.root_parser import ( + _get_parser, _get_registration_names, _register_builder_plugin, + _register_parser_plugin +) +from unittest.mock import Mock, patch + +PARSER_LOC = "paths_cli.parsing.root_parser.PARSERS" + +class TestParserProxy: + def setup(self): + pass + + def test_proxy(self): + # the _proxy should be the registered parser + pytest.skip() + + def test_proxy_nonexisting(self): + # _proxy should error if the no parser is registered + pytest.skip() + + def test_named_objs(self): + # the `.named_objs` attribute should work in the proxy + pytest.skip() + + def test_call(self): + # the `__call__` method should work in the proxy + # the `__call__` method should work in the proxy + pytest.skip() + +def test_parser_for_nonexisting(): + # if the nothing is ever registered with the parser, then parser_for + # should error + parsers = {} + with patch.dict(PARSER_LOC, parsers): + assert 'foo' not in parsers + proxy = parser_for('foo') + assert 'foo' not in parsers + with pytest.raises(KeyError): + proxy._proxy + +def test_parser_for_existing(): + # if a parser already exists when parser_for is called, then parser_for + # should get that as its proxy + pytest.skip() + +def test_parser_for_registered(): + # if a parser is registered after parser_for is called, then parser_for + # should use that as its proxy + pytest.skip() + +def test_get_parser_existing(): + # if a parser has been registered, then _get_parser should return the + # registered parser + pytest.skip() + +def test_get_parser_nonexisting(): + # if a parser has not been registered, then _get_parser should create + # the parser + pytest.skip() + +def test_get_registration_names(): + # _get_registration_names should always provide the names in order + # `canonical, alias1, alias2, ...` regardless of whether `canonical` is + # also listed in the aliases + pytest.skip() + +def test_register_parser_plugin(): + # _register_parser_plugin should register parsers that don't exist + pytest.skip() + +def test_register_parser_plugin_duplicate(): + # if a parser of the same name exists either in canonical or aliases, + # _register_parser_plugin should raise an error + pytest.skip() + +def test_register_builder_plugin(): + # _register_builder_plugin should register plugins that don't exist, + # including registering the parser if needed + pytest.skip() + +def test_register_plugins_unit(): + # register_plugins should correctly sort builder and parser plugins, and + # call the correct registration functions + # TODO: patch _register_builder & _register_parser; this is unit, not + # integration + pytest.skip() + +def test_register_plugins_integration(): + # register_plugins should correctly register plugins + pytest.skip() + +def test_parse(): + # parser should correctly parse a basic input dict + pytest.skip() From 1cd48850bd1ff5cbc55708c1fa3531c66ffd15d7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 13 Aug 2021 12:14:28 -0400 Subject: [PATCH 095/251] some root parser tests --- paths_cli/tests/parsing/test_root_parser.py | 25 ++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/paths_cli/tests/parsing/test_root_parser.py b/paths_cli/tests/parsing/test_root_parser.py index f73a0792..e9ae75ab 100644 --- a/paths_cli/tests/parsing/test_root_parser.py +++ b/paths_cli/tests/parsing/test_root_parser.py @@ -5,33 +5,39 @@ _register_parser_plugin ) from unittest.mock import Mock, patch +from paths_cli.parsing.core import Parser PARSER_LOC = "paths_cli.parsing.root_parser.PARSERS" class TestParserProxy: def setup(self): + self.parser = Parser(None, "foo") + self.parser.named_objs['bar'] = 'baz' + self.proxy = ParserProxy('foo') pass def test_proxy(self): - # the _proxy should be the registered parser - pytest.skip() + # (NOT API) the _proxy should be the registered parser + with patch.dict(PARSER_LOC, {'foo': self.parser}): + assert self.proxy._proxy is self.parser def test_proxy_nonexisting(self): # _proxy should error if the no parser is registered - pytest.skip() + with pytest.raises(KeyError): + self.proxy._proxy def test_named_objs(self): # the `.named_objs` attribute should work in the proxy - pytest.skip() + with patch.dict(PARSER_LOC, {'foo': self.parser}): + assert self.proxy.named_objs == {'bar': 'baz'} def test_call(self): - # the `__call__` method should work in the proxy # the `__call__` method should work in the proxy pytest.skip() def test_parser_for_nonexisting(): - # if the nothing is ever registered with the parser, then parser_for - # should error + # if nothing is ever registered with the parser, then parser_for should + # error parsers = {} with patch.dict(PARSER_LOC, parsers): assert 'foo' not in parsers @@ -43,7 +49,10 @@ def test_parser_for_nonexisting(): def test_parser_for_existing(): # if a parser already exists when parser_for is called, then parser_for # should get that as its proxy - pytest.skip() + foo_parser = Parser(None, 'foo') + with patch.dict(PARSER_LOC, {'foo': foo_parser}): + proxy = parser_for('foo') + assert proxy._proxy is foo_parser def test_parser_for_registered(): # if a parser is registered after parser_for is called, then parser_for From eb78804c5913030ab515d5b94c5d2d3a46fe4d96 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 13 Aug 2021 13:35:11 -0400 Subject: [PATCH 096/251] more tests for root_parser --- paths_cli/parsing/root_parser.py | 3 +- paths_cli/tests/parsing/test_root_parser.py | 76 ++++++++++++++++----- 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index c586896c..69d4ffc5 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -45,7 +45,8 @@ def parser_for(parser_name): def _get_parser(parser_name): """ - _get_parser must only be used after the parsers have been registered + _get_parser must only be used after the parsers have been registered. It + will automatically create a parser for any unknown ``parser_name.`` """ parser = PARSERS.get(parser_name, None) if parser is None: diff --git a/paths_cli/tests/parsing/test_root_parser.py b/paths_cli/tests/parsing/test_root_parser.py index e9ae75ab..3252b36f 100644 --- a/paths_cli/tests/parsing/test_root_parser.py +++ b/paths_cli/tests/parsing/test_root_parser.py @@ -4,11 +4,33 @@ _get_parser, _get_registration_names, _register_builder_plugin, _register_parser_plugin ) -from unittest.mock import Mock, patch -from paths_cli.parsing.core import Parser +from unittest.mock import Mock, PropertyMock, patch +from paths_cli.parsing.core import Parser, InstanceBuilder +from paths_cli.parsing.plugins import ParserPlugin + + +### FIXTURES ############################################################### + +@pytest.fixture +def foo_parser(): + return Parser(None, 'foo') + +@pytest.fixture +def foo_parser_plugin(): + return ParserPlugin(Mock(parser_name='foo'), ['bar']) + +@pytest.fixture +def foo_baz_builder_plugin(): + builder = InstanceBuilder(None, [], name='baz') + builder.parser_name = 'foo' + return builder + +### CONSTANTS ############################################################## PARSER_LOC = "paths_cli.parsing.root_parser.PARSERS" +### TESTS ################################################################## + class TestParserProxy: def setup(self): self.parser = Parser(None, "foo") @@ -46,10 +68,9 @@ def test_parser_for_nonexisting(): with pytest.raises(KeyError): proxy._proxy -def test_parser_for_existing(): +def test_parser_for_existing(foo_parser): # if a parser already exists when parser_for is called, then parser_for # should get that as its proxy - foo_parser = Parser(None, 'foo') with patch.dict(PARSER_LOC, {'foo': foo_parser}): proxy = parser_for('foo') assert proxy._proxy is foo_parser @@ -59,25 +80,42 @@ def test_parser_for_registered(): # should use that as its proxy pytest.skip() -def test_get_parser_existing(): +def test_get_parser_existing(foo_parser): # if a parser has been registered, then _get_parser should return the # registered parser - pytest.skip() + with patch.dict(PARSER_LOC, {'foo': foo_parser}): + assert _get_parser('foo') is foo_parser -def test_get_parser_nonexisting(): +def test_get_parser_nonexisting(foo_parser): # if a parser has not been registered, then _get_parser should create # the parser - pytest.skip() - -def test_get_registration_names(): + with patch.dict(PARSER_LOC, {}): + parser = _get_parser('foo') + assert parser is not foo_parser # overkill + assert parser.label == 'foo' + assert 'foo' in PARSERS + +@pytest.mark.parametrize('canonical,aliases,expected', [ + ('foo', ['bar', 'baz'], ['foo', 'bar', 'baz']), + ('foo', ['baz', 'bar'], ['foo', 'baz', 'bar']), + ('foo', ['foo', 'bar'], ['foo', 'bar']), +]) +def test_get_registration_names(canonical, aliases, expected): # _get_registration_names should always provide the names in order # `canonical, alias1, alias2, ...` regardless of whether `canonical` is # also listed in the aliases - pytest.skip() + plugin = Mock(aliases=aliases) + type(plugin).name = PropertyMock(return_value=canonical) + assert _get_registration_names(plugin) == expected -def test_register_parser_plugin(): +def test_register_parser_plugin(foo_parser_plugin): # _register_parser_plugin should register parsers that don't exist - pytest.skip() + parsers = {} + with patch.dict(PARSER_LOC, parsers): + assert 'foo' not in parsers + _register_parser_plugin(foo_parser_plugin) + assert 'foo' in PARSERS + assert 'bar' in ALIASES def test_register_parser_plugin_duplicate(): # if a parser of the same name exists either in canonical or aliases, @@ -89,12 +127,16 @@ def test_register_builder_plugin(): # including registering the parser if needed pytest.skip() -def test_register_plugins_unit(): +def test_register_plugins_unit(foo_parser_plugin, foo_baz_builder_plugin): # register_plugins should correctly sort builder and parser plugins, and # call the correct registration functions - # TODO: patch _register_builder & _register_parser; this is unit, not - # integration - pytest.skip() + BASE = "paths_cli.parsing.root_parser." + + with patch(BASE + "_register_builder_plugin", Mock()) as builder, \ + patch(BASE + "_register_parser_plugin", Mock()) as parser: + register_plugins([foo_baz_builder_plugin, foo_parser_plugin]) + assert builder.called_once_with(foo_baz_builder_plugin) + assert parser.called_once_with(foo_parser_plugin) def test_register_plugins_integration(): # register_plugins should correctly register plugins From f0ee6eebd5f14dd44b685c0b6626d9504bfcbbaa Mon Sep 17 00:00:00 2001 From: Sander Roet Date: Mon, 23 Aug 2021 11:39:18 +0200 Subject: [PATCH 097/251] fix typo in error message --- paths_cli/param_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/param_core.py b/paths_cli/param_core.py index 79d37643..f1c23b48 100644 --- a/paths_cli/param_core.py +++ b/paths_cli/param_core.py @@ -309,7 +309,7 @@ def get(self, storage, name): if name is None: msg = "Couldn't guess which item to use from " + self.store else: - msg = "Couldn't find {name} is {store}".format( + msg = "Couldn't find {name} in {store}".format( name=name, store=self.store ) From 6d3f00054cebdfe88c32ec8908ad573d0db99edd Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 1 Sep 2021 18:46:25 -0400 Subject: [PATCH 098/251] fix dropped simtk in OpenMM URLs --- paths_cli/wizard/openmm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 6ad3338a..1bd76bdc 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -10,7 +10,7 @@ OPENMM_SERIALIZATION_URL=( "http://docs.openmm.org/latest/api-python/generated/" - "simtk.openmm.openmm.XmlSerializer.html" + "openmm.openmm.XmlSerializer.html" ) def _openmm_serialization_helper(wizard, user_input): # no-cov From 663a68d9849e007252c4183e9ef2c2ba63af6ebb Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 1 Sep 2021 20:15:04 -0400 Subject: [PATCH 099/251] remove other uses of simtk --- paths_cli/tests/wizard/conftest.py | 20 ++++++++++++++++++-- paths_cli/tests/wizard/test_openmm.py | 16 +++++++++++++++- paths_cli/wizard/openmm.py | 10 +++++++--- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index a4803c90..b7968e74 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -3,13 +3,29 @@ import openpathsampling as paths import mdtraj as md +try: + import openmm as mm + from openmm import unit as u +except ImportError: + try: + from simtk import openmm as mm + from simtk import unit as u + except ImportError: + HAS_OPENMM = False + else: # -no-cov- + HAS_OPENMM = True +else: + HAS_OPENMM = True + @pytest.fixture def ad_openmm(tmpdir): """ Provide directory with files to start alanine depeptide sim in OpenMM """ - mm = pytest.importorskip('simtk.openmm') - u = pytest.importorskip('simtk.unit') + if not HAS_OPENMM: + pytest.skip() + # mm = pytest.importorskip('simtk.openmm') + # u = pytest.importorskip('simtk.unit') openmmtools = pytest.importorskip('openmmtools') md = pytest.importorskip('mdtraj') testsystem = openmmtools.testsystems.AlanineDipeptideVacuum() diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 5d785008..696e2cd9 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -8,12 +8,26 @@ _load_openmm_xml, _load_topology, openmm, OPENMM_SERIALIZATION_URL ) +try: + import openmm as mm +except ImportError: + try: + from simtk import openmm as mm + except ImportError: + HAS_OPENMM = False + else: + HAS_OPENMM = True # -no-cov- +else: + HAS_OPENMM = True + def test_helper_url(): assert_url(OPENMM_SERIALIZATION_URL) @pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) def test_load_openmm_xml(ad_openmm, obj_type): - mm = pytest.importorskip('simtk.openmm') + if not HAS_OPENMM: + pytest.skip() + # mm = pytest.importorskip("simtk.openmm") filename = f"{obj_type}.xml" inputs = [filename] expected_count = 1 diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 1bd76bdc..cd508d0d 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,10 +1,14 @@ from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed from paths_cli.wizard.core import get_object try: - from simtk import openmm as mm - import mdtraj as md + import openmm as mm except ImportError: - HAS_OPENMM = False + try: + from simtk import openmm as mm + except ImportError: + HAS_OPENMM = False + else: # -no-cov- + HAS_OPENMM = True else: HAS_OPENMM = True From 7a9d041fa40720586a99609c559e92e7f972ef28 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 2 Sep 2021 11:11:54 -0400 Subject: [PATCH 100/251] Remove nc from descriptions of OPS files (since files can also be .db) --- paths_cli/commands/contents.py | 8 ++++---- paths_cli/parameters.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index 1e9f3164..468572d3 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -22,16 +22,16 @@ @click.command( 'contents', - short_help="list named objects from an OPS .nc file", + short_help="list named objects from an OPS output file", ) @INPUT_FILE.clicked(required=True) @click.option('--table', type=str, required=False, help="table to show results from") def contents(input_file, table): - """List the names of named objects in an OPS .nc file. + """List the names of named objects in an OPS output file. - This is particularly useful when getting ready to use one of simulation - scripts (i.e., to identify exactly how a state or engine is named.) + This is particularly useful when getting ready to use a simulation + command (i.e., to identify exactly how a state or engine is named.) """ storage = INPUT_FILE.get(input_file) print(storage) diff --git a/paths_cli/parameters.py b/paths_cli/parameters.py index e9bff767..e8fbc581 100644 --- a/paths_cli/parameters.py +++ b/paths_cli/parameters.py @@ -96,7 +96,7 @@ OUTPUT_FILE = StorageLoader( param=Option('-o', '--output-file', type=click.Path(writable=True), - help="output ncfile"), + help="output file"), mode='w' ) From 70bdc3ca0ce44221744d1c0577ce8b98e9977990 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 2 Sep 2021 12:29:50 -0400 Subject: [PATCH 101/251] updates after code review --- paths_cli/tests/wizard/conftest.py | 4 +++- paths_cli/tests/wizard/test_openmm.py | 4 +++- paths_cli/wizard/openmm.py | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index b7968e74..89070565 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -3,6 +3,7 @@ import openpathsampling as paths import mdtraj as md +# should be able to remove this try block when we drop OpenMM < 7.6 try: import openmm as mm from openmm import unit as u @@ -22,8 +23,9 @@ def ad_openmm(tmpdir): """ Provide directory with files to start alanine depeptide sim in OpenMM """ + # switch back to importorskip when we drop OpenMM < 7.6 if not HAS_OPENMM: - pytest.skip() + pytest.skip("could not import openmm") # mm = pytest.importorskip('simtk.openmm') # u = pytest.importorskip('simtk.unit') openmmtools = pytest.importorskip('openmmtools') diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 696e2cd9..9c94e869 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -8,6 +8,7 @@ _load_openmm_xml, _load_topology, openmm, OPENMM_SERIALIZATION_URL ) +# should be able to remove this try block when we drop OpenMM < 7.6 try: import openmm as mm except ImportError: @@ -25,8 +26,9 @@ def test_helper_url(): @pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) def test_load_openmm_xml(ad_openmm, obj_type): + # switch back to importorskip when we drop OpenMM < 7.6 if not HAS_OPENMM: - pytest.skip() + pytest.skip("could not import openmm") # mm = pytest.importorskip("simtk.openmm") filename = f"{obj_type}.xml" inputs = [filename] diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index cd508d0d..7341070f 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,5 +1,7 @@ from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed from paths_cli.wizard.core import get_object + +# should be able to simplify this try block when we drop OpenMM < 7.6 try: import openmm as mm except ImportError: From 4a8e50d558bddbaf7107ddb612496ce86bb782cf Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 4 Sep 2021 03:35:50 -0400 Subject: [PATCH 102/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/commands/contents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index 468572d3..12d7ce6c 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -22,13 +22,13 @@ @click.command( 'contents', - short_help="list named objects from an OPS output file", + short_help="list named objects from an OPS storage file", ) @INPUT_FILE.clicked(required=True) @click.option('--table', type=str, required=False, help="table to show results from") def contents(input_file, table): - """List the names of named objects in an OPS output file. + """List the names of named objects in an OPS storage file. This is particularly useful when getting ready to use a simulation command (i.e., to identify exactly how a state or engine is named.) From 59b890ccf31b7058e9b09d565cf156b036fc2213 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 Sep 2021 13:42:08 -0400 Subject: [PATCH 103/251] Some docstrings and tests for core --- paths_cli/parsing/core.py | 31 ++++++-- paths_cli/tests/parsing/test_core.py | 104 +++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index b7cc503a..f7b79016 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -69,7 +69,7 @@ def _get_from_loader(loader, attr_name, attr): return attr def __call__(self, *args, **kwargs): - # check correct call signature here + # check correct call signature here? return self.loader(*args, **kwargs) def to_json_schema(self, schema_context=None): @@ -86,7 +86,7 @@ class Builder: When the parsed parameters dictionary matches the kwargs for your class, you can create a valid delayed builder function with - .. code: + .. code:: builder = Builder('import_path.for_the.ClassToBuild') @@ -129,6 +129,25 @@ def __call__(self, **dct): class InstanceBuilder(OPSPlugin): + """ + + Parameters + ---------- + builder : Callable + Function that actually creates an instance of this object. Note that + the :class:`.Builder` class is often a useful tool here (but any + callable is allowed). + parameters : List[:class:`.Parameter] + Descriptions of the paramters for that the builder takes. + name : str + Name used in the text input for this object. + aliases : List[str] + Other names that can be used. + requires_ops : Tuple[int, int] + version of OpenPathSampling required for this functionality + requires_cli : Tuple[int, int] + version of the OPS CLI requires for this functionality + """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" parser_name = None error_on_duplicate = False # TODO: temporary @@ -174,6 +193,10 @@ def to_json_schema(self, schema_context=None): def parse_attrs(self, dct): """Parse the user input dictionary to mapping of name to object. + This changes the values in the key-value pairs we get from the file + into objects that are suitable as input to a function. Further + remapping of keys is performed by the builder. + Parameters ---------- dct: Dict @@ -182,8 +205,8 @@ def parse_attrs(self, dct): Returns ------- Dict : - Mapping with the keys relevant to the input dictionary, but - values are now appropriate inputs for the builder. + Mapping with the keys from the input dictionary, but values are + now appropriate inputs for the builder. """ # TODO: support aliases in dct[attr] input_dct = self.defaults.copy() diff --git a/paths_cli/tests/parsing/test_core.py b/paths_cli/tests/parsing/test_core.py index 3c2e27f9..754d6e69 100644 --- a/paths_cli/tests/parsing/test_core.py +++ b/paths_cli/tests/parsing/test_core.py @@ -1,8 +1,112 @@ import pytest +from unittest.mock import Mock, patch import numpy.testing as npt from paths_cli.parsing.core import * +class TestParameter: + def setup(self): + self.loader = Mock( + return_value='foo', + json_type='string', + description="string 'foo'", + ) + def test_parameter_info_in_loader(self): + # if parameter doesn't give json_type/description, but the + # loader does, then we return what the loader says + parameter = Parameter(name='foo_param', + loader=self.loader) + assert parameter.name == 'foo_param' + assert parameter.loader() == "foo" + assert parameter.json_type == "string" + assert parameter.description == "string 'foo'" + + def test_parameter_info_local(self): + # if parameter and loader both give json_type/description, then the + # value given by the parameter takes precendence + parameter = Parameter(name='foo_param', + loader=self.loader, + json_type='int', # it's a lie! + description='override') + assert parameter.name == 'foo_param' + assert parameter.loader() == "foo" + assert parameter.json_type == "int" + assert parameter.description == "override" + + def test_parameter_info_none(self): + # if neither parameter nor loader define json_type/description, then + # we should return None for those + parameter = Parameter(name="foo_param", + loader=lambda: "foo") + assert parameter.name == 'foo_param' + assert parameter.loader() == "foo" + assert parameter.json_type is None + assert parameter.description is None + + @pytest.mark.parametrize('is_required', [True, False]) + def test_required(self, is_required): + if is_required: + parameter = Parameter(name="foo_param", loader=self.loader) + else: + parameter = Parameter(name="foo_param", loader=self.loader, + default="bar") + + assert parameter.required == is_required + + def test_call(self): + # calling the parameter object should call its loader + # TODO: maybe check that we pass arguments along correctly? + parameter = Parameter(name="foo_param", loader=self.loader) + assert parameter() == "foo" + + def test_to_json_schema(self): + # to_json_schema should return the expected values + parameter = Parameter(name="foo_param", + loader=self.loader) + expected = {'type': 'string', + 'description': "string 'foo'"} + assert parameter.to_json_schema() == ("foo_param", expected) + + +class TestBuilder: + def test_with_remapper(self): + # a builder that includes a remapping should use the remapper before + # the builder callable + pytest.skip() + + def test_imported_builder(self): + # a builder that takes a string to define its builder callable + # should import that callable, and use it for its call method + pytest.skip() + + def test_callable_builder(self): + # a builder that takes a callable as its builder should use that for + # its call method + pytest.skip() + + def test_with_after_build(self): + # a builder with an after_build parameter should use that after the + # builder callable + pytest.skip() + +class TestInstanceBuilder: + def test_to_json_schema(self): + # to_json_schema should create a valid JSON schema entry for this + # instance builder + pytest.skip() + + def test_parse_attrs(self): + # parse_attrs should create a dictionary with correct objects in the + # attributes from the input dictionary + pytest.skip() + + def test_parse_attrs_missing_required(self): + # an InputError should be raised if a required parameter is missing + pytest.skip() + + def test_call(self): + # calling the instance builder should create the object + pytest.skip() From cb69db63c967563d39506f51a66098932914f0ff Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 Sep 2021 16:20:22 -0400 Subject: [PATCH 104/251] (most) tests for InstanceBuilder --- paths_cli/parsing/core.py | 6 +- paths_cli/tests/parsing/test_core.py | 124 +++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 8 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index f7b79016..2708c129 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -172,7 +172,7 @@ def schema_name(self): if not self.name.endswith(self.parser_name): schema_name = f"{self.name}-{self.object_type}" else: - schema_name = name + schema_name = self.parser_name return schema_name def to_json_schema(self, schema_context=None): @@ -225,6 +225,7 @@ def parse_attrs(self, dct): new_dct[attr] = func(input_dct[attr]) optionals = set(self.optional_attributes) & set(dct) + for attr in optionals: new_dct[attr] = self.optional_attributes[attr](dct[attr]) @@ -271,7 +272,8 @@ def _parse_dict(self, dct): def register_object(self, obj, name): if name is not None: if name in self.named_objs: - raise RuntimeError("Same name twice") # TODO improve + raise InputError(f"An object of type {self.name} and name " + f"{name} already exists.") obj = obj.named(name) self.named_objs[name] = obj obj = obj.named(name) diff --git a/paths_cli/tests/parsing/test_core.py b/paths_cli/tests/parsing/test_core.py index 754d6e69..c91a60e0 100644 --- a/paths_cli/tests/parsing/test_core.py +++ b/paths_cli/tests/parsing/test_core.py @@ -1,6 +1,8 @@ import pytest from unittest.mock import Mock, patch +import os + import numpy.testing as npt from paths_cli.parsing.core import * @@ -71,42 +73,152 @@ def test_to_json_schema(self): class TestBuilder: + @staticmethod + def _callable(string): + return "".join(reversed(string)) + def test_with_remapper(self): # a builder that includes a remapping should use the remapper before # the builder callable - pytest.skip() + def local_remapper(dct): + dct['string'] = dct['string'][:-1] + return dct + + builder = Builder(self._callable, remapper=local_remapper) + assert builder(string="foo") == "of" def test_imported_builder(self): # a builder that takes a string to define its builder callable # should import that callable, and use it for its call method - pytest.skip() + cwd = os.getcwd() + builder = Builder('os.getcwd') + assert builder() == cwd def test_callable_builder(self): # a builder that takes a callable as its builder should use that for # its call method - pytest.skip() + builder = Builder(self._callable) + assert builder(string="foo") == "oof" def test_with_after_build(self): # a builder with an after_build parameter should use that after the # builder callable - pytest.skip() + def local_after(obj, dct): + return obj[:-1] + dct['string'] + + builder = Builder(self._callable, after_build=local_after) + assert builder(string="foo") == "oofoo" class TestInstanceBuilder: + @staticmethod + def _builder(req_param, opt_default=10, opt_override=100): + return f"{req_param}, {opt_default}, {opt_override}" + + def setup(self): + identity = lambda x: x + self.parameters = [ + Parameter('req_param', identity, json_type="string"), + Parameter('opt_default', identity, json_type="int", default=10), + Parameter('opt_override', identity, json_type='int', + default=100) + ] + self.instance_builder = InstanceBuilder( + self._builder, + self.parameters, + name='demo', + aliases=['foo', 'bar'], + ) + self.instance_builder.parser_name = 'demo' + self.input_dict = {'req_param': "qux", 'opt_override': 25} + def test_to_json_schema(self): # to_json_schema should create a valid JSON schema entry for this # instance builder - pytest.skip() + # TODO: this may change as I better understand JSON schema. Details + # of the JSON schema API aren't locked until I can build docs from + # our schema. + expected_schema = { + 'properties': { + 'name': {'type': 'string'}, + 'type': {'type': 'string', + 'enum': ['demo']}, + 'req_param': {'type': 'string', 'description': None}, + 'opt_default': {'type': 'int', 'description': None}, + 'opt_override': {'type': 'int', 'description': None}, + }, + 'required': ['req_param'], + } + name, schema = self.instance_builder.to_json_schema() + assert name == 'demo' + assert expected_schema['required'] == schema['required'] + assert expected_schema['properties'] == schema['properties'] def test_parse_attrs(self): # parse_attrs should create a dictionary with correct objects in the # attributes from the input dictionary + expected = {'req_param': "qux", 'opt_override': 25} + # note that the parameter where we use the default value isn't + # listed: the default value should match the default used in the + # code, though! + assert self.instance_builder.parse_attrs(self.input_dict) == expected + + def test_parse_attrs_parser_integration(self): + # parse_attrs gives the same object as already existing in a parser + # if one of the parameters uses that parser to load a named object pytest.skip() def test_parse_attrs_missing_required(self): # an InputError should be raised if a required parameter is missing - pytest.skip() + input_dict = {'opt_override': 25} + with pytest.raises(InputError, match="missing required"): + self.instance_builder.parse_attrs(input_dict) def test_call(self): # calling the instance builder should create the object + expected = "qux, 10, 25" + assert self.instance_builder(self.input_dict) == expected + + +class TestParser: + def test_parse_str(self): + # parse_str should load a known object with the input name pytest.skip() + def test_parse_str_error(self): + # if parse_str is given a name that is not known, an InputError + # should be raised + pytest.skip() + + def test_parse_dict(self): + # parse_dct should create the object from the input dict + pytest.skip() + + def test_register_object_named(self): + # when registered, a named object should register with the all_objs + # list and with the named_objs dict + pytest.skip() + + def test_register_object_unnamed(self): + # when registered, an unnamed object should register with the + # all_objs list and leave the named_objs dict unchanged + pytest.skip() + + def test_register_object_duplicate(self): + # if an attempt is made to register an object with a name that is + # already in use, an InputError should be raised, and the object + # should not register with either all_objs or named_objs + pytest.skip() + + def test_register_builder(self): + pytest.skip() + + def test_register_builder_duplicate(self): + pytest.skip() + + @pytest.mark.parametrize('input_type', ['str', 'dict']) + def test_parse(self, input_type): + pytest.skip() + + @pytest.mark.parametrize('as_list', [True, False]) + def test_call(self, as_list): + pytest.skip() From eab9b4a087ecb7d0bdfbcda1dbb9f461c04cdce8 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 Sep 2021 17:34:15 -0400 Subject: [PATCH 105/251] more tests in parsing core --- paths_cli/parsing/core.py | 14 +++-- paths_cli/parsing/errors.py | 2 + paths_cli/tests/parsing/test_core.py | 93 +++++++++++++++++++++++++--- 3 files changed, 94 insertions(+), 15 deletions(-) diff --git a/paths_cli/parsing/core.py b/paths_cli/parsing/core.py index 2708c129..327cd366 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/parsing/core.py @@ -232,6 +232,8 @@ def parse_attrs(self, dct): return new_dct def __call__(self, dct): + # TODO: convert this to taking **dct -- I think that makes more + # sense ops_dct = self.parse_attrs(dct) self.logger.debug("Building...") self.logger.debug(ops_dct) @@ -258,7 +260,7 @@ def _parse_str(self, name): try: return self.named_objs[name] except KeyError as e: - raise e # TODO: replace with better error + raise InputError.unknown_name(self.label, name) def _parse_dict(self, dct): dct = dct.copy() # make a local copy @@ -272,23 +274,23 @@ def _parse_dict(self, dct): def register_object(self, obj, name): if name is not None: if name in self.named_objs: - raise InputError(f"An object of type {self.name} and name " + raise InputError(f"An object of type {self.label} and name " f"{name} already exists.") - obj = obj.named(name) self.named_objs[name] = obj - obj = obj.named(name) + obj = obj.named(name) self.all_objs.append(obj) return obj def register_builder(self, builder, name): if name in self.type_dispatch: - msg = (f"'{builder.name}' is already registered " + msg = (f"'{name}' is already registered " f"with {self.label}") if self.error_on_duplicate: raise RuntimeError(msg) else: warnings.warn(msg) - self.type_dispatch[name] = builder + else: + self.type_dispatch[name] = builder def parse(self, dct): if isinstance(dct, str): diff --git a/paths_cli/parsing/errors.py b/paths_cli/parsing/errors.py index ebac708b..c1d5ce62 100644 --- a/paths_cli/parsing/errors.py +++ b/paths_cli/parsing/errors.py @@ -1,4 +1,6 @@ class InputError(Exception): + """Raised when users provide bad input in files/interactive sessions. + """ @classmethod def invalid_input(cls, value, attr, type_name=None, name=None): msg = f"'{value}' is not a valid input for {attr}" diff --git a/paths_cli/tests/parsing/test_core.py b/paths_cli/tests/parsing/test_core.py index c91a60e0..f70faff5 100644 --- a/paths_cli/tests/parsing/test_core.py +++ b/paths_cli/tests/parsing/test_core.py @@ -7,6 +7,20 @@ from paths_cli.parsing.core import * +class MockNamedObject: + # used in the tests for Parser._parse_dict and Parser.register_object + def __init__(self, data): + self.data = data + self.name = None + + def named(self, name): + self.name = name + return self + +def mock_named_object_factory(dct): + return MockNamedObject(**dct) + + class TestParameter: def setup(self): self.loader = Mock( @@ -180,40 +194,101 @@ def test_call(self): class TestParser: + def setup(self): + self.parser = Parser( + {'foo': mock_named_object_factory}, + 'foo_parser' + ) + + def _mock_register_obj(self): + obj = "bar" + self.parser.all_objs.append(obj) + self.parser.named_objs['foo'] = obj + def test_parse_str(self): # parse_str should load a known object with the input name - pytest.skip() + self._mock_register_obj() + assert self.parser._parse_str('foo') == "bar" def test_parse_str_error(self): # if parse_str is given a name that is not known, an InputError # should be raised - pytest.skip() + self._mock_register_obj() + with pytest.raises(InputError, match="Unable to find"): + self.parser._parse_str('baz') - def test_parse_dict(self): + @pytest.mark.parametrize('named', [True, False]) + def test_parse_dict(self, named): # parse_dct should create the object from the input dict - pytest.skip() + input_dict = {'type': 'foo', 'data': "qux"} + if named: + input_dict['name'] = 'bar' + + obj = self.parser._parse_dict(input_dict) + assert obj.data == "qux" + name = {True: 'bar', False: None}[named] + assert obj.name == name def test_register_object_named(self): # when registered, a named object should register with the all_objs # list and with the named_objs dict - pytest.skip() + obj = MockNamedObject('foo') + assert obj.name is None + assert self.parser.all_objs == [] + assert self.parser.named_objs == {} + obj = self.parser.register_object(obj, 'bar') + assert obj.name == 'bar' + assert self.parser.all_objs == [obj] + assert self.parser.named_objs == {'bar': obj} def test_register_object_unnamed(self): # when registered, an unnamed object should register with the # all_objs list and leave the named_objs dict unchanged - pytest.skip() + obj = MockNamedObject('foo') + assert obj.name is None + assert self.parser.all_objs == [] + assert self.parser.named_objs == {} + obj = self.parser.register_object(obj, None) + assert obj.name is None + assert self.parser.all_objs == [obj] + assert self.parser.named_objs == {} def test_register_object_duplicate(self): # if an attempt is made to register an object with a name that is # already in use, an InputError should be raised, and the object # should not register with either all_objs or named_objs - pytest.skip() + obj = MockNamedObject('foo').named('bar') + self.parser.named_objs['bar'] = obj + self.parser.all_objs.append(obj) + obj2 = MockNamedObject('baz') + with pytest.raises(InputError, match="already exists"): + self.parser.register_object(obj2, 'bar') + + assert self.parser.named_objs == {'bar': obj} + assert self.parser.all_objs == [obj] + assert obj2.name is None def test_register_builder(self): - pytest.skip() + # a new builder can be registered and used, if it has a new name + assert len(self.parser.type_dispatch) == 1 + assert 'bar' not in self.parser.type_dispatch + self.parser.register_builder(lambda dct: 10, 'bar') + assert len(self.parser.type_dispatch) == 2 + assert 'bar' in self.parser.type_dispatch + input_dict = {'type': 'bar'} + assert self.parser(input_dict) == 10 def test_register_builder_duplicate(self): - pytest.skip() + # if an attempt is made to registered a builder with a name that is + # already in use, a RuntimeError is raised + orig = self.parser.type_dispatch['foo'] + # TODO: this should be an error; need to figure out how to avoid + # duplication + # with pytest.raises(RuntimeError, match="already registered"): + with pytest.warns(UserWarning, match="already registered"): + self.parser.register_builder(lambda dct: 10, 'foo') + + assert self.parser.type_dispatch['foo'] is orig @pytest.mark.parametrize('input_type', ['str', 'dict']) def test_parse(self, input_type): From 2bd0eea91931cd5cffc2b07f6cbc8ff0589772f8 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 Sep 2021 18:20:22 -0400 Subject: [PATCH 106/251] tests for helpers in compile command --- paths_cli/commands/compile.py | 30 +++++++++---- paths_cli/tests/commands/test_compile.py | 56 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 paths_cli/tests/commands/test_compile.py diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 346c7b84..7838e3b9 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -2,33 +2,45 @@ from paths_cli.parsing.root_parser import parse from paths_cli.parameters import OUTPUT_FILE +from paths_cli.errors import MissingIntegrationError +import importlib -def import_module(module_name): +def import_module(module_name, format_type=None, install=None): try: - mod = __import__(module_name) + mod = importlib.import_module(module_name) except ImportError: - # TODO: better error handling - raise + if format_type is None: + format_type = module_name + + msg = "Unable to find a parser for f{format_type} on your system." + if install is not None: + msg += " Please install f{install} to use this format." + + raise MissingIntegrationError(msg) return mod def load_yaml(f): - yaml = import_module('yaml') + yaml = import_module('yaml', format_type="YAML", install="PyYAML") return yaml.load(f.read(), Loader=yaml.FullLoader) def load_json(f): json = import_module('json') # this should never fail... std lib! return json.loads(f.read()) -def load_toml(f): - toml = import_module('toml') - return toml.loads(f.read()) +# TODO: It looks like TOML was working with my test dict -- I'll have to see +# if that's an issue with the TOML format or just something weird. I thought +# TOML was equally as flexible (in theory) as JSON. +# def load_toml(f): +# toml = import_module('toml', format_type="TOML", install="toml") +# return toml.loads(f.read()) + EXTENSIONS = { 'yaml': load_yaml, 'yml': load_yaml, 'json': load_json, 'jsn': load_json, - 'toml': load_toml, + # 'toml': load_toml, } def select_loader(filename): diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py new file mode 100644 index 00000000..bf2e778e --- /dev/null +++ b/paths_cli/tests/commands/test_compile.py @@ -0,0 +1,56 @@ +import pytest +from click.testing import CliRunner + +import json + +from paths_cli.commands.compile import * + +def test_import_module(): + new_json = import_module('json') + assert new_json is json + +def test_import_module_error(): + with pytest.raises(MissingIntegrationError, match="Unable to find"): + import_module('foobarbazqux') + +_BASIC_DICT = {'foo': ['bar', {'baz': 10}], 'qux': 'quux', 'froob': 0.5} + +def _std_dump(module): + return module.dump + +@pytest.mark.parametrize('mod_name', ['json', 'yaml']) +def test_loaders(mod_name, tmpdir): + mod = pytest.importorskip(mod_name) + dump_getter = { + 'json': _std_dump, + 'yaml': _std_dump, + 'toml': _std_dump, + }[mod_name] + loader = { + 'json': load_json, + 'yaml': load_yaml, + # 'toml': load_toml, + }[mod_name] + dump = dump_getter(mod) + filename = tmpdir.join("temp." + mod_name) + with open(filename, 'a+') as fp: + dump(_BASIC_DICT, fp) + fp.seek(0) + assert loader(fp) == _BASIC_DICT + +@pytest.mark.parametrize('ext', ['jsn', 'json', 'yaml', 'yml']) +def test_select_loader(ext): + if ext in ['jsn', 'json']: + expected = load_json + elif ext in ['yml', 'yaml']: + expected = load_yaml + + assert select_loader("filename." + ext) is expected + pass + +def test_select_loader_error(): + with pytest.raises(RuntimeError, match="Unknown file extension"): + select_loader('foo.bar') + +def test_compile(): + pass From f7cc88cef60f6871edfc6dd46fba6e2f0e65172f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 Sep 2021 23:57:17 -0400 Subject: [PATCH 107/251] steps toward testing compile command Currently, we don't seem to actually register the parsers, so it isn't working at all. But I think the basic outline of the test is in place. --- paths_cli/commands/compile.py | 10 +- paths_cli/tests/commands/conftest.py | 1 + paths_cli/tests/commands/test_compile.py | 30 +++++- paths_cli/tests/conftest.py | 8 ++ paths_cli/tests/testdata/setup.yml | 126 +++++++++++++++++++++++ paths_cli/tests/utils.py | 1 - paths_cli/tests/wizard/conftest.py | 2 + 7 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 paths_cli/tests/commands/conftest.py create mode 100644 paths_cli/tests/testdata/setup.yml diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 7838e3b9..54265225 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -3,6 +3,7 @@ from paths_cli.parsing.root_parser import parse from paths_cli.parameters import OUTPUT_FILE from paths_cli.errors import MissingIntegrationError +from paths_cli import OPSCommandPlugin import importlib def import_module(module_name, format_type=None, install=None): @@ -65,6 +66,9 @@ def compile_(input_file, output_file): storage = OUTPUT_FILE.get(output_file) storage.save(objs) - -CLI = compile_ -SECTION = "Debug" +PLUGIN = OPSCommandPlugin( + command=compile_, + section="Debug", + requires_ops=(1, 0), + requires_cli=(0, 3) +) diff --git a/paths_cli/tests/commands/conftest.py b/paths_cli/tests/commands/conftest.py new file mode 100644 index 00000000..85217936 --- /dev/null +++ b/paths_cli/tests/commands/conftest.py @@ -0,0 +1 @@ +from paths_cli.tests.wizard.conftest import ad_openmm diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index bf2e778e..6287410a 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -2,6 +2,7 @@ from click.testing import CliRunner import json +import shutil from paths_cli.commands.compile import * @@ -46,11 +47,34 @@ def test_select_loader(ext): expected = load_yaml assert select_loader("filename." + ext) is expected - pass def test_select_loader_error(): with pytest.raises(RuntimeError, match="Unknown file extension"): select_loader('foo.bar') -def test_compile(): - pass +def test_compile(ad_openmm, test_data_dir): + runner = CliRunner() + setup = test_data_dir / "setup.yml" + shutil.copy2(str(setup), ad_openmm) + with runner.isolated_filesystem(temp_dir=ad_openmm): + import os + cwd = os.getcwd() + files = [setup, ad_openmm / "ad.pdb", ad_openmm / "system.xml", + ad_openmm / "integrator.xml"] + for filename in files: + shutil.copy2(str(filename), cwd) + pytest.skip() # TODO: right now we're not building the parsers + result = runner.invoke( + compile_, + ['setup.yml', '-o', str(ad_openmm / 'setup.db')] + ) + assert result.exit_code == 0 + from openpathsampling.experimental.storage import ( + Storage, monkey_patch_all) + st = Storage('setup.db', mode='r') + + # smoke tests that we can reload things + engine = st.engines['engine'] + phi = st.cvs['phi'] + C_7eq = st.volumes['C_7eq'] + pytest.skip() diff --git a/paths_cli/tests/conftest.py b/paths_cli/tests/conftest.py index 9a44610a..326168c3 100644 --- a/paths_cli/tests/conftest.py +++ b/paths_cli/tests/conftest.py @@ -3,6 +3,14 @@ import openpathsampling as paths import pytest +import pathlib + +@pytest.fixture +def test_data_dir(): + tests = pathlib.Path(__file__).parent / "testdata" + return tests + + @pytest.fixture def flat_engine(): pes = toys.LinearSlope([0, 0, 0], 0) diff --git a/paths_cli/tests/testdata/setup.yml b/paths_cli/tests/testdata/setup.yml new file mode 100644 index 00000000..5a77b05c --- /dev/null +++ b/paths_cli/tests/testdata/setup.yml @@ -0,0 +1,126 @@ +engines: + - type: openmm + name: engine + system: system.xml + integrator: integrator.xml + topology: ad.pdb + n_steps_per_frame: 10 + n_frames_max: 10000 + +cvs: + - name: phi + type: mdtraj + topology: ad.pdb + period_min: -np.pi + period_max: np.pi + func: compute_dihedrals + kwargs: + atom_indices: [[4, 6, 8, 14]] + - name: psi + type: mdtraj + topology: ad.pdb + period_min: -np.pi + period_max: np.pi + func: compute_dihedrals + kwargs: + atom_indices: [[6, 8, 14, 16]] + +states: + - name: alpha_R + type: intersection + subvolumes: + - type: cv-volume + cv: psi + lambda_min: -100 * np.pi / 180 + lambda_max: 0.0 + - type: cv-volume + cv: phi + lambda_min: -np.pi + lambda_max: 0 + - name: C_7eq + type: intersection + subvolumes: + - type: cv-volume + cv: psi + lambda_min: 100 * np.pi / 180 + lambda_max: 200 * np.pi / 180 + - type: cv-volume + cv: phi + lambda_min: -np.pi + lambda_max: 0 + +#simulations: + #- type: tps + #name: tps-sim + #initial_states: + #- C_7eq + #final_states: + #- alpha_R + ##movescheme: # default scheme; doesn't need to be explicit + ##strategies: + ##- type: one-way-shooting + ##group: shooting + ##selector: + ##type: uniform + #- type: tis + #name: tis-sim + #initial_state: + #- alpha_R + #final_state: + #- C_7eq + #interface_set: + #cv: psi + #min_lambdas: -100.0 * np.pi / 180 + #max_lambdas: np.array([0.0, 25.0, 50.0]) * np.pi / 180 + +networks: + - type: tps + name: tps-network + initial_states: + - C_7eq + final_states: + - alpha_R + + #- type: tis + #name: tis-network + #initial_state: C_7eq + #final_state: alpha_R + #interface_set: + #cv: psi + #min_lambdas: -100.0 * np.pi / 180 + #max_lambdas: np.array([0.0, 25.0, 50.0]) * np.pi / 180 + + #- type: mistis + #name: mistis-network + #transitions: + #- initial_state: C_7eq + #final_state: alpha_R + #interface_set: + #cv: psi + #min_lambdas: -100.0 * np.pi / 180 + #max_lambdas: np.array([0.0, 25.0, 50.0]) * np.pi / 180 + + #- type: mstis + #name: mstis-network + #transitions: + #- initial_state: C_7eq + #interface_set: + #cv: psi + #min_lambdas: -100.0 * np.pi / 180 + #max_lambdas: np.array([0.0, 25.0, 50.0]) * np.pi / 180 + #- initial_state: alpha_R + #interface_set: + #cv: psi + ## TODO need a negative psi here or allow decreasing transitions + + +#moveschemes: + #- type: scheme + #name: tps-sim + #network: tps-network + #strategies: + #- type: one-way + #selector: + #type: uniform + #engine: engine + diff --git a/paths_cli/tests/utils.py b/paths_cli/tests/utils.py index 014cdd62..7dbcf247 100644 --- a/paths_cli/tests/utils.py +++ b/paths_cli/tests/utils.py @@ -16,4 +16,3 @@ def assert_url(url): # nice to give some better output to the user here. resp = urllib.request.urlopen(url) assert resp.status == 200 - diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index 89070565..1e240976 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -18,6 +18,8 @@ else: HAS_OPENMM = True +# TODO: this isn't wizard-specific, and should be moved somwhere more +# generally useful (like, oh, maybe openpathsampling.tests.fixtures?) @pytest.fixture def ad_openmm(tmpdir): """ From b64ea30aa9b503c4ba374c95c3c362635d13e073 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 11:16:00 -0400 Subject: [PATCH 108/251] major refactor of root_paser also steps toward the compile smoke test --- paths_cli/commands/compile.py | 35 +++-- paths_cli/parsing/engines.py | 4 - paths_cli/parsing/networks.py | 11 +- paths_cli/parsing/root_parser.py | 136 +++++++++++++++----- paths_cli/parsing/volumes.py | 15 ++- paths_cli/tests/commands/test_compile.py | 20 ++- paths_cli/tests/parsing/test_networks.py | 6 +- paths_cli/tests/parsing/test_root_parser.py | 55 ++++++-- paths_cli/tests/parsing/test_shooting.py | 2 +- paths_cli/tests/parsing/test_topology.py | 4 +- paths_cli/tests/parsing/test_volumes.py | 4 +- paths_cli/utils.py | 9 +- 12 files changed, 221 insertions(+), 80 deletions(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 54265225..36e6e3cc 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -1,10 +1,15 @@ import click -from paths_cli.parsing.root_parser import parse +from paths_cli.parsing.root_parser import parse, register_plugins from paths_cli.parameters import OUTPUT_FILE from paths_cli.errors import MissingIntegrationError from paths_cli import OPSCommandPlugin +from paths_cli.parsing.plugins import ParserPlugin, InstanceBuilder +from paths_cli.plugin_management import ( + NamespacePluginLoader, FilePluginLoader +) import importlib +from paths_cli.utils import app_dir_plugins def import_module(module_name, format_type=None, install=None): try: @@ -28,13 +33,11 @@ def load_json(f): json = import_module('json') # this should never fail... std lib! return json.loads(f.read()) -# TODO: It looks like TOML was working with my test dict -- I'll have to see -# if that's an issue with the TOML format or just something weird. I thought -# TOML was equally as flexible (in theory) as JSON. -# def load_toml(f): -# toml = import_module('toml', format_type="TOML", install="toml") -# return toml.loads(f.read()) - +# This is why we can't use TOML: +# https://github.com/toml-lang/toml/issues/553#issuecomment-444814690 +# Conflicts with rules preventing mixed types in arrays. This seems to have +# relaxed in TOML 1.0, but the toml package doesn't support the 1.0 +# standard. We'll add toml in once the pacakge supports the standard. EXTENSIONS = { 'yaml': load_yaml, @@ -51,6 +54,17 @@ def select_loader(filename): except KeyError: raise RuntimeError(f"Unknown file extension: {ext}") +def load_plugins(): + plugin_types = (InstanceBuilder, ParserPlugin) + plugin_loaders = [ + NamespacePluginLoader('paths_cli.parsing', plugin_types), + FilePluginLoader(app_dir_plugins(posix=False), plugin_types), + FilePluginLoader(app_dir_plugins(posix=True), plugin_types), + NamespacePluginLoader('paths_cli_plugins', plugin_types) + ] + plugins = sum([loader() for loader in plugin_loaders], []) + return plugins + @click.command( 'compile', ) @@ -61,8 +75,11 @@ def compile_(input_file, output_file): with open(input_file, mode='r') as f: dct = loader(f) + plugins = load_plugins() + register_plugins(plugins) + objs = parse(dct) - # print(objs) + print(objs) storage = OUTPUT_FILE.get(output_file) storage.save(objs) diff --git a/paths_cli/parsing/engines.py b/paths_cli/parsing/engines.py index dccc7bea..33ed6811 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/parsing/engines.py @@ -53,8 +53,4 @@ def openmm_options(dct): name='openmm', ) -TYPE_MAPPING = { - 'openmm': OPENMM_PLUGIN, -} - ENGINE_PARSER = ParserPlugin(EngineParserPlugin, aliases=['engines']) diff --git a/paths_cli/parsing/networks.py b/paths_cli/parsing/networks.py index 7c977c9a..e239db9e 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/parsing/networks.py @@ -42,7 +42,7 @@ def tis_trans_info(dct): 'interfaces': interface_set}] return mistis_trans_info(dct) -build_tps_network = NetworkParserPlugin( +TPS_NETWORK_PLUGIN = NetworkParserPlugin( builder=Builder('openpathsampling.TPSNetwork'), parameters=[ Parameter('initial_states', parser_for('volume'), @@ -53,16 +53,21 @@ def tis_trans_info(dct): name='tps' ) -build_mistis_network = NetworkParserPlugin( +build_tps_network = TPS_NETWORK_PLUGIN + +MISTIS_NETWORK_PLUGIN = NetworkParserPlugin( parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), name='mistis' ) +build_mistis_network = MISTIS_NETWORK_PLUGIN -build_tis_network = NetworkParserPlugin( +TIS_NETWORK_PLUGIN = NetworkParserPlugin( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], name='tis' ) +build_tis_network = TIS_NETWORK_PLUGIN + NETWORK_PARSER = ParserPlugin(NetworkParserPlugin, aliases=['networks']) diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/parsing/root_parser.py index 69d4ffc5..c16772fd 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/parsing/root_parser.py @@ -1,7 +1,14 @@ from paths_cli.parsing.core import Parser, InstanceBuilder from paths_cli.parsing.plugins import ParserPlugin +import logging +logger = logging.getLogger(__name__) +class ParserRegistrationError(Exception): + pass + + +# TODO: I think this is the only OPS-specific thing in here _DEFAULT_PARSE_ORDER = [ 'engine', 'cv', @@ -13,16 +20,80 @@ PARSE_ORDER = _DEFAULT_PARSE_ORDER.copy() -PARSERS = {} -ALIASES = {} +def clean_input_key(key): + # TODO: move this to core + """ + Canonical approach is to treat everything as lowercase with underscore + separators. This will make everything lowercase, and convert + whitespace/hyphens to underscores. + """ + key = key.lower() + key = "_".join(key.split()) # whitespace to underscore + key.replace("-", "_") + return key + +### Managing known parsers and aliases to the known parsers ################ + +_PARSERS = {} # mapping: {canonical_name: Parser} +_ALIASES = {} # mapping: {alias: canonical_name} +# NOTE: _ALIASES does *not* include self-mapping of the canonical names + +def _canonical_name(alias): + """Take an alias or a parser name and return the parser name + + This also cleans user input (using the canonical form generated by + :meth:`.clean_input_key`). + """ + alias = clean_input_key(alias) + alias_to_canonical = _ALIASES.copy() + alias_to_canonical.update({pname: pname for pname in _PARSERS}) + return alias_to_canonical.get(alias, None) + +def _get_parser(parser_name): + """ + _get_parser must only be used after the parsers have been registered. It + will automatically create a parser for any unknown ``parser_name.`` + """ + canonical_name = _canonical_name(parser_name) + # create a new parser if none exists + if canonical_name is None: + canonical_name = parser_name + _PARSERS[parser_name] = Parser(None, parser_name) + return _PARSERS[canonical_name] + +def _register_parser_plugin(plugin): + DUPLICATE_ERROR = RuntimeError(f"The name {plugin.name} has been " + "reserved by another parser") + if plugin.name in _PARSERS: + raise DUPLICATE_ERROR + + parser = _get_parser(plugin.name) + + # register aliases + new_aliases = set(plugin.aliases) - set([plugin.name]) + for alias in new_aliases: + if alias in _PARSERS or alias in _ALIASES: + raise DUPLICATE_ERROR + _ALIASES[alias] = plugin.name + -class ParserProxy: +### Handling delayed loading of parsers #################################### +# +# Many objects need to use parsers to create their input parameters. In +# order for them to be able to access dynamically-loaded plugins, we delay +# the loading of the parser by using a proxy object. + +class _ParserProxy: def __init__(self, parser_name): self.parser_name = parser_name @property def _proxy(self): - return PARSERS[self.parser_name] + canonical_name = _canonical_name(self.parser_name) + if canonical_name is None: + raise RuntimeError("No parser registered for " + f"'{self.parser_name}'") + return _get_parser(canonical_name) @property def named_objs(self): @@ -41,18 +112,10 @@ def parser_for(parser_name): parser_name : str the name of the parser to use """ - return ParserProxy(parser_name) + return _ParserProxy(parser_name) -def _get_parser(parser_name): - """ - _get_parser must only be used after the parsers have been registered. It - will automatically create a parser for any unknown ``parser_name.`` - """ - parser = PARSERS.get(parser_name, None) - if parser is None: - parser = Parser(None, parser_name) - PARSERS[parser_name] = parser - return parser + +### Registering builder plugins and user-facing register_plugins ########### def _get_registration_names(plugin): """This is a trick to ensure that the names appear in the desired order. @@ -70,6 +133,11 @@ def _get_registration_names(plugin): found_names.add(name) return ordered_names +def _register_builder_plugin(plugin): + parser = _get_parser(plugin.parser_name) + for name in _get_registration_names(plugin): + parser.register_builder(plugin, name) + def register_plugins(plugins): builders = [] parsers = [] @@ -85,31 +153,31 @@ def register_plugins(plugins): for plugin in builders: _register_builder_plugin(plugin) -def _register_builder_plugin(plugin): - parser = _get_parser(plugin.parser_name) - for name in _get_registration_names(plugin): - parser.register_builder(plugin, name) - -def _register_parser_plugin(plugin): - DUPLICATE_ERROR = RuntimeError(f"The name {plugin.name} has been " - "reserved by another parser") - if plugin.name in PARSERS: - raise DUPLICATE_ERROR +### Performing the parsing of user input ################################### - parser = _get_parser(plugin.name) +def _sort_user_categories(user_categories): + """Organize user input categories into parse order. - # register aliases - new_aliases = set(plugin.aliases) - set([plugin.name]) - for alias in new_aliases: - if alias in PARSERS or alias in ALIASES: - raise DUPLICATE_ERROR - ALIASES[alias] = plugin.name + "Cateogories" are the first-level keys in the user input file (e.g., + 'engines', 'cvs', etc.) There must be one Parser per category. + """ + user_to_canonical = {user_key: _canonical_name(user_key) + for user_key in user_categories} + sorted_keys = sorted( + user_categories, + key=lambda x: PARSE_ORDER.index(user_to_canonical[x]) + ) + return sorted_keys def parse(dct): + """Main function for compiling user input to objects. + """ objs = [] - for category in PARSE_ORDER: - func = PARSERS[category] + for category in _sort_user_categories(dct): + # func = PARSERS[category] + func = _get_parser(category) yaml_objs = dct.get(category, []) + print(f"{yaml_objs}") new = [func(obj) for obj in yaml_objs] objs.extend(new) return objs diff --git a/paths_cli/parsing/volumes.py b/paths_cli/parsing/volumes.py index e9e40bc4..e1050243 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/parsing/volumes.py @@ -18,7 +18,7 @@ def cv_volume_build_func(**dct): # TODO: wrap this with some logging return builder(**dct) -build_cv_volume = VolumeParserPlugin( +CV_VOLUME_PLUGIN = VolumeParserPlugin( builder=cv_volume_build_func, parameters=[ Parameter('cv', parser_for('cv'), @@ -31,13 +31,15 @@ def cv_volume_build_func(**dct): name='cv-volume', ) +build_cv_volume = CV_VOLUME_PLUGIN + # jsonschema type for combination volumes VOL_ARRAY_TYPE = { 'type': 'array', 'items': {"$ref": "#/definitions/volume_type"} } -build_intersection_volume = VolumeParserPlugin( +INTERSECTION_VOLUME_PLUGIN = VolumeParserPlugin( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), parameters=[ @@ -48,7 +50,9 @@ def cv_volume_build_func(**dct): name='intersection', ) -build_union_volume = VolumeParserPlugin( +build_intersection_volume = INTERSECTION_VOLUME_PLUGIN + +UNION_VOLUME_PLUGIN = VolumeParserPlugin( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), parameters=[ @@ -59,7 +63,4 @@ def cv_volume_build_func(**dct): name='union', ) -TYPE_MAPPING = { - 'cv-volume': build_cv_volume, - 'intersection': build_intersection_volume, -} +build_union_volume = UNION_VOLUME_PLUGIN diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 6287410a..548732c9 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -53,6 +53,9 @@ def test_select_loader_error(): select_loader('foo.bar') def test_compile(ad_openmm, test_data_dir): + # this is a smoke test to check that, given a correct YAML file, we can + # compile the yaml into a db file, and then reload (some of) the saves + # objects from the db. runner = CliRunner() setup = test_data_dir / "setup.yml" shutil.copy2(str(setup), ad_openmm) @@ -63,18 +66,29 @@ def test_compile(ad_openmm, test_data_dir): ad_openmm / "integrator.xml"] for filename in files: shutil.copy2(str(filename), cwd) - pytest.skip() # TODO: right now we're not building the parsers + pytest.skip() # TODO: parser aliases don't work yet result = runner.invoke( compile_, ['setup.yml', '-o', str(ad_openmm / 'setup.db')] ) + if result.exc_info: # -no-cov- + # this only runs in the event of an error (to provide a clearer + # failure) (TODO: change this up a little maybe? re-raise the + # error?) + import traceback + traceback.print_tb(result.exc_info[2]) + print(result.exception) + print(result.exc_info) assert result.exit_code == 0 + print(result.output) + assert os.path.exists(str(ad_openmm / 'setup.db')) from openpathsampling.experimental.storage import ( Storage, monkey_patch_all) - st = Storage('setup.db', mode='r') + # TODO: need to do the temporary monkey patch here + st = Storage(str(ad_openmm / 'setup.db'), mode='r') # smoke tests that we can reload things + print("Engines:", len(st.engines), [e.name for e in st.engines]) engine = st.engines['engine'] phi = st.cvs['phi'] C_7eq = st.volumes['C_7eq'] - pytest.skip() diff --git a/paths_cli/tests/parsing/test_networks.py b/paths_cli/tests/parsing/test_networks.py index 533fe705..9aef3eba 100644 --- a/paths_cli/tests/parsing/test_networks.py +++ b/paths_cli/tests/parsing/test_networks.py @@ -46,7 +46,7 @@ def test_mistis_trans_info(cv_and_states): "A": state_A, "B": state_B }), } - with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): + with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): results = mistis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -71,7 +71,7 @@ def test_tis_trans_info(cv_and_states): "A": state_A, "B": state_B }), } - with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): + with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): results = tis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -86,7 +86,7 @@ def test_build_tps_network(cv_and_states): 'volume': mock_parser('volume', named_objs={"A": state_A, "B": state_B}), } - with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): + with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): network = build_tps_network(dct) assert isinstance(network, paths.TPSNetwork) assert len(network.initial_states) == len(network.final_states) == 1 diff --git a/paths_cli/tests/parsing/test_root_parser.py b/paths_cli/tests/parsing/test_root_parser.py index 3252b36f..ba2dba06 100644 --- a/paths_cli/tests/parsing/test_root_parser.py +++ b/paths_cli/tests/parsing/test_root_parser.py @@ -1,8 +1,10 @@ import pytest +import paths_cli from paths_cli.parsing.root_parser import * from paths_cli.parsing.root_parser import ( _get_parser, _get_registration_names, _register_builder_plugin, - _register_parser_plugin + _register_parser_plugin, _sort_user_categories, _ParserProxy, _PARSERS, + _ALIASES ) from unittest.mock import Mock, PropertyMock, patch from paths_cli.parsing.core import Parser, InstanceBuilder @@ -27,16 +29,22 @@ def foo_baz_builder_plugin(): ### CONSTANTS ############################################################## -PARSER_LOC = "paths_cli.parsing.root_parser.PARSERS" +PARSER_LOC = "paths_cli.parsing.root_parser._PARSERS" +BASE = "paths_cli.parsing.root_parser." ### TESTS ################################################################## +def clean_input_key(): + pytest.skip() + +def test_canonical_name(): + pytest.skip() + class TestParserProxy: def setup(self): self.parser = Parser(None, "foo") self.parser.named_objs['bar'] = 'baz' - self.proxy = ParserProxy('foo') - pass + self.proxy = _ParserProxy('foo') def test_proxy(self): # (NOT API) the _proxy should be the registered parser @@ -45,7 +53,7 @@ def test_proxy(self): def test_proxy_nonexisting(self): # _proxy should error if the no parser is registered - with pytest.raises(KeyError): + with pytest.raises(RuntimeError, match="No parser registered"): self.proxy._proxy def test_named_objs(self): @@ -65,7 +73,7 @@ def test_parser_for_nonexisting(): assert 'foo' not in parsers proxy = parser_for('foo') assert 'foo' not in parsers - with pytest.raises(KeyError): + with pytest.raises(RuntimeError, match="No parser registered"): proxy._proxy def test_parser_for_existing(foo_parser): @@ -80,6 +88,11 @@ def test_parser_for_registered(): # should use that as its proxy pytest.skip() +def test_parser_for_registered_alias(): + # if parser_for is registered as an alias, parser_for should still get + # the correct parser + pytest.skip() + def test_get_parser_existing(foo_parser): # if a parser has been registered, then _get_parser should return the # registered parser @@ -93,7 +106,7 @@ def test_get_parser_nonexisting(foo_parser): parser = _get_parser('foo') assert parser is not foo_parser # overkill assert parser.label == 'foo' - assert 'foo' in PARSERS + assert 'foo' in _PARSERS @pytest.mark.parametrize('canonical,aliases,expected', [ ('foo', ['bar', 'baz'], ['foo', 'bar', 'baz']), @@ -114,8 +127,10 @@ def test_register_parser_plugin(foo_parser_plugin): with patch.dict(PARSER_LOC, parsers): assert 'foo' not in parsers _register_parser_plugin(foo_parser_plugin) - assert 'foo' in PARSERS - assert 'bar' in ALIASES + assert 'foo' in _PARSERS + assert 'bar' in _ALIASES + + assert 'foo' not in _PARSERS def test_register_parser_plugin_duplicate(): # if a parser of the same name exists either in canonical or aliases, @@ -130,8 +145,6 @@ def test_register_builder_plugin(): def test_register_plugins_unit(foo_parser_plugin, foo_baz_builder_plugin): # register_plugins should correctly sort builder and parser plugins, and # call the correct registration functions - BASE = "paths_cli.parsing.root_parser." - with patch(BASE + "_register_builder_plugin", Mock()) as builder, \ patch(BASE + "_register_parser_plugin", Mock()) as parser: register_plugins([foo_baz_builder_plugin, foo_parser_plugin]) @@ -142,6 +155,26 @@ def test_register_plugins_integration(): # register_plugins should correctly register plugins pytest.skip() +def test_sort_user_categories(): + # sorted user categories should match the expected parse order + aliases = {'quux': 'qux'} + # values for parsers and user_input shouldn't matter, but that's in + # implementation detail that might change + parsers = {'foo': "FOO", 'baz': "BAZ", 'bar': "BAR", 'qux': "QUX"} + user_input = {'baz': "Baz", 'quux': "Qux", 'foo': "Foo", 'qux': "Qux"} + order = ['foo', 'bar', 'baz', 'qux'] + expected = ['foo', 'baz', 'quux', 'qux'] + + try: + paths_cli.parsing.root_parser.PARSE_ORDER = order + with patch.dict(PARSER_LOC, parsers) as _parser, \ + patch.dict(BASE + "_ALIASES", aliases) as _alias: + assert _sort_user_categories(user_input) == expected + finally: + paths_cli.parsing.root_parser.PARSE_ORDER = PARSE_ORDER + # check that we unset properly (test the test) + assert paths_cli.parsing.root_parser.PARSE_ORDER[0] == 'engine' + def test_parse(): # parser should correctly parse a basic input dict pytest.skip() diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/parsing/test_shooting.py index 5cca9038..f81235f3 100644 --- a/paths_cli/tests/parsing/test_shooting.py +++ b/paths_cli/tests/parsing/test_shooting.py @@ -18,7 +18,7 @@ def test_build_gaussian_selector(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': 'x', 'mean': 1.0, 'stddev': 2.0} parser = {'cv': mock_parser('cv', named_objs={'x': cv})} - with patch.dict('paths_cli.parsing.root_parser.PARSERS', parser): + with patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): sel = build_gaussian_selector(dct) assert isinstance(sel, paths.GaussianBiasSelector) diff --git a/paths_cli/tests/parsing/test_topology.py b/paths_cli/tests/parsing/test_topology.py index e33e94ef..26a19c26 100644 --- a/paths_cli/tests/parsing/test_topology.py +++ b/paths_cli/tests/parsing/test_topology.py @@ -16,7 +16,7 @@ def test_build_topology_file(self): assert topology.n_atoms == 1651 def test_build_topology_engine(self, flat_engine): - patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + patch_loc = 'paths_cli.parsing.root_parser._PARSERS' parser = mock_parser('engine', named_objs={'flat': flat_engine}) parsers = {'engine': parser} with patch.dict(patch_loc, parsers): @@ -25,7 +25,7 @@ def test_build_topology_engine(self, flat_engine): assert topology.n_atoms == 1 def test_build_topology_fail(self): - patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + patch_loc = 'paths_cli.parsing.root_parser._PARSERS' parsers = {'engine': mock_parser('engine')} with patch.dict(patch_loc, parsers): with pytest.raises(InputError): diff --git a/paths_cli/tests/parsing/test_volumes.py b/paths_cli/tests/parsing/test_volumes.py index 8e931784..309000e1 100644 --- a/paths_cli/tests/parsing/test_volumes.py +++ b/paths_cli/tests/parsing/test_volumes.py @@ -41,7 +41,7 @@ def test_build_cv_volume(self, inline, periodic): yml = self.yml.format(func=self.func[inline]) dct = yaml.load(yml, Loader=yaml.FullLoader) if inline =='external': - patch_loc = 'paths_cli.parsing.root_parser.PARSERS' + patch_loc = 'paths_cli.parsing.root_parser._PARSERS' parsers = { 'cv': mock_parser('cv', named_objs={'foo': self.mock_cv}) } @@ -107,7 +107,7 @@ def test_build_combo_volume(self, combo, inline): named_objs=named_volumes_dict ), } - with mock.patch.dict('paths_cli.parsing.root_parser.PARSERS', + with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): vol = builder(dct) diff --git a/paths_cli/utils.py b/paths_cli/utils.py index f2eb25a2..c47f26d7 100644 --- a/paths_cli/utils.py +++ b/paths_cli/utils.py @@ -1,5 +1,6 @@ import importlib - +import pathlib +import click def tag_final_result(result, storage, tag='final_conditions'): """Save results to a tag in storage. @@ -24,3 +25,9 @@ def import_thing(module, obj=None): if obj is not None: result = getattr(result, obj) return result + + +def app_dir_plugins(posix): + return str(pathlib.Path( + click.get_app_dir("OpenPathSampling", force_posix=posix) + ).resolve() / 'cli-plugins') From d77b8aade5710647fdcc4a2471914424acdfc0ae Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 12:13:19 -0400 Subject: [PATCH 109/251] Major rename: parsing => compiling --- paths_cli/commands/compile.py | 12 +- paths_cli/{parsing => compiling}/__init__.py | 0 paths_cli/{parsing => compiling}/core.py | 36 ++-- paths_cli/{parsing => compiling}/cvs.py | 10 +- paths_cli/{parsing => compiling}/engines.py | 10 +- paths_cli/{parsing => compiling}/errors.py | 0 paths_cli/{parsing => compiling}/networks.py | 30 +-- paths_cli/compiling/plugins.py | 38 ++++ .../root_compiler.py} | 105 +++++----- paths_cli/{parsing => compiling}/schemes.py | 28 +-- paths_cli/{parsing => compiling}/shooting.py | 12 +- .../{parsing => compiling}/strategies.py | 33 ++-- paths_cli/{parsing => compiling}/tools.py | 0 paths_cli/{parsing => compiling}/topology.py | 10 +- paths_cli/{parsing => compiling}/volumes.py | 18 +- paths_cli/parsing/plugins.py | 38 ---- paths_cli/tests/commands/test_compile.py | 2 +- .../tests/{parsing => compiling}/__init__.py | 0 .../tests/{parsing => compiling}/test_core.py | 105 +++++----- .../tests/{parsing => compiling}/test_cvs.py | 4 +- .../{parsing => compiling}/test_engines.py | 4 +- .../{parsing => compiling}/test_networks.py | 30 +-- .../tests/compiling/test_root_compiler.py | 180 ++++++++++++++++++ .../{parsing => compiling}/test_shooting.py | 10 +- .../{parsing => compiling}/test_tools.py | 2 +- .../{parsing => compiling}/test_topology.py | 22 +-- .../{parsing => compiling}/test_volumes.py | 25 +-- paths_cli/tests/compiling/utils.py | 9 + paths_cli/tests/parsing/test_root_parser.py | 180 ------------------ paths_cli/tests/parsing/utils.py | 9 - paths_cli/wizard/cvs.py | 2 +- paths_cli/wizard/shooting.py | 2 +- paths_cli/wizard/wizard.py | 2 +- 33 files changed, 490 insertions(+), 478 deletions(-) rename paths_cli/{parsing => compiling}/__init__.py (100%) rename paths_cli/{parsing => compiling}/core.py (91%) rename paths_cli/{parsing => compiling}/cvs.py (86%) rename paths_cli/{parsing => compiling}/engines.py (85%) rename paths_cli/{parsing => compiling}/errors.py (100%) rename paths_cli/{parsing => compiling}/networks.py (66%) create mode 100644 paths_cli/compiling/plugins.py rename paths_cli/{parsing/root_parser.py => compiling/root_compiler.py} (56%) rename paths_cli/{parsing => compiling}/schemes.py (65%) rename paths_cli/{parsing => compiling}/shooting.py (73%) rename paths_cli/{parsing => compiling}/strategies.py (72%) rename paths_cli/{parsing => compiling}/tools.py (100%) rename paths_cli/{parsing => compiling}/topology.py (84%) rename paths_cli/{parsing => compiling}/volumes.py (78%) delete mode 100644 paths_cli/parsing/plugins.py rename paths_cli/tests/{parsing => compiling}/__init__.py (100%) rename paths_cli/tests/{parsing => compiling}/test_core.py (76%) rename paths_cli/tests/{parsing => compiling}/test_cvs.py (94%) rename paths_cli/tests/{parsing => compiling}/test_engines.py (96%) rename paths_cli/tests/{parsing => compiling}/test_networks.py (77%) create mode 100644 paths_cli/tests/compiling/test_root_compiler.py rename paths_cli/tests/{parsing => compiling}/test_shooting.py (76%) rename paths_cli/tests/{parsing => compiling}/test_tools.py (91%) rename paths_cli/tests/{parsing => compiling}/test_topology.py (53%) rename paths_cli/tests/{parsing => compiling}/test_volumes.py (85%) create mode 100644 paths_cli/tests/compiling/utils.py delete mode 100644 paths_cli/tests/parsing/test_root_parser.py delete mode 100644 paths_cli/tests/parsing/utils.py diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 36e6e3cc..975d8e58 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -1,10 +1,10 @@ import click -from paths_cli.parsing.root_parser import parse, register_plugins +from paths_cli.compiling.root_compiler import do_compile, register_plugins from paths_cli.parameters import OUTPUT_FILE from paths_cli.errors import MissingIntegrationError from paths_cli import OPSCommandPlugin -from paths_cli.parsing.plugins import ParserPlugin, InstanceBuilder +from paths_cli.compiling.plugins import CompilerPlugin, InstanceBuilder from paths_cli.plugin_management import ( NamespacePluginLoader, FilePluginLoader ) @@ -18,7 +18,7 @@ def import_module(module_name, format_type=None, install=None): if format_type is None: format_type = module_name - msg = "Unable to find a parser for f{format_type} on your system." + msg = "Unable to find a compiler for f{format_type} on your system." if install is not None: msg += " Please install f{install} to use this format." @@ -55,9 +55,9 @@ def select_loader(filename): raise RuntimeError(f"Unknown file extension: {ext}") def load_plugins(): - plugin_types = (InstanceBuilder, ParserPlugin) + plugin_types = (InstanceBuilder, CompilerPlugin) plugin_loaders = [ - NamespacePluginLoader('paths_cli.parsing', plugin_types), + NamespacePluginLoader('paths_cli.compiling', plugin_types), FilePluginLoader(app_dir_plugins(posix=False), plugin_types), FilePluginLoader(app_dir_plugins(posix=True), plugin_types), NamespacePluginLoader('paths_cli_plugins', plugin_types) @@ -78,7 +78,7 @@ def compile_(input_file, output_file): plugins = load_plugins() register_plugins(plugins) - objs = parse(dct) + objs = do_compile(dct) print(objs) storage = OUTPUT_FILE.get(output_file) storage.save(objs) diff --git a/paths_cli/parsing/__init__.py b/paths_cli/compiling/__init__.py similarity index 100% rename from paths_cli/parsing/__init__.py rename to paths_cli/compiling/__init__.py diff --git a/paths_cli/parsing/core.py b/paths_cli/compiling/core.py similarity index 91% rename from paths_cli/parsing/core.py rename to paths_cli/compiling/core.py index 327cd366..86564340 100644 --- a/paths_cli/parsing/core.py +++ b/paths_cli/compiling/core.py @@ -83,8 +83,8 @@ def to_json_schema(self, schema_context=None): class Builder: """Builder is a wrapper class to simplify writing builder functions. - When the parsed parameters dictionary matches the kwargs for your class, - you can create a valid delayed builder function with + When the compiled parameters dictionary matches the kwargs for your + class, you can create a valid delayed builder function with .. code:: @@ -149,7 +149,7 @@ class InstanceBuilder(OPSPlugin): version of the OPS CLI requires for this functionality """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" - parser_name = None + compiler_name = None error_on_duplicate = False # TODO: temporary def __init__(self, builder, parameters, name=None, aliases=None, requires_ops=(1, 0), requires_cli=(0, 3)): @@ -165,14 +165,14 @@ def __init__(self, builder, parameters, name=None, aliases=None, self.builder = builder self.builder_name = str(self.builder) self.parameters = parameters - self.logger = logging.getLogger(f"parser.InstanceBuilder.{builder}") + self.logger = logging.getLogger(f"compiler.InstanceBuilder.{builder}") @property def schema_name(self): - if not self.name.endswith(self.parser_name): + if not self.name.endswith(self.compiler_name): schema_name = f"{self.name}-{self.object_type}" else: - schema_name = self.parser_name + schema_name = self.compiler_name return schema_name def to_json_schema(self, schema_context=None): @@ -190,8 +190,8 @@ def to_json_schema(self, schema_context=None): } return self.schema_name, dct - def parse_attrs(self, dct): - """Parse the user input dictionary to mapping of name to object. + def compile_attrs(self, dct): + """Compile the user input dictionary to mapping of name to object. This changes the values in the key-value pairs we get from the file into objects that are suitable as input to a function. Further @@ -234,7 +234,7 @@ def parse_attrs(self, dct): def __call__(self, dct): # TODO: convert this to taking **dct -- I think that makes more # sense - ops_dct = self.parse_attrs(dct) + ops_dct = self.compile_attrs(dct) self.logger.debug("Building...") self.logger.debug(ops_dct) obj = self.builder(**ops_dct) @@ -242,8 +242,8 @@ def __call__(self, dct): return obj -class Parser: - """Generic parse class; instances for each category""" +class Compiler: + """Generic compile class; instances for each category""" error_on_duplicate = False # TODO: temporary def __init__(self, type_dispatch, label): if type_dispatch is None: @@ -252,17 +252,17 @@ def __init__(self, type_dispatch, label): self.label = label self.named_objs = {} self.all_objs = [] - logger_name = f"parser.Parser[{label}]" + logger_name = f"compiler.Compiler[{label}]" self.logger = logging.getLogger(logger_name) - def _parse_str(self, name): + def _compile_str(self, name): self.logger.debug(f"Looking for '{name}'") try: return self.named_objs[name] except KeyError as e: raise InputError.unknown_name(self.label, name) - def _parse_dict(self, dct): + def _compile_dict(self, dct): dct = dct.copy() # make a local copy name = dct.pop('name', None) type_name = dct.pop('type') @@ -292,14 +292,14 @@ def register_builder(self, builder, name): else: self.type_dispatch[name] = builder - def parse(self, dct): + def compile(self, dct): if isinstance(dct, str): - return self._parse_str(dct) + return self._compile_str(dct) else: - return self._parse_dict(dct) + return self._compile_dict(dct) def __call__(self, dct): dcts, listified = listify(dct) - objs = [self.parse(d) for d in dcts] + objs = [self.compile(d) for d in dcts] results = unlistify(objs, listified) return results diff --git a/paths_cli/parsing/cvs.py b/paths_cli/compiling/cvs.py similarity index 86% rename from paths_cli/parsing/cvs.py rename to paths_cli/compiling/cvs.py index eb73c46a..db29e367 100644 --- a/paths_cli/parsing/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -1,11 +1,11 @@ import os import importlib -from .core import Parser, InstanceBuilder, custom_eval, Parameter, Builder +from .core import Compiler, InstanceBuilder, custom_eval, Parameter, Builder from .topology import build_topology from .errors import InputError from paths_cli.utils import import_thing -from paths_cli.parsing.plugins import CVParserPlugin, ParserPlugin +from paths_cli.compiling.plugins import CVCompilerPlugin, CompilerPlugin class AllowedPackageHandler: @@ -27,7 +27,7 @@ def cv_kwargs_remapper(dct): # MDTraj-specific -MDTRAJ_CV_PLUGIN = CVParserPlugin( +MDTRAJ_CV_PLUGIN = CVCompilerPlugin( builder=Builder('openpathsampling.experimental.storage.' 'collective_variables.MDTrajFunctionCV', remapper=cv_kwargs_remapper), @@ -51,10 +51,10 @@ def cv_kwargs_remapper(dct): name="mdtraj" ) -# Main CV parser +# Main CV compiler TYPE_MAPPING = { 'mdtraj': MDTRAJ_CV_PLUGIN, } -CV_PARSER = ParserPlugin(CVParserPlugin, aliases=['cvs']) +CV_COMPILER = CompilerPlugin(CVCompilerPlugin, aliases=['cvs']) diff --git a/paths_cli/parsing/engines.py b/paths_cli/compiling/engines.py similarity index 85% rename from paths_cli/parsing/engines.py rename to paths_cli/compiling/engines.py index 33ed6811..2d74687f 100644 --- a/paths_cli/parsing/engines.py +++ b/paths_cli/compiling/engines.py @@ -1,8 +1,8 @@ from .topology import build_topology -from .core import Parser, custom_eval, Builder -from paths_cli.parsing.core import Parameter +from .core import Compiler, custom_eval, Builder +from paths_cli.compiling.core import Parameter from .tools import custom_eval_int -from paths_cli.parsing.plugins import EngineParserPlugin, ParserPlugin +from paths_cli.compiling.plugins import EngineCompilerPlugin, CompilerPlugin from paths_cli.errors import MissingIntegrationError @@ -46,11 +46,11 @@ def openmm_options(dct): "trajectory")), ] -OPENMM_PLUGIN = EngineParserPlugin( +OPENMM_PLUGIN = EngineCompilerPlugin( builder=Builder('openpathsampling.engines.openmm.Engine', remapper=openmm_options), parameters=OPENMM_PARAMETERS, name='openmm', ) -ENGINE_PARSER = ParserPlugin(EngineParserPlugin, aliases=['engines']) +ENGINE_COMPILER = CompilerPlugin(EngineCompilerPlugin, aliases=['engines']) diff --git a/paths_cli/parsing/errors.py b/paths_cli/compiling/errors.py similarity index 100% rename from paths_cli/parsing/errors.py rename to paths_cli/compiling/errors.py diff --git a/paths_cli/parsing/networks.py b/paths_cli/compiling/networks.py similarity index 66% rename from paths_cli/parsing/networks.py rename to paths_cli/compiling/networks.py index e239db9e..410f3c62 100644 --- a/paths_cli/parsing/networks.py +++ b/paths_cli/compiling/networks.py @@ -1,14 +1,14 @@ -from paths_cli.parsing.core import ( - InstanceBuilder, Parser, Builder, Parameter +from paths_cli.compiling.core import ( + InstanceBuilder, Compiler, Builder, Parameter ) -from paths_cli.parsing.tools import custom_eval -from paths_cli.parsing.plugins import NetworkParserPlugin, ParserPlugin -from paths_cli.parsing.root_parser import parser_for +from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.plugins import NetworkCompilerPlugin, CompilerPlugin +from paths_cli.compiling.root_compiler import compiler_for build_interface_set = InstanceBuilder( builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ - Parameter('cv', parser_for('cv'), description="the collective " + Parameter('cv', compiler_for('cv'), description="the collective " "variable for this interface set"), Parameter('minvals', custom_eval), # TODO fill in JSON types Parameter('maxvals', custom_eval), # TODO fill in JSON types @@ -19,12 +19,12 @@ def mistis_trans_info(dct): dct = dct.copy() transitions = dct.pop('transitions') - volume_parser = parser_for('volume') + volume_compiler = compiler_for('volume') trans_info = [ ( - volume_parser(trans['initial_state']), + volume_compiler(trans['initial_state']), build_interface_set(trans['interfaces']), - volume_parser(trans['final_state']) + volume_compiler(trans['final_state']) ) for trans in transitions ] @@ -42,12 +42,12 @@ def tis_trans_info(dct): 'interfaces': interface_set}] return mistis_trans_info(dct) -TPS_NETWORK_PLUGIN = NetworkParserPlugin( +TPS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.TPSNetwork'), parameters=[ - Parameter('initial_states', parser_for('volume'), + Parameter('initial_states', compiler_for('volume'), description="initial states for this transition"), - Parameter('final_states', parser_for('volume'), + Parameter('final_states', compiler_for('volume'), description="final states for this transition") ], name='tps' @@ -55,14 +55,14 @@ def tis_trans_info(dct): build_tps_network = TPS_NETWORK_PLUGIN -MISTIS_NETWORK_PLUGIN = NetworkParserPlugin( +MISTIS_NETWORK_PLUGIN = NetworkCompilerPlugin( parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), name='mistis' ) build_mistis_network = MISTIS_NETWORK_PLUGIN -TIS_NETWORK_PLUGIN = NetworkParserPlugin( +TIS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], name='tis' @@ -70,4 +70,4 @@ def tis_trans_info(dct): build_tis_network = TIS_NETWORK_PLUGIN -NETWORK_PARSER = ParserPlugin(NetworkParserPlugin, aliases=['networks']) +NETWORK_COMPILER = CompilerPlugin(NetworkCompilerPlugin, aliases=['networks']) diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py new file mode 100644 index 00000000..47f2d513 --- /dev/null +++ b/paths_cli/compiling/plugins.py @@ -0,0 +1,38 @@ +from paths_cli.compiling.core import InstanceBuilder +from paths_cli.plugin_management import OPSPlugin + +class CompilerPlugin(OPSPlugin): + """ + Compiler plugins only need to be made for top-level + """ + error_on_duplicate = False # TODO: temporary + def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), + requires_cli=(0,4)): + super().__init__(requires_ops, requires_cli) + self.plugin_class = plugin_class + if aliases is None: + aliases = [] + self.aliases = aliases + + @property + def name(self): + return self.plugin_class.compiler_name + + +class EngineCompilerPlugin(InstanceBuilder): + compiler_name = 'engine' + +class CVCompilerPlugin(InstanceBuilder): + compiler_name = 'cv' + +class VolumeCompilerPlugin(InstanceBuilder): + compiler_name = 'volume' + +class NetworkCompilerPlugin(InstanceBuilder): + compiler_name = 'network' + +class SchemeCompilerPlugin(InstanceBuilder): + compiler_name = 'scheme' + +class StrategyCompilerPlugin(InstanceBuilder): + compiler_name = 'strategy' diff --git a/paths_cli/parsing/root_parser.py b/paths_cli/compiling/root_compiler.py similarity index 56% rename from paths_cli/parsing/root_parser.py rename to paths_cli/compiling/root_compiler.py index c16772fd..dd383505 100644 --- a/paths_cli/parsing/root_parser.py +++ b/paths_cli/compiling/root_compiler.py @@ -1,15 +1,15 @@ -from paths_cli.parsing.core import Parser, InstanceBuilder -from paths_cli.parsing.plugins import ParserPlugin +from paths_cli.compiling.core import Compiler, InstanceBuilder +from paths_cli.compiling.plugins import CompilerPlugin import logging logger = logging.getLogger(__name__) -class ParserRegistrationError(Exception): +class CompilerRegistrationError(Exception): pass # TODO: I think this is the only OPS-specific thing in here -_DEFAULT_PARSE_ORDER = [ +_DEFAULT_COMPILE_ORDER = [ 'engine', 'cv', 'volume', @@ -18,7 +18,7 @@ class ParserRegistrationError(Exception): 'movescheme', ] -PARSE_ORDER = _DEFAULT_PARSE_ORDER.copy() +COMPILE_ORDER = _DEFAULT_COMPILE_ORDER.copy() def clean_input_key(key): # TODO: move this to core @@ -32,68 +32,69 @@ def clean_input_key(key): key.replace("-", "_") return key -### Managing known parsers and aliases to the known parsers ################ +### Managing known compilers and aliases to the known compilers ############ -_PARSERS = {} # mapping: {canonical_name: Parser} +_COMPILERS = {} # mapping: {canonical_name: Compiler} _ALIASES = {} # mapping: {alias: canonical_name} # NOTE: _ALIASES does *not* include self-mapping of the canonical names def _canonical_name(alias): - """Take an alias or a parser name and return the parser name + """Take an alias or a compiler name and return the compiler name This also cleans user input (using the canonical form generated by :meth:`.clean_input_key`). """ alias = clean_input_key(alias) alias_to_canonical = _ALIASES.copy() - alias_to_canonical.update({pname: pname for pname in _PARSERS}) + alias_to_canonical.update({pname: pname for pname in _COMPILERS}) return alias_to_canonical.get(alias, None) -def _get_parser(parser_name): +def _get_compiler(compiler_name): """ - _get_parser must only be used after the parsers have been registered. It - will automatically create a parser for any unknown ``parser_name.`` + _get_compiler must only be used after the compilers have been + registered. It will automatically create a compiler for any unknown + ``compiler_name.`` """ - canonical_name = _canonical_name(parser_name) - # create a new parser if none exists + canonical_name = _canonical_name(compiler_name) + # create a new compiler if none exists if canonical_name is None: - canonical_name = parser_name - _PARSERS[parser_name] = Parser(None, parser_name) - return _PARSERS[canonical_name] + canonical_name = compiler_name + _COMPILERS[compiler_name] = Compiler(None, compiler_name) + return _COMPILERS[canonical_name] -def _register_parser_plugin(plugin): +def _register_compiler_plugin(plugin): DUPLICATE_ERROR = RuntimeError(f"The name {plugin.name} has been " - "reserved by another parser") - if plugin.name in _PARSERS: + "reserved by another compiler") + if plugin.name in _COMPILERS: raise DUPLICATE_ERROR - parser = _get_parser(plugin.name) + compiler = _get_compiler(plugin.name) # register aliases new_aliases = set(plugin.aliases) - set([plugin.name]) for alias in new_aliases: - if alias in _PARSERS or alias in _ALIASES: + if alias in _COMPILERS or alias in _ALIASES: raise DUPLICATE_ERROR _ALIASES[alias] = plugin.name -### Handling delayed loading of parsers #################################### +### Handling delayed loading of compilers ################################## # -# Many objects need to use parsers to create their input parameters. In +# Many objects need to use compilers to create their input parameters. In # order for them to be able to access dynamically-loaded plugins, we delay -# the loading of the parser by using a proxy object. +# the loading of the compiler by using a proxy object. -class _ParserProxy: - def __init__(self, parser_name): - self.parser_name = parser_name +class _CompilerProxy: + def __init__(self, compiler_name): + self.compiler_name = compiler_name @property def _proxy(self): - canonical_name = _canonical_name(self.parser_name) + canonical_name = _canonical_name(self.compiler_name) if canonical_name is None: - raise RuntimeError("No parser registered for " - f"'{self.parser_name}'") - return _get_parser(canonical_name) + raise RuntimeError("No compiler registered for " + f"'{self.compiler_name}'") + return _get_compiler(canonical_name) @property def named_objs(self): @@ -102,17 +103,17 @@ def named_objs(self): def __call__(self, *args, **kwargs): return self._proxy(*args, **kwargs) -def parser_for(parser_name): - """Delayed parser calling. +def compiler_for(compiler_name): + """Delayed compiler calling. - Use this when you need to use a parser as the loader for a parameter. + Use this when you need to use a compiler as the loader for a parameter. Parameters ---------- - parser_name : str - the name of the parser to use + compiler_name : str + the name of the compiler to use """ - return _ParserProxy(parser_name) + return _CompilerProxy(compiler_name) ### Registering builder plugins and user-facing register_plugins ########### @@ -134,48 +135,48 @@ def _get_registration_names(plugin): return ordered_names def _register_builder_plugin(plugin): - parser = _get_parser(plugin.parser_name) + compiler = _get_compiler(plugin.compiler_name) for name in _get_registration_names(plugin): - parser.register_builder(plugin, name) + compiler.register_builder(plugin, name) def register_plugins(plugins): builders = [] - parsers = [] + compilers = [] for plugin in plugins: if isinstance(plugin, InstanceBuilder): builders.append(plugin) - elif isinstance(plugin, ParserPlugin): - parsers.append(plugin) + elif isinstance(plugin, CompilerPlugin): + compilers.append(plugin) - for plugin in parsers: - _register_parser_plugin(plugin) + for plugin in compilers: + _register_compiler_plugin(plugin) for plugin in builders: _register_builder_plugin(plugin) -### Performing the parsing of user input ################################### +### Performing the compiling of user input ################################# def _sort_user_categories(user_categories): - """Organize user input categories into parse order. + """Organize user input categories into compile order. "Cateogories" are the first-level keys in the user input file (e.g., - 'engines', 'cvs', etc.) There must be one Parser per category. + 'engines', 'cvs', etc.) There must be one Compiler per category. """ user_to_canonical = {user_key: _canonical_name(user_key) for user_key in user_categories} sorted_keys = sorted( user_categories, - key=lambda x: PARSE_ORDER.index(user_to_canonical[x]) + key=lambda x: COMPILE_ORDER.index(user_to_canonical[x]) ) return sorted_keys -def parse(dct): +def do_compile(dct): """Main function for compiling user input to objects. """ objs = [] for category in _sort_user_categories(dct): - # func = PARSERS[category] - func = _get_parser(category) + # func = COMPILERS[category] + func = _get_compiler(category) yaml_objs = dct.get(category, []) print(f"{yaml_objs}") new = [func(obj) for obj in yaml_objs] diff --git a/paths_cli/parsing/schemes.py b/paths_cli/compiling/schemes.py similarity index 65% rename from paths_cli/parsing/schemes.py rename to paths_cli/compiling/schemes.py index 425e8784..af231f04 100644 --- a/paths_cli/parsing/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -1,22 +1,22 @@ -from paths_cli.parsing.core import ( - InstanceBuilder, Parser, Builder, Parameter +from paths_cli.compiling.core import ( + InstanceBuilder, Compiler, Builder, Parameter ) -from paths_cli.parsing.tools import custom_eval -from paths_cli.parsing.shooting import shooting_selector_parser -from paths_cli.parsing.strategies import SP_SELECTOR_PARAMETER -from paths_cli.parsing.plugins import SchemeParserPlugin, ParserPlugin -from paths_cli.parsing.root_parser import parser_for +from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.shooting import shooting_selector_compiler +from paths_cli.compiling.strategies import SP_SELECTOR_PARAMETER +from paths_cli.compiling.plugins import SchemeCompilerPlugin, CompilerPlugin +from paths_cli.compiling.root_compiler import compiler_for -NETWORK_PARAMETER = Parameter('network', parser_for('network')) +NETWORK_PARAMETER = Parameter('network', compiler_for('network')) -ENGINE_PARAMETER = Parameter('engine', parser_for('engine')) # reuse elsewhere? +ENGINE_PARAMETER = Parameter('engine', compiler_for('engine')) # reuse? -STRATEGIES_PARAMETER = Parameter('strategies', parser_for('strategy'), +STRATEGIES_PARAMETER = Parameter('strategies', compiler_for('strategy'), default=None) -build_spring_shooting_scheme = SchemeParserPlugin( +build_spring_shooting_scheme = SchemeCompilerPlugin( builder=Builder('openpathsampling.SpringShootingMoveScheme'), parameters=[ NETWORK_PARAMETER, @@ -48,7 +48,7 @@ def __call__(self, dct): return scheme -build_one_way_shooting_scheme = SchemeParserPlugin( +build_one_way_shooting_scheme = SchemeCompilerPlugin( builder=BuildSchemeStrategy('openpathsampling.OneWayShootingMoveScheme', default_global_strategy=False), parameters=[ @@ -60,7 +60,7 @@ def __call__(self, dct): name='one-way-shooting', ) -build_scheme = SchemeParserPlugin( +build_scheme = SchemeCompilerPlugin( builder=BuildSchemeStrategy('openpathsampling.MoveScheme', default_global_strategy=True), parameters=[ @@ -70,4 +70,4 @@ def __call__(self, dct): name='scheme' ) -SCHEME_PARSER = ParserPlugin(SchemeParserPlugin, aliases=['schemes']) +SCHEME_COMPILER = CompilerPlugin(SchemeCompilerPlugin, aliases=['schemes']) diff --git a/paths_cli/parsing/shooting.py b/paths_cli/compiling/shooting.py similarity index 73% rename from paths_cli/parsing/shooting.py rename to paths_cli/compiling/shooting.py index 1cd1b70c..977bd17e 100644 --- a/paths_cli/parsing/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -1,8 +1,8 @@ -from paths_cli.parsing.core import ( - InstanceBuilder, Parser, Builder, Parameter +from paths_cli.compiling.core import ( + InstanceBuilder, Compiler, Builder, Parameter ) -from paths_cli.parsing.root_parser import parser_for -from paths_cli.parsing.tools import custom_eval +from paths_cli.compiling.root_compiler import compiler_for +from paths_cli.compiling.tools import custom_eval import numpy as np build_uniform_selector = InstanceBuilder( @@ -21,14 +21,14 @@ def remapping_gaussian_stddev(dct): builder=Builder('openpathsampling.GaussianBiasSelector', remapper=remapping_gaussian_stddev), parameters=[ - Parameter('cv', parser_for('cv')), + Parameter('cv', compiler_for('cv')), Parameter('mean', custom_eval), Parameter('stddev', custom_eval), ], name='gaussian', ) -shooting_selector_parser = Parser( +shooting_selector_compiler = Compiler( type_dispatch={ 'uniform': build_uniform_selector, 'gaussian': build_gaussian_selector, diff --git a/paths_cli/parsing/strategies.py b/paths_cli/compiling/strategies.py similarity index 72% rename from paths_cli/parsing/strategies.py rename to paths_cli/compiling/strategies.py index 86d908df..816ad785 100644 --- a/paths_cli/parsing/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -1,9 +1,11 @@ -from paths_cli.parsing.core import ( - Parser, Builder, Parameter +from paths_cli.compiling.core import ( + Compiler, Builder, Parameter ) -from paths_cli.parsing.shooting import shooting_selector_parser -from paths_cli.parsing.plugins import StrategyParserPlugin, ParserPlugin -from paths_cli.parsing.root_parser import parser_for +from paths_cli.compiling.shooting import shooting_selector_compiler +from paths_cli.compiling.plugins import ( + StrategyCompilerPlugin, CompilerPlugin +) +from paths_cli.compiling.root_compiler import compiler_for def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" @@ -13,9 +15,9 @@ def _group_parameter(group_name): description="the group name for these movers") # TODO: maybe this moves into shooting once we have the metadata? -SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_parser) +SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler) -ENGINE_PARAMETER = Parameter('engine', parser_for('engine'), +ENGINE_PARAMETER = Parameter('engine', compiler_for('engine'), description="the engine for moves of this " "type") @@ -28,7 +30,7 @@ def _group_parameter(group_name): -build_one_way_shooting_strategy = StrategyParserPlugin( +build_one_way_shooting_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("OneWayShootingStrategy")), parameters=[ SP_SELECTOR_PARAMETER, @@ -39,7 +41,7 @@ def _group_parameter(group_name): name='one-way-shooting', ) -build_two_way_shooting_strategy = StrategyParserPlugin( +build_two_way_shooting_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("TwoWayShootingStrategy")), parameters = [ Parameter('modifier', ...), @@ -51,7 +53,7 @@ def _group_parameter(group_name): name='two-way-shooting', ) -build_nearest_neighbor_repex_strategy = StrategyParserPlugin( +build_nearest_neighbor_repex_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("NearestNeighborRepExStrategy")), parameters=[ REPEX_GROUP_PARAMETER, @@ -60,7 +62,7 @@ def _group_parameter(group_name): name='nearest-neighbor=repex', ) -build_all_set_repex_strategy = StrategyParserPlugin( +build_all_set_repex_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("AllSetRepExStrategy")), parameters=[ REPEX_GROUP_PARAMETER, @@ -69,7 +71,7 @@ def _group_parameter(group_name): name='all-set-repex', ) -build_path_reversal_strategy = StrategyParserPlugin( +build_path_reversal_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("PathReversalStrategy")), parameters=[ _group_parameter('pathreversal'), @@ -78,7 +80,7 @@ def _group_parameter(group_name): name='path-reversal', ) -build_minus_move_strategy = StrategyParserPlugin( +build_minus_move_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("MinusMoveStrategy")), parameters=[ ENGINE_PARAMETER, @@ -88,7 +90,7 @@ def _group_parameter(group_name): name='minus', ) -build_single_replica_minus_move_strategy = StrategyParserPlugin( +build_single_replica_minus_move_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("SingleReplicaMinusMoveStrategy")), parameters=[ ENGINE_PARAMETER, @@ -98,4 +100,5 @@ def _group_parameter(group_name): name='single-replica-minus', ) -STRATEGY_PARSER = ParserPlugin(StrategyParserPlugin, aliases=['strategies']) +STRATEGY_COMPILER = CompilerPlugin(StrategyCompilerPlugin, + aliases=['strategies']) diff --git a/paths_cli/parsing/tools.py b/paths_cli/compiling/tools.py similarity index 100% rename from paths_cli/parsing/tools.py rename to paths_cli/compiling/tools.py diff --git a/paths_cli/parsing/topology.py b/paths_cli/compiling/topology.py similarity index 84% rename from paths_cli/parsing/topology.py rename to paths_cli/compiling/topology.py index 4d32f11e..08425d7d 100644 --- a/paths_cli/parsing/topology.py +++ b/paths_cli/compiling/topology.py @@ -1,13 +1,13 @@ import os from .errors import InputError -from paths_cli.parsing.root_parser import parser_for +from paths_cli.compiling.root_compiler import compiler_for def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" - # from paths_cli.parsing.engines import engine_parser - engine_parser = parser_for('engine') - if dct in engine_parser.named_objs: - engine = engine_parser.named_objs[dct] + # from paths_cli.compiling.engines import engine_compiler + engine_compiler = compiler_for('engine') + if dct in engine_compiler.named_objs: + engine = engine_compiler.named_objs[dct] try: return engine.topology except AttributeError: # no-cov diff --git a/paths_cli/parsing/volumes.py b/paths_cli/compiling/volumes.py similarity index 78% rename from paths_cli/parsing/volumes.py rename to paths_cli/compiling/volumes.py index e1050243..cfb8d4b7 100644 --- a/paths_cli/parsing/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -1,9 +1,9 @@ import operator import functools -from .core import Parser, InstanceBuilder, custom_eval, Parameter -from paths_cli.parsing.plugins import VolumeParserPlugin -from paths_cli.parsing.root_parser import parser_for +from .core import Compiler, InstanceBuilder, custom_eval, Parameter +from paths_cli.compiling.plugins import VolumeCompilerPlugin +from paths_cli.compiling.root_compiler import compiler_for # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): @@ -18,10 +18,10 @@ def cv_volume_build_func(**dct): # TODO: wrap this with some logging return builder(**dct) -CV_VOLUME_PLUGIN = VolumeParserPlugin( +CV_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=cv_volume_build_func, parameters=[ - Parameter('cv', parser_for('cv'), + Parameter('cv', compiler_for('cv'), description="CV that defines this volume"), Parameter('lambda_min', custom_eval, description="Lower bound for this volume"), @@ -39,11 +39,11 @@ def cv_volume_build_func(**dct): 'items': {"$ref": "#/definitions/volume_type"} } -INTERSECTION_VOLUME_PLUGIN = VolumeParserPlugin( +INTERSECTION_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), parameters=[ - Parameter('subvolumes', parser_for('volume'), + Parameter('subvolumes', compiler_for('volume'), json_type=VOL_ARRAY_TYPE, description="List of the volumes to intersect") ], @@ -52,11 +52,11 @@ def cv_volume_build_func(**dct): build_intersection_volume = INTERSECTION_VOLUME_PLUGIN -UNION_VOLUME_PLUGIN = VolumeParserPlugin( +UNION_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), parameters=[ - Parameter('subvolumes', parser_for('volume'), + Parameter('subvolumes', compiler_for('volume'), json_type=VOL_ARRAY_TYPE, description="List of the volumes to join into a union") ], diff --git a/paths_cli/parsing/plugins.py b/paths_cli/parsing/plugins.py deleted file mode 100644 index c6d36c43..00000000 --- a/paths_cli/parsing/plugins.py +++ /dev/null @@ -1,38 +0,0 @@ -from paths_cli.parsing.core import InstanceBuilder -from paths_cli.plugin_management import OPSPlugin - -class ParserPlugin(OPSPlugin): - """ - Parser plugins only need to be made for top-level - """ - error_on_duplicate = False # TODO: temporary - def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), - requires_cli=(0,4)): - super().__init__(requires_ops, requires_cli) - self.plugin_class = plugin_class - if aliases is None: - aliases = [] - self.aliases = aliases - - @property - def name(self): - return self.plugin_class.parser_name - - -class EngineParserPlugin(InstanceBuilder): - parser_name = 'engine' - -class CVParserPlugin(InstanceBuilder): - parser_name = 'cv' - -class VolumeParserPlugin(InstanceBuilder): - parser_name = 'volume' - -class NetworkParserPlugin(InstanceBuilder): - parser_name = 'network' - -class SchemeParserPlugin(InstanceBuilder): - parser_name = 'scheme' - -class StrategyParserPlugin(InstanceBuilder): - parser_name = 'strategy' diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 548732c9..828c5f9f 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -66,7 +66,7 @@ def test_compile(ad_openmm, test_data_dir): ad_openmm / "integrator.xml"] for filename in files: shutil.copy2(str(filename), cwd) - pytest.skip() # TODO: parser aliases don't work yet + pytest.skip() # TODO: compiler aliases don't work yet result = runner.invoke( compile_, ['setup.yml', '-o', str(ad_openmm / 'setup.db')] diff --git a/paths_cli/tests/parsing/__init__.py b/paths_cli/tests/compiling/__init__.py similarity index 100% rename from paths_cli/tests/parsing/__init__.py rename to paths_cli/tests/compiling/__init__.py diff --git a/paths_cli/tests/parsing/test_core.py b/paths_cli/tests/compiling/test_core.py similarity index 76% rename from paths_cli/tests/parsing/test_core.py rename to paths_cli/tests/compiling/test_core.py index f70faff5..1bff047a 100644 --- a/paths_cli/tests/parsing/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -5,10 +5,11 @@ import numpy.testing as npt -from paths_cli.parsing.core import * +from paths_cli.compiling.core import * class MockNamedObject: - # used in the tests for Parser._parse_dict and Parser.register_object + # used in the tests for Compiler._compile_dict and + # Compiler.register_object def __init__(self, data): self.data = data self.name = None @@ -142,7 +143,7 @@ def setup(self): name='demo', aliases=['foo', 'bar'], ) - self.instance_builder.parser_name = 'demo' + self.instance_builder.compiler_name = 'demo' self.input_dict = {'req_param': "qux", 'opt_override': 25} def test_to_json_schema(self): @@ -167,25 +168,27 @@ def test_to_json_schema(self): assert expected_schema['required'] == schema['required'] assert expected_schema['properties'] == schema['properties'] - def test_parse_attrs(self): - # parse_attrs should create a dictionary with correct objects in the - # attributes from the input dictionary + def test_compile_attrs(self): + # compile_attrs should create a dictionary with correct objects in + # the attributes from the input dictionary expected = {'req_param': "qux", 'opt_override': 25} # note that the parameter where we use the default value isn't # listed: the default value should match the default used in the # code, though! - assert self.instance_builder.parse_attrs(self.input_dict) == expected + compile_attrs = self.instance_builder.compile_attrs + assert compile_attrs(self.input_dict) == expected - def test_parse_attrs_parser_integration(self): - # parse_attrs gives the same object as already existing in a parser - # if one of the parameters uses that parser to load a named object + def test_compile_attrs_compiler_integration(self): + # compile_attrs gives the same object as already existing in a + # compiler if one of the parameters uses that compiler to load a + # named object pytest.skip() - def test_parse_attrs_missing_required(self): + def test_compile_attrs_missing_required(self): # an InputError should be raised if a required parameter is missing input_dict = {'opt_override': 25} with pytest.raises(InputError, match="missing required"): - self.instance_builder.parse_attrs(input_dict) + self.instance_builder.compile_attrs(input_dict) def test_call(self): # calling the instance builder should create the object @@ -193,38 +196,38 @@ def test_call(self): assert self.instance_builder(self.input_dict) == expected -class TestParser: +class TestCompiler: def setup(self): - self.parser = Parser( + self.compiler = Compiler( {'foo': mock_named_object_factory}, - 'foo_parser' + 'foo_compiler' ) def _mock_register_obj(self): obj = "bar" - self.parser.all_objs.append(obj) - self.parser.named_objs['foo'] = obj + self.compiler.all_objs.append(obj) + self.compiler.named_objs['foo'] = obj - def test_parse_str(self): - # parse_str should load a known object with the input name + def test_compile_str(self): + # compile_str should load a known object with the input name self._mock_register_obj() - assert self.parser._parse_str('foo') == "bar" + assert self.compiler._compile_str('foo') == "bar" - def test_parse_str_error(self): - # if parse_str is given a name that is not known, an InputError + def test_compile_str_error(self): + # if compile_str is given a name that is not known, an InputError # should be raised self._mock_register_obj() with pytest.raises(InputError, match="Unable to find"): - self.parser._parse_str('baz') + self.compiler._compile_str('baz') @pytest.mark.parametrize('named', [True, False]) - def test_parse_dict(self, named): - # parse_dct should create the object from the input dict + def test_compile_dict(self, named): + # compile_dct should create the object from the input dict input_dict = {'type': 'foo', 'data': "qux"} if named: input_dict['name'] = 'bar' - obj = self.parser._parse_dict(input_dict) + obj = self.compiler._compile_dict(input_dict) assert obj.data == "qux" name = {True: 'bar', False: None}[named] assert obj.name == name @@ -234,64 +237,64 @@ def test_register_object_named(self): # list and with the named_objs dict obj = MockNamedObject('foo') assert obj.name is None - assert self.parser.all_objs == [] - assert self.parser.named_objs == {} - obj = self.parser.register_object(obj, 'bar') + assert self.compiler.all_objs == [] + assert self.compiler.named_objs == {} + obj = self.compiler.register_object(obj, 'bar') assert obj.name == 'bar' - assert self.parser.all_objs == [obj] - assert self.parser.named_objs == {'bar': obj} + assert self.compiler.all_objs == [obj] + assert self.compiler.named_objs == {'bar': obj} def test_register_object_unnamed(self): # when registered, an unnamed object should register with the # all_objs list and leave the named_objs dict unchanged obj = MockNamedObject('foo') assert obj.name is None - assert self.parser.all_objs == [] - assert self.parser.named_objs == {} - obj = self.parser.register_object(obj, None) + assert self.compiler.all_objs == [] + assert self.compiler.named_objs == {} + obj = self.compiler.register_object(obj, None) assert obj.name is None - assert self.parser.all_objs == [obj] - assert self.parser.named_objs == {} + assert self.compiler.all_objs == [obj] + assert self.compiler.named_objs == {} def test_register_object_duplicate(self): # if an attempt is made to register an object with a name that is # already in use, an InputError should be raised, and the object # should not register with either all_objs or named_objs obj = MockNamedObject('foo').named('bar') - self.parser.named_objs['bar'] = obj - self.parser.all_objs.append(obj) + self.compiler.named_objs['bar'] = obj + self.compiler.all_objs.append(obj) obj2 = MockNamedObject('baz') with pytest.raises(InputError, match="already exists"): - self.parser.register_object(obj2, 'bar') + self.compiler.register_object(obj2, 'bar') - assert self.parser.named_objs == {'bar': obj} - assert self.parser.all_objs == [obj] + assert self.compiler.named_objs == {'bar': obj} + assert self.compiler.all_objs == [obj] assert obj2.name is None def test_register_builder(self): # a new builder can be registered and used, if it has a new name - assert len(self.parser.type_dispatch) == 1 - assert 'bar' not in self.parser.type_dispatch - self.parser.register_builder(lambda dct: 10, 'bar') - assert len(self.parser.type_dispatch) == 2 - assert 'bar' in self.parser.type_dispatch + assert len(self.compiler.type_dispatch) == 1 + assert 'bar' not in self.compiler.type_dispatch + self.compiler.register_builder(lambda dct: 10, 'bar') + assert len(self.compiler.type_dispatch) == 2 + assert 'bar' in self.compiler.type_dispatch input_dict = {'type': 'bar'} - assert self.parser(input_dict) == 10 + assert self.compiler(input_dict) == 10 def test_register_builder_duplicate(self): # if an attempt is made to registered a builder with a name that is # already in use, a RuntimeError is raised - orig = self.parser.type_dispatch['foo'] + orig = self.compiler.type_dispatch['foo'] # TODO: this should be an error; need to figure out how to avoid # duplication # with pytest.raises(RuntimeError, match="already registered"): with pytest.warns(UserWarning, match="already registered"): - self.parser.register_builder(lambda dct: 10, 'foo') + self.compiler.register_builder(lambda dct: 10, 'foo') - assert self.parser.type_dispatch['foo'] is orig + assert self.compiler.type_dispatch['foo'] is orig @pytest.mark.parametrize('input_type', ['str', 'dict']) - def test_parse(self, input_type): + def test_compile(self, input_type): pytest.skip() @pytest.mark.parametrize('as_list', [True, False]) diff --git a/paths_cli/tests/parsing/test_cvs.py b/paths_cli/tests/compiling/test_cvs.py similarity index 94% rename from paths_cli/tests/parsing/test_cvs.py rename to paths_cli/tests/compiling/test_cvs.py index d0f02f32..166aa428 100644 --- a/paths_cli/tests/parsing/test_cvs.py +++ b/paths_cli/tests/compiling/test_cvs.py @@ -4,8 +4,8 @@ from openpathsampling.tests.test_helpers import data_filename import numpy.testing as npt -from paths_cli.parsing.cvs import * -from paths_cli.parsing.errors import InputError +from paths_cli.compiling.cvs import * +from paths_cli.compiling.errors import InputError import openpathsampling as paths from openpathsampling.experimental.storage.collective_variables \ import MDTrajFunctionCV diff --git a/paths_cli/tests/parsing/test_engines.py b/paths_cli/tests/compiling/test_engines.py similarity index 96% rename from paths_cli/tests/parsing/test_engines.py rename to paths_cli/tests/compiling/test_engines.py index 38a21430..249e574c 100644 --- a/paths_cli/tests/parsing/test_engines.py +++ b/paths_cli/tests/compiling/test_engines.py @@ -2,8 +2,8 @@ import yaml import os -from paths_cli.parsing.engines import * -from paths_cli.parsing.errors import InputError +from paths_cli.compiling.engines import * +from paths_cli.compiling.errors import InputError import openpathsampling as paths from openpathsampling.engines import openmm as ops_openmm diff --git a/paths_cli/tests/parsing/test_networks.py b/paths_cli/tests/compiling/test_networks.py similarity index 77% rename from paths_cli/tests/parsing/test_networks.py rename to paths_cli/tests/compiling/test_networks.py index 9aef3eba..9e29b468 100644 --- a/paths_cli/tests/parsing/test_networks.py +++ b/paths_cli/tests/compiling/test_networks.py @@ -1,12 +1,14 @@ import pytest from unittest import mock -from paths_cli.tests.parsing.utils import mock_parser +from paths_cli.tests.compiling.utils import mock_compiler import numpy as np import yaml import openpathsampling as paths -from paths_cli.parsing.networks import * +from paths_cli.compiling.networks import * + +_COMPILERS_LOC = 'paths_cli.compiling.root_compiler._COMPILERS' def check_unidirectional_tis(results, state_A, state_B, cv): @@ -39,14 +41,14 @@ def test_mistis_trans_info(cv_and_states): } }] } - patch_base = 'paths_cli.parsing.networks' - parser = { - 'cv': mock_parser('cv', named_objs={'cv': cv}), - 'volume': mock_parser('volume', named_objs={ + patch_base = 'paths_cli.compiling.networks' + compiler = { + 'cv': mock_compiler('cv', named_objs={'cv': cv}), + 'volume': mock_compiler('volume', named_objs={ "A": state_A, "B": state_B }), } - with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): + with mock.patch.dict(_COMPILERS_LOC, compiler): results = mistis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -65,13 +67,13 @@ def test_tis_trans_info(cv_and_states): } } - parser = { - 'cv': mock_parser('cv', named_objs={'cv': cv}), - 'volume': mock_parser('volume', named_objs={ + compiler = { + 'cv': mock_compiler('cv', named_objs={'cv': cv}), + 'volume': mock_compiler('volume', named_objs={ "A": state_A, "B": state_B }), } - with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): + with mock.patch.dict(_COMPILERS_LOC, compiler): results = tis_trans_info(dct) check_unidirectional_tis(results, state_A, state_B, cv) @@ -82,11 +84,11 @@ def test_build_tps_network(cv_and_states): _, state_A, state_B = cv_and_states yml = "\n".join(["initial_states:", " - A", "final_states:", " - B"]) dct = yaml.load(yml, yaml.FullLoader) - parser = { - 'volume': mock_parser('volume', named_objs={"A": state_A, + compiler = { + 'volume': mock_compiler('volume', named_objs={"A": state_A, "B": state_B}), } - with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): + with mock.patch.dict(_COMPILERS_LOC, compiler): network = build_tps_network(dct) assert isinstance(network, paths.TPSNetwork) assert len(network.initial_states) == len(network.final_states) == 1 diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py new file mode 100644 index 00000000..c7a3f7a2 --- /dev/null +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -0,0 +1,180 @@ +import pytest +import paths_cli +from paths_cli.compiling.root_compiler import * +from paths_cli.compiling.root_compiler import ( + _get_compiler, _get_registration_names, _register_builder_plugin, + _register_compiler_plugin, _sort_user_categories, _CompilerProxy, + _COMPILERS, _ALIASES +) +from unittest.mock import Mock, PropertyMock, patch +from paths_cli.compiling.core import Compiler, InstanceBuilder +from paths_cli.compiling.plugins import CompilerPlugin + + +### FIXTURES ############################################################### + +@pytest.fixture +def foo_compiler(): + return Compiler(None, 'foo') + +@pytest.fixture +def foo_compiler_plugin(): + return CompilerPlugin(Mock(compiler_name='foo'), ['bar']) + +@pytest.fixture +def foo_baz_builder_plugin(): + builder = InstanceBuilder(None, [], name='baz') + builder.compiler_name = 'foo' + return builder + +### CONSTANTS ############################################################## + +COMPILER_LOC = "paths_cli.compiling.root_compiler._COMPILERS" +BASE = "paths_cli.compiling.root_compiler." + +### TESTS ################################################################## + +def clean_input_key(): + pytest.skip() + +def test_canonical_name(): + pytest.skip() + +class TestCompilerProxy: + def setup(self): + self.compiler = Compiler(None, "foo") + self.compiler.named_objs['bar'] = 'baz' + self.proxy = _CompilerProxy('foo') + + def test_proxy(self): + # (NOT API) the _proxy should be the registered compiler + with patch.dict(COMPILER_LOC, {'foo': self.compiler}): + assert self.proxy._proxy is self.compiler + + def test_proxy_nonexisting(self): + # _proxy should error if the no compiler is registered + with pytest.raises(RuntimeError, match="No compiler registered"): + self.proxy._proxy + + def test_named_objs(self): + # the `.named_objs` attribute should work in the proxy + with patch.dict(COMPILER_LOC, {'foo': self.compiler}): + assert self.proxy.named_objs == {'bar': 'baz'} + + def test_call(self): + # the `__call__` method should work in the proxy + pytest.skip() + +def test_compiler_for_nonexisting(): + # if nothing is ever registered with the compiler, then compiler_for + # should error + compilers = {} + with patch.dict(COMPILER_LOC, compilers): + assert 'foo' not in compilers + proxy = compiler_for('foo') + assert 'foo' not in compilers + with pytest.raises(RuntimeError, match="No compiler registered"): + proxy._proxy + +def test_compiler_for_existing(foo_compiler): + # if a compiler already exists when compiler_for is called, then + # compiler_for should get that as its proxy + with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): + proxy = compiler_for('foo') + assert proxy._proxy is foo_compiler + +def test_compiler_for_registered(): + # if a compiler is registered after compiler_for is called, then + # compiler_for should use that as its proxy + pytest.skip() + +def test_compiler_for_registered_alias(): + # if compiler_for is registered as an alias, compiler_for should still + # get the correct compiler + pytest.skip() + +def test_get_compiler_existing(foo_compiler): + # if a compiler has been registered, then _get_compiler should return the + # registered compiler + with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): + assert _get_compiler('foo') is foo_compiler + +def test_get_compiler_nonexisting(foo_compiler): + # if a compiler has not been registered, then _get_compiler should create + # the compiler + with patch.dict(COMPILER_LOC, {}): + compiler = _get_compiler('foo') + assert compiler is not foo_compiler # overkill + assert compiler.label == 'foo' + assert 'foo' in _COMPILERS + +@pytest.mark.parametrize('canonical,aliases,expected', [ + ('foo', ['bar', 'baz'], ['foo', 'bar', 'baz']), + ('foo', ['baz', 'bar'], ['foo', 'baz', 'bar']), + ('foo', ['foo', 'bar'], ['foo', 'bar']), +]) +def test_get_registration_names(canonical, aliases, expected): + # _get_registration_names should always provide the names in order + # `canonical, alias1, alias2, ...` regardless of whether `canonical` is + # also listed in the aliases + plugin = Mock(aliases=aliases) + type(plugin).name = PropertyMock(return_value=canonical) + assert _get_registration_names(plugin) == expected + +def test_register_compiler_plugin(foo_compiler_plugin): + # _register_compiler_plugin should register compilers that don't exist + compilers = {} + with patch.dict(COMPILER_LOC, compilers): + assert 'foo' not in compilers + _register_compiler_plugin(foo_compiler_plugin) + assert 'foo' in _COMPILERS + assert 'bar' in _ALIASES + + assert 'foo' not in _COMPILERS + +def test_register_compiler_plugin_duplicate(): + # if a compiler of the same name exists either in canonical or aliases, + # _register_compiler_plugin should raise an error + pytest.skip() + +def test_register_builder_plugin(): + # _register_builder_plugin should register plugins that don't exist, + # including registering the compiler if needed + pytest.skip() + +def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): + # register_plugins should correctly sort builder and compiler plugins, + # and call the correct registration functions + with patch(BASE + "_register_builder_plugin", Mock()) as builder, \ + patch(BASE + "_register_compiler_plugin", Mock()) as compiler: + register_plugins([foo_baz_builder_plugin, foo_compiler_plugin]) + assert builder.called_once_with(foo_baz_builder_plugin) + assert compiler.called_once_with(foo_compiler_plugin) + +def test_register_plugins_integration(): + # register_plugins should correctly register plugins + pytest.skip() + +def test_sort_user_categories(): + # sorted user categories should match the expected compile order + aliases = {'quux': 'qux'} + # values for compilers and user_input shouldn't matter, but that's in + # implementation detail that might change + compilers = {'foo': "FOO", 'baz': "BAZ", 'bar': "BAR", 'qux': "QUX"} + user_input = {'baz': "Baz", 'quux': "Qux", 'foo': "Foo", 'qux': "Qux"} + order = ['foo', 'bar', 'baz', 'qux'] + expected = ['foo', 'baz', 'quux', 'qux'] + + try: + paths_cli.compiling.root_compiler.COMPILE_ORDER = order + with patch.dict(COMPILER_LOC, compilers) as _compiler, \ + patch.dict(BASE + "_ALIASES", aliases) as _alias: + assert _sort_user_categories(user_input) == expected + finally: + paths_cli.compiling.root_compiler.COMPILE_ORDER = COMPILE_ORDER + # check that we unset properly (test the test) + assert paths_cli.compiling.root_compiler.COMPILE_ORDER[0] == 'engine' + +def test_do_compile(): + # compiler should correctly compile a basic input dict + pytest.skip() diff --git a/paths_cli/tests/parsing/test_shooting.py b/paths_cli/tests/compiling/test_shooting.py similarity index 76% rename from paths_cli/tests/parsing/test_shooting.py rename to paths_cli/tests/compiling/test_shooting.py index f81235f3..a6001f86 100644 --- a/paths_cli/tests/parsing/test_shooting.py +++ b/paths_cli/tests/compiling/test_shooting.py @@ -1,12 +1,14 @@ import pytest -from paths_cli.parsing.shooting import * +from paths_cli.compiling.shooting import * import openpathsampling as paths -from paths_cli.tests.parsing.utils import mock_parser +from paths_cli.tests.compiling.utils import mock_compiler from unittest.mock import patch from openpathsampling.tests.test_helpers import make_1d_traj +_COMPILERS_LOC = 'paths_cli.compiling.root_compiler._COMPILERS' + def test_remapping_gaussian_stddev(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': cv, 'mean': 1.0, 'stddev': 2.0} @@ -17,8 +19,8 @@ def test_remapping_gaussian_stddev(cv_and_states): def test_build_gaussian_selector(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': 'x', 'mean': 1.0, 'stddev': 2.0} - parser = {'cv': mock_parser('cv', named_objs={'x': cv})} - with patch.dict('paths_cli.parsing.root_parser._PARSERS', parser): + compiler = {'cv': mock_compiler('cv', named_objs={'x': cv})} + with patch.dict(_COMPILERS_LOC, compiler): sel = build_gaussian_selector(dct) assert isinstance(sel, paths.GaussianBiasSelector) diff --git a/paths_cli/tests/parsing/test_tools.py b/paths_cli/tests/compiling/test_tools.py similarity index 91% rename from paths_cli/tests/parsing/test_tools.py rename to paths_cli/tests/compiling/test_tools.py index 477c210b..2015436f 100644 --- a/paths_cli/tests/parsing/test_tools.py +++ b/paths_cli/tests/compiling/test_tools.py @@ -3,7 +3,7 @@ import numpy as np import math -from paths_cli.parsing.tools import * +from paths_cli.compiling.tools import * @pytest.mark.parametrize('expr,expected', [ ('1+1', 2), diff --git a/paths_cli/tests/parsing/test_topology.py b/paths_cli/tests/compiling/test_topology.py similarity index 53% rename from paths_cli/tests/parsing/test_topology.py rename to paths_cli/tests/compiling/test_topology.py index 26a19c26..523c7d53 100644 --- a/paths_cli/tests/parsing/test_topology.py +++ b/paths_cli/tests/compiling/test_topology.py @@ -2,10 +2,10 @@ from openpathsampling.tests.test_helpers import data_filename from unittest.mock import patch, Mock -from paths_cli.parsing.topology import * -from paths_cli.parsing.errors import InputError -import paths_cli.parsing.root_parser -from paths_cli.tests.parsing.utils import mock_parser +from paths_cli.compiling.topology import * +from paths_cli.compiling.errors import InputError +import paths_cli.compiling.root_compiler +from paths_cli.tests.compiling.utils import mock_compiler class TestBuildTopology: @@ -16,17 +16,17 @@ def test_build_topology_file(self): assert topology.n_atoms == 1651 def test_build_topology_engine(self, flat_engine): - patch_loc = 'paths_cli.parsing.root_parser._PARSERS' - parser = mock_parser('engine', named_objs={'flat': flat_engine}) - parsers = {'engine': parser} - with patch.dict(patch_loc, parsers): + patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' + compiler = mock_compiler('engine', named_objs={'flat': flat_engine}) + compilers = {'engine': compiler} + with patch.dict(patch_loc, compilers): topology = build_topology('flat') assert topology.n_spatial == 3 assert topology.n_atoms == 1 def test_build_topology_fail(self): - patch_loc = 'paths_cli.parsing.root_parser._PARSERS' - parsers = {'engine': mock_parser('engine')} - with patch.dict(patch_loc, parsers): + patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' + compilers = {'engine': mock_compiler('engine')} + with patch.dict(patch_loc, compilers): with pytest.raises(InputError): topology = build_topology('foo') diff --git a/paths_cli/tests/parsing/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py similarity index 85% rename from paths_cli/tests/parsing/test_volumes.py rename to paths_cli/tests/compiling/test_volumes.py index 309000e1..3eabdd3c 100644 --- a/paths_cli/tests/parsing/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -1,12 +1,12 @@ import pytest from unittest import mock -from paths_cli.tests.parsing.utils import mock_parser +from paths_cli.tests.compiling.utils import mock_compiler import yaml import openpathsampling as paths from openpathsampling.tests.test_helpers import make_1d_traj -from paths_cli.parsing.volumes import * +from paths_cli.compiling.volumes import * class TestBuildCVVolume: def setup(self): @@ -41,11 +41,11 @@ def test_build_cv_volume(self, inline, periodic): yml = self.yml.format(func=self.func[inline]) dct = yaml.load(yml, Loader=yaml.FullLoader) if inline =='external': - patch_loc = 'paths_cli.parsing.root_parser._PARSERS' - parsers = { - 'cv': mock_parser('cv', named_objs={'foo': self.mock_cv}) + patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' + compilers = { + 'cv': mock_compiler('cv', named_objs={'foo': self.mock_cv}) } - with mock.patch.dict(patch_loc, parsers): + with mock.patch.dict(patch_loc, compilers): vol = build_cv_volume(dct) elif inline == 'internal': vol = build_cv_volume(dct) @@ -67,7 +67,8 @@ def _vol_and_yaml(self, lambda_min, lambda_max, name): yml = ['- type: cv-volume', ' cv: foo', f" lambda_min: {lambda_min}", f" lambda_max: {lambda_max}"] - vol = paths.CVDefinedVolume(self.cv, lambda_min, lambda_max).named(name) + vol = paths.CVDefinedVolume(self.cv, lambda_min, + lambda_max).named(name) description = {'name': name, 'type': 'cv-volume', 'cv': 'foo', @@ -99,16 +100,16 @@ def test_build_combo_volume(self, combo, inline): true_vol = combo_class(vol_A, vol_B) dct = yaml.load(yml, yaml.FullLoader) - parser = { - 'cv': mock_parser('cv', named_objs={'foo': self.cv}), - 'volume': mock_parser( + compiler = { + 'cv': mock_compiler('cv', named_objs={'foo': self.cv}), + 'volume': mock_compiler( 'volume', type_dispatch={'cv-volume': build_cv_volume}, named_objs=named_volumes_dict ), } - with mock.patch.dict('paths_cli.parsing.root_parser._PARSERS', - parser): + with mock.patch.dict('paths_cli.compiling.root_compiler._COMPILERS', + compiler): vol = builder(dct) traj = make_1d_traj([0.5, 2.0, 0.2]) diff --git a/paths_cli/tests/compiling/utils.py b/paths_cli/tests/compiling/utils.py new file mode 100644 index 00000000..b181311d --- /dev/null +++ b/paths_cli/tests/compiling/utils.py @@ -0,0 +1,9 @@ +from paths_cli.compiling.core import Compiler + +def mock_compiler(compiler_name, type_dispatch=None, named_objs=None): + if type_dispatch is None: + type_dispatch = {} + compiler = Compiler(type_dispatch, compiler_name) + if named_objs is not None: + compiler.named_objs = named_objs + return compiler diff --git a/paths_cli/tests/parsing/test_root_parser.py b/paths_cli/tests/parsing/test_root_parser.py deleted file mode 100644 index ba2dba06..00000000 --- a/paths_cli/tests/parsing/test_root_parser.py +++ /dev/null @@ -1,180 +0,0 @@ -import pytest -import paths_cli -from paths_cli.parsing.root_parser import * -from paths_cli.parsing.root_parser import ( - _get_parser, _get_registration_names, _register_builder_plugin, - _register_parser_plugin, _sort_user_categories, _ParserProxy, _PARSERS, - _ALIASES -) -from unittest.mock import Mock, PropertyMock, patch -from paths_cli.parsing.core import Parser, InstanceBuilder -from paths_cli.parsing.plugins import ParserPlugin - - -### FIXTURES ############################################################### - -@pytest.fixture -def foo_parser(): - return Parser(None, 'foo') - -@pytest.fixture -def foo_parser_plugin(): - return ParserPlugin(Mock(parser_name='foo'), ['bar']) - -@pytest.fixture -def foo_baz_builder_plugin(): - builder = InstanceBuilder(None, [], name='baz') - builder.parser_name = 'foo' - return builder - -### CONSTANTS ############################################################## - -PARSER_LOC = "paths_cli.parsing.root_parser._PARSERS" -BASE = "paths_cli.parsing.root_parser." - -### TESTS ################################################################## - -def clean_input_key(): - pytest.skip() - -def test_canonical_name(): - pytest.skip() - -class TestParserProxy: - def setup(self): - self.parser = Parser(None, "foo") - self.parser.named_objs['bar'] = 'baz' - self.proxy = _ParserProxy('foo') - - def test_proxy(self): - # (NOT API) the _proxy should be the registered parser - with patch.dict(PARSER_LOC, {'foo': self.parser}): - assert self.proxy._proxy is self.parser - - def test_proxy_nonexisting(self): - # _proxy should error if the no parser is registered - with pytest.raises(RuntimeError, match="No parser registered"): - self.proxy._proxy - - def test_named_objs(self): - # the `.named_objs` attribute should work in the proxy - with patch.dict(PARSER_LOC, {'foo': self.parser}): - assert self.proxy.named_objs == {'bar': 'baz'} - - def test_call(self): - # the `__call__` method should work in the proxy - pytest.skip() - -def test_parser_for_nonexisting(): - # if nothing is ever registered with the parser, then parser_for should - # error - parsers = {} - with patch.dict(PARSER_LOC, parsers): - assert 'foo' not in parsers - proxy = parser_for('foo') - assert 'foo' not in parsers - with pytest.raises(RuntimeError, match="No parser registered"): - proxy._proxy - -def test_parser_for_existing(foo_parser): - # if a parser already exists when parser_for is called, then parser_for - # should get that as its proxy - with patch.dict(PARSER_LOC, {'foo': foo_parser}): - proxy = parser_for('foo') - assert proxy._proxy is foo_parser - -def test_parser_for_registered(): - # if a parser is registered after parser_for is called, then parser_for - # should use that as its proxy - pytest.skip() - -def test_parser_for_registered_alias(): - # if parser_for is registered as an alias, parser_for should still get - # the correct parser - pytest.skip() - -def test_get_parser_existing(foo_parser): - # if a parser has been registered, then _get_parser should return the - # registered parser - with patch.dict(PARSER_LOC, {'foo': foo_parser}): - assert _get_parser('foo') is foo_parser - -def test_get_parser_nonexisting(foo_parser): - # if a parser has not been registered, then _get_parser should create - # the parser - with patch.dict(PARSER_LOC, {}): - parser = _get_parser('foo') - assert parser is not foo_parser # overkill - assert parser.label == 'foo' - assert 'foo' in _PARSERS - -@pytest.mark.parametrize('canonical,aliases,expected', [ - ('foo', ['bar', 'baz'], ['foo', 'bar', 'baz']), - ('foo', ['baz', 'bar'], ['foo', 'baz', 'bar']), - ('foo', ['foo', 'bar'], ['foo', 'bar']), -]) -def test_get_registration_names(canonical, aliases, expected): - # _get_registration_names should always provide the names in order - # `canonical, alias1, alias2, ...` regardless of whether `canonical` is - # also listed in the aliases - plugin = Mock(aliases=aliases) - type(plugin).name = PropertyMock(return_value=canonical) - assert _get_registration_names(plugin) == expected - -def test_register_parser_plugin(foo_parser_plugin): - # _register_parser_plugin should register parsers that don't exist - parsers = {} - with patch.dict(PARSER_LOC, parsers): - assert 'foo' not in parsers - _register_parser_plugin(foo_parser_plugin) - assert 'foo' in _PARSERS - assert 'bar' in _ALIASES - - assert 'foo' not in _PARSERS - -def test_register_parser_plugin_duplicate(): - # if a parser of the same name exists either in canonical or aliases, - # _register_parser_plugin should raise an error - pytest.skip() - -def test_register_builder_plugin(): - # _register_builder_plugin should register plugins that don't exist, - # including registering the parser if needed - pytest.skip() - -def test_register_plugins_unit(foo_parser_plugin, foo_baz_builder_plugin): - # register_plugins should correctly sort builder and parser plugins, and - # call the correct registration functions - with patch(BASE + "_register_builder_plugin", Mock()) as builder, \ - patch(BASE + "_register_parser_plugin", Mock()) as parser: - register_plugins([foo_baz_builder_plugin, foo_parser_plugin]) - assert builder.called_once_with(foo_baz_builder_plugin) - assert parser.called_once_with(foo_parser_plugin) - -def test_register_plugins_integration(): - # register_plugins should correctly register plugins - pytest.skip() - -def test_sort_user_categories(): - # sorted user categories should match the expected parse order - aliases = {'quux': 'qux'} - # values for parsers and user_input shouldn't matter, but that's in - # implementation detail that might change - parsers = {'foo': "FOO", 'baz': "BAZ", 'bar': "BAR", 'qux': "QUX"} - user_input = {'baz': "Baz", 'quux': "Qux", 'foo': "Foo", 'qux': "Qux"} - order = ['foo', 'bar', 'baz', 'qux'] - expected = ['foo', 'baz', 'quux', 'qux'] - - try: - paths_cli.parsing.root_parser.PARSE_ORDER = order - with patch.dict(PARSER_LOC, parsers) as _parser, \ - patch.dict(BASE + "_ALIASES", aliases) as _alias: - assert _sort_user_categories(user_input) == expected - finally: - paths_cli.parsing.root_parser.PARSE_ORDER = PARSE_ORDER - # check that we unset properly (test the test) - assert paths_cli.parsing.root_parser.PARSE_ORDER[0] == 'engine' - -def test_parse(): - # parser should correctly parse a basic input dict - pytest.skip() diff --git a/paths_cli/tests/parsing/utils.py b/paths_cli/tests/parsing/utils.py deleted file mode 100644 index ed88364a..00000000 --- a/paths_cli/tests/parsing/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -from paths_cli.parsing.core import Parser - -def mock_parser(parser_name, type_dispatch=None, named_objs=None): - if type_dispatch is None: - type_dispatch = {} - parser = Parser(type_dispatch, parser_name) - if named_objs is not None: - parser.named_objs = named_objs - return parser diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 376dd085..023ef5da 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,5 +1,5 @@ from paths_cli.wizard.engines import engines -from paths_cli.parsing.tools import custom_eval, mdtraj_parse_atomlist +from paths_cli.compiling.tools import custom_eval, mdtraj_parse_atomlist from paths_cli.wizard.load_from_ops import load_from_ops from paths_cli.wizard.load_from_ops import LABEL as _load_label from paths_cli.wizard.core import get_object diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 97927b10..099c5230 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -3,7 +3,7 @@ from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.engines import engines from paths_cli.wizard.cvs import cvs -from paths_cli.parsing.tools import custom_eval +from paths_cli.compiling.tools import custom_eval import numpy as np diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 28aa70a7..dc4fa03e 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -9,7 +9,7 @@ FILE_LOADING_ERROR_MSG, RestartObjectException ) from paths_cli.wizard.joke import name_joke -from paths_cli.parsing.tools import custom_eval +from paths_cli.compiling.tools import custom_eval import shutil From 030679c6c826802aa7723d52919a2324205faa89 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 14:09:49 -0400 Subject: [PATCH 110/251] more tests for root_compiler --- paths_cli/compiling/root_compiler.py | 14 ++- paths_cli/tests/compiling/test_core.py | 8 +- .../tests/compiling/test_root_compiler.py | 111 +++++++++++++++--- 3 files changed, 109 insertions(+), 24 deletions(-) diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index dd383505..cccc1185 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -29,7 +29,7 @@ def clean_input_key(key): """ key = key.lower() key = "_".join(key.split()) # whitespace to underscore - key.replace("-", "_") + key = key.replace("-", "_") return key ### Managing known compilers and aliases to the known compilers ############ @@ -63,8 +63,9 @@ def _get_compiler(compiler_name): return _COMPILERS[canonical_name] def _register_compiler_plugin(plugin): - DUPLICATE_ERROR = RuntimeError(f"The name {plugin.name} has been " - "reserved by another compiler") + DUPLICATE_ERROR = CompilerRegistrationError( + f"The name {plugin.name} has been reserved by another compiler" + ) if plugin.name in _COMPILERS: raise DUPLICATE_ERROR @@ -175,10 +176,11 @@ def do_compile(dct): """ objs = [] for category in _sort_user_categories(dct): - # func = COMPILERS[category] + # breakpoint() func = _get_compiler(category) yaml_objs = dct.get(category, []) - print(f"{yaml_objs}") - new = [func(obj) for obj in yaml_objs] + logger.debug(f"{yaml_objs}") + new = func(yaml_objs) + # new = [func(obj) for obj in yaml_objs] objs.extend(new) return objs diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index 1bff047a..ba6e8822 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -295,8 +295,14 @@ def test_register_builder_duplicate(self): @pytest.mark.parametrize('input_type', ['str', 'dict']) def test_compile(self, input_type): + # the compile method should work whether the input is a dict + # representing an object to be compiled or string name for an + # already-compiled object pytest.skip() + @pytest.mark.parametrize('input_type', ['str', 'dict']) @pytest.mark.parametrize('as_list', [True, False]) - def test_call(self, as_list): + def test_call(self, input_type, as_list): + # the call method should work whether the input is a single object + # or a list of objects (as well as whether string or dict) pytest.skip() diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index c7a3f7a2..7793f4fb 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -2,9 +2,9 @@ import paths_cli from paths_cli.compiling.root_compiler import * from paths_cli.compiling.root_compiler import ( - _get_compiler, _get_registration_names, _register_builder_plugin, - _register_compiler_plugin, _sort_user_categories, _CompilerProxy, - _COMPILERS, _ALIASES + _canonical_name, _get_compiler, _get_registration_names, + _register_builder_plugin, _register_compiler_plugin, + _sort_user_categories, _CompilerProxy, _COMPILERS, _ALIASES ) from unittest.mock import Mock, PropertyMock, patch from paths_cli.compiling.core import Compiler, InstanceBuilder @@ -23,7 +23,8 @@ def foo_compiler_plugin(): @pytest.fixture def foo_baz_builder_plugin(): - builder = InstanceBuilder(None, [], name='baz') + builder = InstanceBuilder(lambda: "FOO" , [], name='baz', + aliases=['qux']) builder.compiler_name = 'foo' return builder @@ -34,11 +35,18 @@ def foo_baz_builder_plugin(): ### TESTS ################################################################## -def clean_input_key(): - pytest.skip() +@pytest.mark.parametrize('input_string', ["foo-bar", "FOO_bar", "foo bar", + "foo_bar", "foo BAR"]) +def test_clean_input_key(input_string): + assert clean_input_key(input_string) == "foo_bar" -def test_canonical_name(): - pytest.skip() +@pytest.mark.parametrize('input_name', ['canonical', 'alias']) +def test_canonical_name(input_name): + compilers = {'canonical': "FOO"} + aliases = {'alias': 'canonical'} + with patch.dict(COMPILER_LOC, compilers) as compilers_, \ + patch.dict(BASE + "_ALIASES", aliases) as aliases_: + assert _canonical_name(input_name) == "canonical" class TestCompilerProxy: def setup(self): @@ -83,15 +91,22 @@ def test_compiler_for_existing(foo_compiler): proxy = compiler_for('foo') assert proxy._proxy is foo_compiler -def test_compiler_for_registered(): +def test_compiler_for_unregistered(foo_compiler): # if a compiler is registered after compiler_for is called, then # compiler_for should use that as its proxy - pytest.skip() + proxy = compiler_for('foo') + with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): + assert proxy._proxy is foo_compiler -def test_compiler_for_registered_alias(): +def test_compiler_for_registered_alias(foo_compiler): # if compiler_for is registered as an alias, compiler_for should still # get the correct compiler - pytest.skip() + compilers = {'foo': foo_compiler} + aliases = {'bar': 'foo'} + with patch.dict(COMPILER_LOC, compilers) as compilers_, \ + patch.dict(BASE + "_ALIASES", aliases) as aliases_: + proxy = compiler_for('bar') + assert proxy._proxy is foo_compiler def test_get_compiler_existing(foo_compiler): # if a compiler has been registered, then _get_compiler should return the @@ -132,15 +147,47 @@ def test_register_compiler_plugin(foo_compiler_plugin): assert 'foo' not in _COMPILERS -def test_register_compiler_plugin_duplicate(): +@pytest.mark.parametrize('duplicate_of', ['canonical', 'alias']) +@pytest.mark.parametrize('duplicate_from', ['canonical', 'alias']) +def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): # if a compiler of the same name exists either in canonical or aliases, # _register_compiler_plugin should raise an error - pytest.skip() -def test_register_builder_plugin(): + # duplicate_of: existing + # duplicate_from: which part of the plugin has the duplicated name + pytest.skip() # FIXME: same duplication problem as before, I think + if duplicate_from == 'canonical': + plugin = CompilerPlugin(Mock(compiler_name=duplicate_of), + aliases=['foo']) + else: + plugin = CompilerPlugin(Mock(compiler_name='foo'), + aliases=[duplicate_of]) + + compilers = {'canonical': "FOO"} + aliases = {'alias': 'canonical'} + with patch.dict(COMPILER_LOC, compilers) as compilers_,\ + patch.dict(BASE + "_ALIASES", aliases) as aliases_: + with pytest.raises(CompilerRegistrationError): + _register_compiler_plugin(plugin) + +@pytest.mark.parametrize('compiler_exists', [True, False]) +def test_register_builder_plugin(compiler_exists, foo_baz_builder_plugin, + foo_compiler): # _register_builder_plugin should register plugins that don't exist, # including registering the compiler if needed - pytest.skip() + if compiler_exists: + compilers = {'foo': foo_compiler} + else: + compilers = {} + + with patch.dict(COMPILER_LOC, compilers): + if not compiler_exists: + assert 'foo' not in _COMPILERS + _register_builder_plugin(foo_baz_builder_plugin) + assert 'foo' in _COMPILERS + type_dispatch = _COMPILERS['foo'].type_dispatch + assert type_dispatch['baz'] is foo_baz_builder_plugin + assert type_dispatch['qux'] is foo_baz_builder_plugin def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): # register_plugins should correctly sort builder and compiler plugins, @@ -177,4 +224,34 @@ def test_sort_user_categories(): def test_do_compile(): # compiler should correctly compile a basic input dict - pytest.skip() + compilers = { + 'foo': Compiler({ + 'baz': lambda dct: "BAZ" * dct['x'] + }, 'foo'), + 'bar': Compiler({ + 'qux': lambda dct: "QUX" * dct['x'] + }, 'bar'), + } + aliases = {'baar': 'bar'} + input_dict = { + 'foo': [{ + 'type': 'baz', + 'x': 2 + }], + 'baar': [{ + 'type': 'qux', + 'x': 3 + }] + } + order = ['bar', 'foo'] + try: + paths_cli.compiling.root_compiler.COMPILE_ORDER = order + with patch.dict(COMPILER_LOC, compilers) as _compiler,\ + patch.dict(BASE + "_ALIASES", aliases) as _alias: + objs = do_compile(input_dict) + finally: + paths_cli.compiling.root_compiler.COMPILE_ORDER = COMPILE_ORDER + # check that we unset properly (test the test) + assert paths_cli.compiling.root_compiler.COMPILE_ORDER[0] == 'engine' + + assert objs == ["QUXQUXQUX", "BAZBAZ"] From 4b7dd7c837f2a143313435ba736b6f395c6ce84d Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 16:18:46 -0400 Subject: [PATCH 111/251] more cleanup to get tests running --- paths_cli/cli.py | 6 +----- paths_cli/commands/compile.py | 1 + paths_cli/compiling/engines.py | 2 +- paths_cli/compiling/networks.py | 6 ++++-- paths_cli/compiling/root_compiler.py | 12 ++++++++---- paths_cli/compiling/volumes.py | 5 ++++- paths_cli/param_core.py | 8 ++++++++ paths_cli/tests/commands/test_compile.py | 13 +++++++++++-- paths_cli/tests/commands/test_contents.py | 3 +++ paths_cli/tests/compiling/test_plugins.py | 19 +++++++++++++++++++ paths_cli/utils.py | 2 +- 11 files changed, 61 insertions(+), 16 deletions(-) create mode 100644 paths_cli/tests/compiling/test_plugins.py diff --git a/paths_cli/cli.py b/paths_cli/cli.py index 67583c03..5d394008 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -15,6 +15,7 @@ from .plugin_management import (FilePluginLoader, NamespacePluginLoader, OPSCommandPlugin) +from .utils import app_dir_plugins CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -26,11 +27,6 @@ class OpenPathSamplingCLI(click.MultiCommand): def __init__(self, *args, **kwargs): # the logic here is all about loading the plugins commands = str(pathlib.Path(__file__).parent.resolve() / 'commands') - def app_dir_plugins(posix): - return str(pathlib.Path( - click.get_app_dir("OpenPathSampling", force_posix=posix) - ).resolve() / 'cli-plugins') - self.plugin_loaders = [ FilePluginLoader(commands, OPSCommandPlugin), FilePluginLoader(app_dir_plugins(posix=False), OPSCommandPlugin), diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 975d8e58..e58c4e7a 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -11,6 +11,7 @@ import importlib from paths_cli.utils import app_dir_plugins +# this is just to handle a nicer error def import_module(module_name, format_type=None, install=None): try: mod = importlib.import_module(module_name) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 2d74687f..08a0cdc3 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -14,7 +14,7 @@ HAS_OPENMM = True def load_openmm_xml(filename): - if not HAS_OPENMM: # pragma: no cover + if not HAS_OPENMM: # -no-cov- raise RuntimeError("OpenMM does not seem to be installed") with open(filename, mode='r') as f: diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index 410f3c62..d781263f 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -53,20 +53,22 @@ def tis_trans_info(dct): name='tps' ) -build_tps_network = TPS_NETWORK_PLUGIN MISTIS_NETWORK_PLUGIN = NetworkCompilerPlugin( parameters=[Parameter('trans_info', mistis_trans_info)], builder=Builder('openpathsampling.MISTISNetwork'), name='mistis' ) -build_mistis_network = MISTIS_NETWORK_PLUGIN TIS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], name='tis' ) + +# old names not yet replaced in testing +build_tps_network = TPS_NETWORK_PLUGIN +build_mistis_network = MISTIS_NETWORK_PLUGIN build_tis_network = TIS_NETWORK_PLUGIN diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index cccc1185..623ffc91 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -55,6 +55,11 @@ def _get_compiler(compiler_name): registered. It will automatically create a compiler for any unknown ``compiler_name.`` """ + if compiler_name is None: + if None not in _COMPILERS: + _COMPILERS[None] = Compiler(None, None) + return _COMPILERS[None] + canonical_name = _canonical_name(compiler_name) # create a new compiler if none exists if canonical_name is None: @@ -165,6 +170,7 @@ def _sort_user_categories(user_categories): """ user_to_canonical = {user_key: _canonical_name(user_key) for user_key in user_categories} + logger.debug(user_to_canonical) sorted_keys = sorted( user_categories, key=lambda x: COMPILE_ORDER.index(user_to_canonical[x]) @@ -175,12 +181,10 @@ def do_compile(dct): """Main function for compiling user input to objects. """ objs = [] - for category in _sort_user_categories(dct): - # breakpoint() + for category in _sort_user_categories(dct.keys()): func = _get_compiler(category) yaml_objs = dct.get(category, []) - logger.debug(f"{yaml_objs}") + logger.debug(yaml_objs) new = func(yaml_objs) - # new = [func(obj) for obj in yaml_objs] objs.extend(new) return objs diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index cfb8d4b7..14f8d21b 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -2,7 +2,7 @@ import functools from .core import Compiler, InstanceBuilder, custom_eval, Parameter -from paths_cli.compiling.plugins import VolumeCompilerPlugin +from paths_cli.compiling.plugins import VolumeCompilerPlugin, CompilerPlugin from paths_cli.compiling.root_compiler import compiler_for # TODO: extra function for volumes should not be necessary as of OPS 2.0 @@ -64,3 +64,6 @@ def cv_volume_build_func(**dct): ) build_union_volume = UNION_VOLUME_PLUGIN + +VOLUME_COMPILER = CompilerPlugin(VolumeCompilerPlugin, aliases=['state', + 'states']) diff --git a/paths_cli/param_core.py b/paths_cli/param_core.py index f1c23b48..46b94af0 100644 --- a/paths_cli/param_core.py +++ b/paths_cli/param_core.py @@ -115,6 +115,14 @@ def get(self, name): storage = Storage(name, self.mode) return storage + @classmethod + def cleanup(cls): + if cls.has_simstore_patch: + import openpathsampling as paths + from openpathsampling.experimental.storage.monkey_patches \ + import unpatch + paths = unpatch(paths) + class OPSStorageLoadNames(AbstractLoader): """Simple loader that expects its input to be a name or index. diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 828c5f9f..804d4cd1 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -66,7 +66,7 @@ def test_compile(ad_openmm, test_data_dir): ad_openmm / "integrator.xml"] for filename in files: shutil.copy2(str(filename), cwd) - pytest.skip() # TODO: compiler aliases don't work yet + # pytest.skip() # TODO: compiler aliases don't work yet result = runner.invoke( compile_, ['setup.yml', '-o', str(ad_openmm / 'setup.db')] @@ -79,9 +79,10 @@ def test_compile(ad_openmm, test_data_dir): traceback.print_tb(result.exc_info[2]) print(result.exception) print(result.exc_info) - assert result.exit_code == 0 print(result.output) + assert result.exit_code == 0 assert os.path.exists(str(ad_openmm / 'setup.db')) + import openpathsampling as paths from openpathsampling.experimental.storage import ( Storage, monkey_patch_all) # TODO: need to do the temporary monkey patch here @@ -92,3 +93,11 @@ def test_compile(ad_openmm, test_data_dir): engine = st.engines['engine'] phi = st.cvs['phi'] C_7eq = st.volumes['C_7eq'] + from openpathsampling.experimental.storage.monkey_patches import unpatch + paths = unpatch(paths) + paths.InterfaceSet.simstore = False + import importlib + importlib.reload(paths.netcdfplus) + importlib.reload(paths.collectivevariable) + importlib.reload(paths.collectivevariables) + importlib.reload(paths) diff --git a/paths_cli/tests/commands/test_contents.py b/paths_cli/tests/commands/test_contents.py index 0d136437..ca8d2380 100644 --- a/paths_cli/tests/commands/test_contents.py +++ b/paths_cli/tests/commands/test_contents.py @@ -7,6 +7,7 @@ import openpathsampling as paths from paths_cli.commands.contents import * +from .utils import assert_click_success def test_contents(tps_fixture): # we just do a full integration test of this one @@ -36,6 +37,7 @@ def test_contents(tps_fixture): "Trajectories: 1 unnamed item", f"Snapshots: {2*len(init_conds[0])} unnamed items", "" ] + assert_click_success(results) assert results.exit_code == 0 assert results.output.split('\n') == expected for truth, beauty in zip(expected, results.output.split('\n')): @@ -72,6 +74,7 @@ def test_contents_table(tps_fixture, table): ], }[table] assert results.output.split("\n") == expected + assert_click_success(results) assert results.exit_code == 0 def test_contents_table_error(): diff --git a/paths_cli/tests/compiling/test_plugins.py b/paths_cli/tests/compiling/test_plugins.py new file mode 100644 index 00000000..8a78a09a --- /dev/null +++ b/paths_cli/tests/compiling/test_plugins.py @@ -0,0 +1,19 @@ +import pytest +from paths_cli.compiling.plugins import * +from unittest.mock import Mock + +class TestCompilerPlugin: + def setup(self): + self.plugin_class = Mock(compiler_name='foo') + self.plugin = CompilerPlugin(self.plugin_class) + self.aliased_plugin = CompilerPlugin(self.plugin_class, + aliases=['bar']) + + @pytest.mark.parametrize('plugin_type', ['basic', 'aliased']) + def test_init(self, plugin_type): + plugin = {'basic': self.plugin, + 'aliased': self.aliased_plugin}[plugin_type] + expected_alias = {'basic': [], 'aliased': ['bar']}[plugin_type] + assert plugin.aliases == expected_alias + assert plugin.plugin_class == self.plugin_class + assert plugin.name == 'foo' diff --git a/paths_cli/utils.py b/paths_cli/utils.py index c47f26d7..8ff1e826 100644 --- a/paths_cli/utils.py +++ b/paths_cli/utils.py @@ -27,7 +27,7 @@ def import_thing(module, obj=None): return result -def app_dir_plugins(posix): +def app_dir_plugins(posix): # covered as smoke tests (too OS dependent) return str(pathlib.Path( click.get_app_dir("OpenPathSampling", force_posix=posix) ).resolve() / 'cli-plugins') From ddc6a17929d79666c3ca2677a0865d4379ebfd90 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 16:23:49 -0400 Subject: [PATCH 112/251] add test/commands/utils --- paths_cli/tests/commands/utils.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 paths_cli/tests/commands/utils.py diff --git a/paths_cli/tests/commands/utils.py b/paths_cli/tests/commands/utils.py new file mode 100644 index 00000000..27a31d16 --- /dev/null +++ b/paths_cli/tests/commands/utils.py @@ -0,0 +1,8 @@ +import traceback + +def assert_click_success(result): + if result.exit_code != 0: + print(result.output) + traceback.print_tb(result.exc_info[2]) + print(result.exc_info[0], result.exc_info[1]) + assert result.exit_code == 0 From ffbf6c5bccfb04708f8b5a924dfbfb567ee5bc9f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 18:55:36 -0400 Subject: [PATCH 113/251] cleanup; start to tests move scheme compiling --- paths_cli/compiling/schemes.py | 12 ++-- paths_cli/compiling/strategies.py | 3 +- paths_cli/compiling/volumes.py | 1 - paths_cli/param_core.py | 8 --- paths_cli/tests/compiling/test_schemes.py | 82 +++++++++++++++++++++++ 5 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 paths_cli/tests/compiling/test_schemes.py diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index af231f04..dd8d0238 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -16,7 +16,7 @@ default=None) -build_spring_shooting_scheme = SchemeCompilerPlugin( +SPRING_SHOOTING_PLUGIN = SchemeCompilerPlugin( builder=Builder('openpathsampling.SpringShootingMoveScheme'), parameters=[ NETWORK_PARAMETER, @@ -32,7 +32,7 @@ def __init__(self, scheme_class, default_global_strategy): self.scheme_class = scheme_class self.default_global_strategy = default_global_strategy - def __call__(self, dct): + def __call__(self, **dct): from openpathsampling import strategies if self.default_global_strategy: global_strategy = [strategies.OrganizeByMoveGroupStrategy()] @@ -40,15 +40,15 @@ def __call__(self, dct): global_strategy = [] builder = Builder(self.scheme_class) - strategies = dct.pop('strategies', []) + global_strategy - scheme = builder(dct) + strategies = global_strategy + dct.pop('strategies', []) + scheme = builder(**dct) for strat in strategies: scheme.append(strat) # self.logger.debug(f"strategies: {scheme.strategies}") return scheme -build_one_way_shooting_scheme = SchemeCompilerPlugin( +ONE_WAY_SHOOTING_SCHEME_PLUGIN = SchemeCompilerPlugin( builder=BuildSchemeStrategy('openpathsampling.OneWayShootingMoveScheme', default_global_strategy=False), parameters=[ @@ -60,7 +60,7 @@ def __call__(self, dct): name='one-way-shooting', ) -build_scheme = SchemeCompilerPlugin( +MOVESCHEME_PLUGIN = SchemeCompilerPlugin( builder=BuildSchemeStrategy('openpathsampling.MoveScheme', default_global_strategy=True), parameters=[ diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 816ad785..0b2833e8 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -15,7 +15,8 @@ def _group_parameter(group_name): description="the group name for these movers") # TODO: maybe this moves into shooting once we have the metadata? -SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler) +SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler, + default=None) ENGINE_PARAMETER = Parameter('engine', compiler_for('engine'), description="the engine for moves of this " diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index 14f8d21b..3c924b0c 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -7,7 +7,6 @@ # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): - # TODO: this should take dict, not kwargs import openpathsampling as paths cv = dct['cv'] builder = paths.CVDefinedVolume diff --git a/paths_cli/param_core.py b/paths_cli/param_core.py index 46b94af0..f1c23b48 100644 --- a/paths_cli/param_core.py +++ b/paths_cli/param_core.py @@ -115,14 +115,6 @@ def get(self, name): storage = Storage(name, self.mode) return storage - @classmethod - def cleanup(cls): - if cls.has_simstore_patch: - import openpathsampling as paths - from openpathsampling.experimental.storage.monkey_patches \ - import unpatch - paths = unpatch(paths) - class OPSStorageLoadNames(AbstractLoader): """Simple loader that expects its input to be a name or index. diff --git a/paths_cli/tests/compiling/test_schemes.py b/paths_cli/tests/compiling/test_schemes.py new file mode 100644 index 00000000..9d579f61 --- /dev/null +++ b/paths_cli/tests/compiling/test_schemes.py @@ -0,0 +1,82 @@ +import pytest + +from paths_cli.compiling.schemes import * +import openpathsampling as paths + +from unittest.mock import patch +from openpathsampling.tests.test_helpers import make_1d_traj +from paths_cli.tests.compiling.utils import mock_compiler + +_COMPILERS_LOC = 'paths_cli.compiling.root_compiler._COMPILERS' + +@pytest.fixture +def tps_compilers_and_traj(tps_network_and_traj, flat_engine): + network, traj = tps_network_and_traj + compilers = { + 'network': mock_compiler('network', None, {'tps_network': network}), + 'engine': mock_compiler('engine', None, + {'flat_engine': flat_engine}), + } + return compilers, traj + +@pytest.fixture +def tis_compilers_and_traj(tps_network_and_traj, tis_network, flat_engine): + _, traj = tps_network_and_traj + compilers = { + 'network': mock_compiler('network', None, + {'tis_network': tis_network}), + 'engine': mock_compiler('engine', None, + {'flat_engine': flat_engine}), + } + return compilers, traj + +def test_build_spring_shooting_scheme(tps_compilers_and_traj): + paths.InterfaceSet._reset() + + compilers, traj = tps_compilers_and_traj + dct = {'network': 'tps_network', 'k_spring': 0.5, 'delta_max': 100, + 'engine': 'flat_engine'} + with patch.dict(_COMPILERS_LOC, compilers): + scheme = SPRING_SHOOTING_PLUGIN(dct) + + assert isinstance(scheme, paths.SpringShootingMoveScheme) + # smoke test that it can build its tree and load init conds + scheme.move_decision_tree() + _ = scheme.initial_conditions_from_trajectories(traj) + + +@pytest.mark.parametrize('network', ['tps', 'tis']) +def test_build_one_way_shooting_scheme(network, tps_compilers_and_traj, + tis_compilers_and_traj): + paths.InterfaceSet._reset() + compilers, traj = {'tps': tps_compilers_and_traj, + 'tis': tis_compilers_and_traj}[network] + dct = {'network': f"{network}_network", 'engine': 'flat_engine'} + with patch.dict(_COMPILERS_LOC, compilers): + scheme = ONE_WAY_SHOOTING_SCHEME_PLUGIN(dct) + + assert isinstance(scheme, paths.OneWayShootingMoveScheme) + # smoke test that it can build its tree and load init conds + scheme.move_decision_tree() + _ = scheme.initial_conditions_from_trajectories(traj) + + +def test_movescheme_plugin(tis_compilers_and_traj, flat_engine): + paths.InterfaceSet._reset() + compilers, traj = tis_compilers_and_traj + + dct = {'network': 'tis_network', + 'strategies': [ + {'type': ''}, + {'type': ''}, + {'type': ''}, + {'type': ''}, + ]} + pytest.skip() + with patch.dict(_COMPILERS_LOC, compilers): + scheme = MOVESCHEME_PLUGIN(dct) + + assert isinstance(scheme, paths.MoveScheme) + # smoke test that it can build its tree and load init conds + scheme.move_decision_tree() + _ = scheme.initial_conditions_from_trajectories(traj) From 1b6f5053b17c02c579127c5b0c1b85b7d84a5e6b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 20:12:22 -0400 Subject: [PATCH 114/251] Fix the error_on_duplicate issue --- paths_cli/compiling/core.py | 7 ++++--- paths_cli/compiling/networks.py | 3 ++- paths_cli/tests/compiling/test_core.py | 5 +---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 86564340..2d1a92c1 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -244,7 +244,8 @@ def __call__(self, dct): class Compiler: """Generic compile class; instances for each category""" - error_on_duplicate = False # TODO: temporary + # error_on_duplicate = False # TODO: temporary + error_on_duplicate = True def __init__(self, type_dispatch, label): if type_dispatch is None: type_dispatch = {} @@ -283,12 +284,12 @@ def register_object(self, obj, name): def register_builder(self, builder, name): if name in self.type_dispatch: + if self.type_dispatch[name] is builder: + return # nothing to do here! msg = (f"'{name}' is already registered " f"with {self.label}") if self.error_on_duplicate: raise RuntimeError(msg) - else: - warnings.warn(msg) else: self.type_dispatch[name] = builder diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index d781263f..9b6815da 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -66,7 +66,8 @@ def tis_trans_info(dct): name='tis' ) -# old names not yet replaced in testing +# old names not yet replaced in testing THESE ARE WHY WE'RE DOUBLING! GET +# RID OF THEM! (also, use an is-check) build_tps_network = TPS_NETWORK_PLUGIN build_mistis_network = MISTIS_NETWORK_PLUGIN build_tis_network = TIS_NETWORK_PLUGIN diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index ba6e8822..4aeca9af 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -285,10 +285,7 @@ def test_register_builder_duplicate(self): # if an attempt is made to registered a builder with a name that is # already in use, a RuntimeError is raised orig = self.compiler.type_dispatch['foo'] - # TODO: this should be an error; need to figure out how to avoid - # duplication - # with pytest.raises(RuntimeError, match="already registered"): - with pytest.warns(UserWarning, match="already registered"): + with pytest.raises(RuntimeError, match="already registered"): self.compiler.register_builder(lambda dct: 10, 'foo') assert self.compiler.type_dispatch['foo'] is orig From ad714d9b43306134a5b7dc58a2226689efedc202 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 20:24:43 -0400 Subject: [PATCH 115/251] fix other problem with duplication --- paths_cli/compiling/root_compiler.py | 2 +- paths_cli/tests/compiling/test_root_compiler.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index 623ffc91..f7b61cc4 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -71,7 +71,7 @@ def _register_compiler_plugin(plugin): DUPLICATE_ERROR = CompilerRegistrationError( f"The name {plugin.name} has been reserved by another compiler" ) - if plugin.name in _COMPILERS: + if plugin.name in _COMPILERS or plugin.name in _ALIASES: raise DUPLICATE_ERROR compiler = _get_compiler(plugin.name) diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 7793f4fb..4c41c78c 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -155,7 +155,6 @@ def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): # duplicate_of: existing # duplicate_from: which part of the plugin has the duplicated name - pytest.skip() # FIXME: same duplication problem as before, I think if duplicate_from == 'canonical': plugin = CompilerPlugin(Mock(compiler_name=duplicate_of), aliases=['foo']) From 1e531ba5fb0bc02a3f1b426ca5e79c551a9e91f4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 21:09:04 -0400 Subject: [PATCH 116/251] more tests for compiling.schemes --- paths_cli/compiling/plugins.py | 6 ++++- paths_cli/compiling/strategies.py | 27 +++++++++++------------ paths_cli/tests/compiling/test_schemes.py | 18 ++++++++++----- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index 47f2d513..91fae3b6 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -18,6 +18,10 @@ def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), def name(self): return self.plugin_class.compiler_name + def __repr__(self): + return (f"CompilerPlugin({self.plugin_class.__name__}, " + f"{self.aliases})") + class EngineCompilerPlugin(InstanceBuilder): compiler_name = 'engine' @@ -32,7 +36,7 @@ class NetworkCompilerPlugin(InstanceBuilder): compiler_name = 'network' class SchemeCompilerPlugin(InstanceBuilder): - compiler_name = 'scheme' + compiler_name = 'scheme' class StrategyCompilerPlugin(InstanceBuilder): compiler_name = 'strategy' diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 0b2833e8..6d148a6f 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -30,8 +30,7 @@ def _group_parameter(group_name): REPLACE_FALSE_PARAMETER = Parameter('replace', bool, default=False) - -build_one_way_shooting_strategy = StrategyCompilerPlugin( +ONE_WAY_SHOOTING_STRATEGY_PLUGIN = StrategyCompilerPlugin( builder=Builder(_strategy_name("OneWayShootingStrategy")), parameters=[ SP_SELECTOR_PARAMETER, @@ -41,18 +40,18 @@ def _group_parameter(group_name): ], name='one-way-shooting', ) - -build_two_way_shooting_strategy = StrategyCompilerPlugin( - builder=Builder(_strategy_name("TwoWayShootingStrategy")), - parameters = [ - Parameter('modifier', ...), - SP_SELECTOR_PARAMETER, - ENGINE_PARAMETER, - SHOOTING_GROUP_PARAMETER, - REPLACE_TRUE_PARAMETER, - ], - name='two-way-shooting', -) +build_one_way_shooting_strategy = ONE_WAY_SHOOTING_STRATEGY_PLUGIN +# build_two_way_shooting_strategy = StrategyCompilerPlugin( +# builder=Builder(_strategy_name("TwoWayShootingStrategy")), +# parameters = [ +# Parameter('modifier', ...), +# SP_SELECTOR_PARAMETER, +# ENGINE_PARAMETER, +# SHOOTING_GROUP_PARAMETER, +# REPLACE_TRUE_PARAMETER, +# ], +# name='two-way-shooting', +# ) build_nearest_neighbor_repex_strategy = StrategyCompilerPlugin( builder=Builder(_strategy_name("NearestNeighborRepExStrategy")), diff --git a/paths_cli/tests/compiling/test_schemes.py b/paths_cli/tests/compiling/test_schemes.py index 9d579f61..bf2433cc 100644 --- a/paths_cli/tests/compiling/test_schemes.py +++ b/paths_cli/tests/compiling/test_schemes.py @@ -6,6 +6,7 @@ from unittest.mock import patch from openpathsampling.tests.test_helpers import make_1d_traj from paths_cli.tests.compiling.utils import mock_compiler +from paths_cli.compiling.strategies import ONE_WAY_SHOOTING_STRATEGY_PLUGIN _COMPILERS_LOC = 'paths_cli.compiling.root_compiler._COMPILERS' @@ -42,6 +43,7 @@ def test_build_spring_shooting_scheme(tps_compilers_and_traj): assert isinstance(scheme, paths.SpringShootingMoveScheme) # smoke test that it can build its tree and load init conds scheme.move_decision_tree() + assert len(scheme.strategies) == 2 # includes the global _ = scheme.initial_conditions_from_trajectories(traj) @@ -61,18 +63,22 @@ def test_build_one_way_shooting_scheme(network, tps_compilers_and_traj, _ = scheme.initial_conditions_from_trajectories(traj) -def test_movescheme_plugin(tis_compilers_and_traj, flat_engine): +def test_movescheme_plugin(tis_compilers_and_traj): paths.InterfaceSet._reset() compilers, traj = tis_compilers_and_traj + compilers['strategy'] = mock_compiler( + 'strategy', + type_dispatch={ + 'one-way-shooting': ONE_WAY_SHOOTING_STRATEGY_PLUGIN + }, + named_objs={} + ) dct = {'network': 'tis_network', 'strategies': [ - {'type': ''}, - {'type': ''}, - {'type': ''}, - {'type': ''}, + {'type': 'one-way-shooting', + 'engine': 'flat_engine'}, ]} - pytest.skip() with patch.dict(_COMPILERS_LOC, compilers): scheme = MOVESCHEME_PLUGIN(dct) From 71719d5a17a462fe0ffcc71e72f167bdd7f92ec5 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 21:23:33 -0400 Subject: [PATCH 117/251] more testing/coverage --- paths_cli/compiling/core.py | 4 ++-- paths_cli/tests/commands/test_compile.py | 4 ++++ paths_cli/tests/compiling/test_core.py | 8 ++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 2d1a92c1..4fee04be 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -170,9 +170,9 @@ def __init__(self, builder, parameters, name=None, aliases=None, @property def schema_name(self): if not self.name.endswith(self.compiler_name): - schema_name = f"{self.name}-{self.object_type}" + schema_name = f"{self.name}-{self.compiler_name}" else: - schema_name = self.compiler_name + schema_name = self.name return schema_name def to_json_schema(self, schema_context=None): diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 804d4cd1..85bbf99b 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -14,6 +14,10 @@ def test_import_module_error(): with pytest.raises(MissingIntegrationError, match="Unable to find"): import_module('foobarbazqux') +def test_import_module_install_suggestion(): + with pytest.raises(MissingIntegrationError, match"Please install"): + import_module('foobarbazqux', install='foobarbazqux') + _BASIC_DICT = {'foo': ['bar', {'baz': 10}], 'qux': 'quux', 'froob': 0.5} def _std_dump(module): diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index 4aeca9af..d44ffd00 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -146,6 +146,14 @@ def setup(self): self.instance_builder.compiler_name = 'demo' self.input_dict = {'req_param': "qux", 'opt_override': 25} + def test_schema_name(self): + assert self.instance_builder.schema_name == 'demo' + self.instance_builder.compiler_name = 'foo' + assert self.instance_builder.schema_name == 'demo-foo' + self.instance_builder.name = 'demo-foo' + assert self.instance_builder.schema_name == 'demo-foo' + + def test_to_json_schema(self): # to_json_schema should create a valid JSON schema entry for this # instance builder From 2f9dc3d32b10cbaa6d416e0d71f1f8bd2cd0feab Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 21:27:07 -0400 Subject: [PATCH 118/251] remove unneeded options for InputError can always add this back later if I want to --- paths_cli/compiling/errors.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/paths_cli/compiling/errors.py b/paths_cli/compiling/errors.py index c1d5ce62..8517f250 100644 --- a/paths_cli/compiling/errors.py +++ b/paths_cli/compiling/errors.py @@ -2,12 +2,8 @@ class InputError(Exception): """Raised when users provide bad input in files/interactive sessions. """ @classmethod - def invalid_input(cls, value, attr, type_name=None, name=None): + def invalid_input(cls, value, attr): msg = f"'{value}' is not a valid input for {attr}" - if type_name is not None: - msg += f" in {type_name}" - if name is not None: - msg += f" named {name}" return cls(msg) @classmethod From fdbd7a4d78333f536472414db3253bf390cfd57c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 21:28:46 -0400 Subject: [PATCH 119/251] fix typo --- paths_cli/tests/commands/test_compile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 85bbf99b..d8ad1386 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -15,7 +15,7 @@ def test_import_module_error(): import_module('foobarbazqux') def test_import_module_install_suggestion(): - with pytest.raises(MissingIntegrationError, match"Please install"): + with pytest.raises(MissingIntegrationError, match="Please install"): import_module('foobarbazqux', install='foobarbazqux') _BASIC_DICT = {'foo': ['bar', {'baz': 10}], 'qux': 'quux', 'froob': 0.5} From 43fec221197ac8b1f0b3ae06010623edc63db233 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Sep 2021 23:37:23 -0400 Subject: [PATCH 120/251] Renamings * InstanceBuilder => InstanceCompilerPlugin * CompilerPlugin => CategoryPlugin * Compiler => CategoryCompiler --- paths_cli/commands/compile.py | 6 ++-- paths_cli/compiling/core.py | 15 ++++----- paths_cli/compiling/cvs.py | 7 ++-- paths_cli/compiling/engines.py | 8 ++--- paths_cli/compiling/networks.py | 8 ++--- paths_cli/compiling/plugins.py | 18 +++++------ paths_cli/compiling/root_compiler.py | 30 +++++++++-------- paths_cli/compiling/schemes.py | 7 ++-- paths_cli/compiling/shooting.py | 9 +++--- paths_cli/compiling/strategies.py | 8 ++--- paths_cli/compiling/volumes.py | 7 ++-- paths_cli/tests/compiling/test_core.py | 12 +++---- paths_cli/tests/compiling/test_plugins.py | 4 +-- .../tests/compiling/test_root_compiler.py | 32 ++++++++++--------- paths_cli/tests/compiling/utils.py | 4 +-- 15 files changed, 87 insertions(+), 88 deletions(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index e58c4e7a..0df08d8c 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -4,7 +4,9 @@ from paths_cli.parameters import OUTPUT_FILE from paths_cli.errors import MissingIntegrationError from paths_cli import OPSCommandPlugin -from paths_cli.compiling.plugins import CompilerPlugin, InstanceBuilder +from paths_cli.compiling.plugins import ( + CategoryPlugin, InstanceCompilerPlugin +) from paths_cli.plugin_management import ( NamespacePluginLoader, FilePluginLoader ) @@ -56,7 +58,7 @@ def select_loader(filename): raise RuntimeError(f"Unknown file extension: {ext}") def load_plugins(): - plugin_types = (InstanceBuilder, CompilerPlugin) + plugin_types = (InstanceCompilerPlugin, CategoryPlugin) plugin_loaders = [ NamespacePluginLoader('paths_cli.compiling', plugin_types), FilePluginLoader(app_dir_plugins(posix=False), plugin_types), diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 4fee04be..639d3b3d 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -1,15 +1,11 @@ -import os import json -import importlib import yaml -from collections import namedtuple, abc -import warnings +from collections import namedtuple import logging from .errors import InputError -from .tools import custom_eval from paths_cli.utils import import_thing from paths_cli.plugin_management import OPSPlugin @@ -115,7 +111,7 @@ def __init__(self, builder, *, remapper=None, after_build=None): self.builder = builder def __call__(self, **dct): - # TODO: change this InstanceBuilder.build to make this better + # TODO: change this InstanceCompilerPlugin.build to make this better if isinstance(self.builder, str): module, _, func = self.builder.rpartition('.') builder = import_thing(module, func) @@ -128,7 +124,7 @@ def __call__(self, **dct): return after -class InstanceBuilder(OPSPlugin): +class InstanceCompilerPlugin(OPSPlugin): """ Parameters @@ -165,7 +161,8 @@ def __init__(self, builder, parameters, name=None, aliases=None, self.builder = builder self.builder_name = str(self.builder) self.parameters = parameters - self.logger = logging.getLogger(f"compiler.InstanceBuilder.{builder}") + logger_name = f"compiler.InstanceCompilerPlugin.{builder}" + self.logger = logging.getLogger(logger_name) @property def schema_name(self): @@ -242,7 +239,7 @@ def __call__(self, dct): return obj -class Compiler: +class CategoryCompiler: """Generic compile class; instances for each category""" # error_on_duplicate = False # TODO: temporary error_on_duplicate = True diff --git a/paths_cli/compiling/cvs.py b/paths_cli/compiling/cvs.py index db29e367..82488198 100644 --- a/paths_cli/compiling/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -1,11 +1,12 @@ import os import importlib -from .core import Compiler, InstanceBuilder, custom_eval, Parameter, Builder +from .core import Parameter, Builder +from .tools import custom_eval from .topology import build_topology from .errors import InputError from paths_cli.utils import import_thing -from paths_cli.compiling.plugins import CVCompilerPlugin, CompilerPlugin +from paths_cli.compiling.plugins import CVCompilerPlugin, CategoryPlugin class AllowedPackageHandler: @@ -57,4 +58,4 @@ def cv_kwargs_remapper(dct): 'mdtraj': MDTRAJ_CV_PLUGIN, } -CV_COMPILER = CompilerPlugin(CVCompilerPlugin, aliases=['cvs']) +CV_COMPILER = CategoryPlugin(CVCompilerPlugin, aliases=['cvs']) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 08a0cdc3..3848f824 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -1,10 +1,8 @@ from .topology import build_topology -from .core import Compiler, custom_eval, Builder +from .core import Builder from paths_cli.compiling.core import Parameter from .tools import custom_eval_int -from paths_cli.compiling.plugins import EngineCompilerPlugin, CompilerPlugin - -from paths_cli.errors import MissingIntegrationError +from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin try: from simtk import openmm as mm @@ -53,4 +51,4 @@ def openmm_options(dct): name='openmm', ) -ENGINE_COMPILER = CompilerPlugin(EngineCompilerPlugin, aliases=['engines']) +ENGINE_COMPILER = CategoryPlugin(EngineCompilerPlugin, aliases=['engines']) diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index 9b6815da..a0b0b05f 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -1,11 +1,11 @@ from paths_cli.compiling.core import ( - InstanceBuilder, Compiler, Builder, Parameter + InstanceCompilerPlugin, Builder, Parameter ) from paths_cli.compiling.tools import custom_eval -from paths_cli.compiling.plugins import NetworkCompilerPlugin, CompilerPlugin +from paths_cli.compiling.plugins import NetworkCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for -build_interface_set = InstanceBuilder( +build_interface_set = InstanceCompilerPlugin( builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ Parameter('cv', compiler_for('cv'), description="the collective " @@ -73,4 +73,4 @@ def tis_trans_info(dct): build_tis_network = TIS_NETWORK_PLUGIN -NETWORK_COMPILER = CompilerPlugin(NetworkCompilerPlugin, aliases=['networks']) +NETWORK_COMPILER = CategoryPlugin(NetworkCompilerPlugin, aliases=['networks']) diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index 91fae3b6..975ef0f6 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -1,9 +1,9 @@ -from paths_cli.compiling.core import InstanceBuilder +from paths_cli.compiling.core import InstanceCompilerPlugin from paths_cli.plugin_management import OPSPlugin -class CompilerPlugin(OPSPlugin): +class CategoryPlugin(OPSPlugin): """ - Compiler plugins only need to be made for top-level + Category plugins only need to be made for top-level """ error_on_duplicate = False # TODO: temporary def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), @@ -23,20 +23,20 @@ def __repr__(self): f"{self.aliases})") -class EngineCompilerPlugin(InstanceBuilder): +class EngineCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'engine' -class CVCompilerPlugin(InstanceBuilder): +class CVCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'cv' -class VolumeCompilerPlugin(InstanceBuilder): +class VolumeCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'volume' -class NetworkCompilerPlugin(InstanceBuilder): +class NetworkCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'network' -class SchemeCompilerPlugin(InstanceBuilder): +class SchemeCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'scheme' -class StrategyCompilerPlugin(InstanceBuilder): +class StrategyCompilerPlugin(InstanceCompilerPlugin): compiler_name = 'strategy' diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index f7b61cc4..f66adcad 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -1,10 +1,12 @@ -from paths_cli.compiling.core import Compiler, InstanceBuilder -from paths_cli.compiling.plugins import CompilerPlugin +from paths_cli.compiling.core import ( + CategoryCompiler, InstanceCompilerPlugin +) +from paths_cli.compiling.plugins import CategoryPlugin import logging logger = logging.getLogger(__name__) -class CompilerRegistrationError(Exception): +class CategoryCompilerRegistrationError(Exception): pass @@ -34,7 +36,7 @@ def clean_input_key(key): ### Managing known compilers and aliases to the known compilers ############ -_COMPILERS = {} # mapping: {canonical_name: Compiler} +_COMPILERS = {} # mapping: {canonical_name: CategoryCompiler} _ALIASES = {} # mapping: {alias: canonical_name} # NOTE: _ALIASES does *not* include self-mapping of the canonical names @@ -57,25 +59,23 @@ def _get_compiler(compiler_name): """ if compiler_name is None: if None not in _COMPILERS: - _COMPILERS[None] = Compiler(None, None) + _COMPILERS[None] = CategoryCompiler(None, None) return _COMPILERS[None] canonical_name = _canonical_name(compiler_name) # create a new compiler if none exists if canonical_name is None: canonical_name = compiler_name - _COMPILERS[compiler_name] = Compiler(None, compiler_name) + _COMPILERS[compiler_name] = CategoryCompiler(None, compiler_name) return _COMPILERS[canonical_name] def _register_compiler_plugin(plugin): - DUPLICATE_ERROR = CompilerRegistrationError( + DUPLICATE_ERROR = CategoryCompilerRegistrationError( f"The name {plugin.name} has been reserved by another compiler" ) if plugin.name in _COMPILERS or plugin.name in _ALIASES: raise DUPLICATE_ERROR - compiler = _get_compiler(plugin.name) - # register aliases new_aliases = set(plugin.aliases) - set([plugin.name]) for alias in new_aliases: @@ -83,6 +83,8 @@ def _register_compiler_plugin(plugin): raise DUPLICATE_ERROR _ALIASES[alias] = plugin.name + _ = _get_compiler(plugin.name) + ### Handling delayed loading of compilers ################################## # @@ -90,7 +92,7 @@ def _register_compiler_plugin(plugin): # order for them to be able to access dynamically-loaded plugins, we delay # the loading of the compiler by using a proxy object. -class _CompilerProxy: +class _CategoryCompilerProxy: def __init__(self, compiler_name): self.compiler_name = compiler_name @@ -119,7 +121,7 @@ def compiler_for(compiler_name): compiler_name : str the name of the compiler to use """ - return _CompilerProxy(compiler_name) + return _CategoryCompilerProxy(compiler_name) ### Registering builder plugins and user-facing register_plugins ########### @@ -149,9 +151,9 @@ def register_plugins(plugins): builders = [] compilers = [] for plugin in plugins: - if isinstance(plugin, InstanceBuilder): + if isinstance(plugin, InstanceCompilerPlugin): builders.append(plugin) - elif isinstance(plugin, CompilerPlugin): + elif isinstance(plugin, CategoryPlugin): compilers.append(plugin) for plugin in compilers: @@ -166,7 +168,7 @@ def _sort_user_categories(user_categories): """Organize user input categories into compile order. "Cateogories" are the first-level keys in the user input file (e.g., - 'engines', 'cvs', etc.) There must be one Compiler per category. + 'engines', 'cvs', etc.) There must be one CategoryCompiler per category. """ user_to_canonical = {user_key: _canonical_name(user_key) for user_key in user_categories} diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index dd8d0238..c5923990 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -1,10 +1,9 @@ from paths_cli.compiling.core import ( - InstanceBuilder, Compiler, Builder, Parameter + Builder, Parameter ) from paths_cli.compiling.tools import custom_eval -from paths_cli.compiling.shooting import shooting_selector_compiler from paths_cli.compiling.strategies import SP_SELECTOR_PARAMETER -from paths_cli.compiling.plugins import SchemeCompilerPlugin, CompilerPlugin +from paths_cli.compiling.plugins import SchemeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for @@ -70,4 +69,4 @@ def __call__(self, **dct): name='scheme' ) -SCHEME_COMPILER = CompilerPlugin(SchemeCompilerPlugin, aliases=['schemes']) +SCHEME_COMPILER = CategoryPlugin(SchemeCompilerPlugin, aliases=['schemes']) diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index 977bd17e..1b419190 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -1,11 +1,10 @@ from paths_cli.compiling.core import ( - InstanceBuilder, Compiler, Builder, Parameter + InstanceCompilerPlugin, CategoryCompiler, Builder, Parameter ) from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.tools import custom_eval -import numpy as np -build_uniform_selector = InstanceBuilder( +build_uniform_selector = InstanceCompilerPlugin( builder=Builder('openpathsampling.UniformSelector'), parameters=[], name='uniform', @@ -17,7 +16,7 @@ def remapping_gaussian_stddev(dct): dct['l_0'] = dct.pop('mean') return dct -build_gaussian_selector = InstanceBuilder( +build_gaussian_selector = InstanceCompilerPlugin( builder=Builder('openpathsampling.GaussianBiasSelector', remapper=remapping_gaussian_stddev), parameters=[ @@ -28,7 +27,7 @@ def remapping_gaussian_stddev(dct): name='gaussian', ) -shooting_selector_compiler = Compiler( +shooting_selector_compiler = CategoryCompiler( type_dispatch={ 'uniform': build_uniform_selector, 'gaussian': build_gaussian_selector, diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 6d148a6f..23145cc9 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -1,9 +1,7 @@ -from paths_cli.compiling.core import ( - Compiler, Builder, Parameter -) +from paths_cli.compiling.core import Builder, Parameter from paths_cli.compiling.shooting import shooting_selector_compiler from paths_cli.compiling.plugins import ( - StrategyCompilerPlugin, CompilerPlugin + StrategyCompilerPlugin, CategoryPlugin ) from paths_cli.compiling.root_compiler import compiler_for @@ -100,5 +98,5 @@ def _group_parameter(group_name): name='single-replica-minus', ) -STRATEGY_COMPILER = CompilerPlugin(StrategyCompilerPlugin, +STRATEGY_COMPILER = CategoryPlugin(StrategyCompilerPlugin, aliases=['strategies']) diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index 3c924b0c..cdc1c958 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -1,8 +1,9 @@ import operator import functools -from .core import Compiler, InstanceBuilder, custom_eval, Parameter -from paths_cli.compiling.plugins import VolumeCompilerPlugin, CompilerPlugin +from .core import Parameter +from .tools import custom_eval +from paths_cli.compiling.plugins import VolumeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for # TODO: extra function for volumes should not be necessary as of OPS 2.0 @@ -64,5 +65,5 @@ def cv_volume_build_func(**dct): build_union_volume = UNION_VOLUME_PLUGIN -VOLUME_COMPILER = CompilerPlugin(VolumeCompilerPlugin, aliases=['state', +VOLUME_COMPILER = CategoryPlugin(VolumeCompilerPlugin, aliases=['state', 'states']) diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index d44ffd00..abffc54c 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -8,8 +8,8 @@ from paths_cli.compiling.core import * class MockNamedObject: - # used in the tests for Compiler._compile_dict and - # Compiler.register_object + # used in the tests for CategoryCompiler._compile_dict and + # CategoryCompiler.register_object def __init__(self, data): self.data = data self.name = None @@ -124,7 +124,7 @@ def local_after(obj, dct): builder = Builder(self._callable, after_build=local_after) assert builder(string="foo") == "oofoo" -class TestInstanceBuilder: +class TestInstanceCompilerPlugin: @staticmethod def _builder(req_param, opt_default=10, opt_override=100): return f"{req_param}, {opt_default}, {opt_override}" @@ -137,7 +137,7 @@ def setup(self): Parameter('opt_override', identity, json_type='int', default=100) ] - self.instance_builder = InstanceBuilder( + self.instance_builder = InstanceCompilerPlugin( self._builder, self.parameters, name='demo', @@ -204,9 +204,9 @@ def test_call(self): assert self.instance_builder(self.input_dict) == expected -class TestCompiler: +class TestCategoryCompiler: def setup(self): - self.compiler = Compiler( + self.compiler = CategoryCompiler( {'foo': mock_named_object_factory}, 'foo_compiler' ) diff --git a/paths_cli/tests/compiling/test_plugins.py b/paths_cli/tests/compiling/test_plugins.py index 8a78a09a..e388271d 100644 --- a/paths_cli/tests/compiling/test_plugins.py +++ b/paths_cli/tests/compiling/test_plugins.py @@ -5,8 +5,8 @@ class TestCompilerPlugin: def setup(self): self.plugin_class = Mock(compiler_name='foo') - self.plugin = CompilerPlugin(self.plugin_class) - self.aliased_plugin = CompilerPlugin(self.plugin_class, + self.plugin = CategoryPlugin(self.plugin_class) + self.aliased_plugin = CategoryPlugin(self.plugin_class, aliases=['bar']) @pytest.mark.parametrize('plugin_type', ['basic', 'aliased']) diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 4c41c78c..8cab5786 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -4,27 +4,29 @@ from paths_cli.compiling.root_compiler import ( _canonical_name, _get_compiler, _get_registration_names, _register_builder_plugin, _register_compiler_plugin, - _sort_user_categories, _CompilerProxy, _COMPILERS, _ALIASES + _sort_user_categories, _CategoryCompilerProxy, _COMPILERS, _ALIASES ) from unittest.mock import Mock, PropertyMock, patch -from paths_cli.compiling.core import Compiler, InstanceBuilder -from paths_cli.compiling.plugins import CompilerPlugin +from paths_cli.compiling.core import ( + CategoryCompiler, InstanceCompilerPlugin +) +from paths_cli.compiling.plugins import CategoryPlugin ### FIXTURES ############################################################### @pytest.fixture def foo_compiler(): - return Compiler(None, 'foo') + return CategoryCompiler(None, 'foo') @pytest.fixture def foo_compiler_plugin(): - return CompilerPlugin(Mock(compiler_name='foo'), ['bar']) + return CategoryPlugin(Mock(compiler_name='foo'), ['bar']) @pytest.fixture def foo_baz_builder_plugin(): - builder = InstanceBuilder(lambda: "FOO" , [], name='baz', - aliases=['qux']) + builder = InstanceCompilerPlugin(lambda: "FOO" , [], name='baz', + aliases=['qux']) builder.compiler_name = 'foo' return builder @@ -48,11 +50,11 @@ def test_canonical_name(input_name): patch.dict(BASE + "_ALIASES", aliases) as aliases_: assert _canonical_name(input_name) == "canonical" -class TestCompilerProxy: +class TestCategoryCompilerProxy: def setup(self): - self.compiler = Compiler(None, "foo") + self.compiler = CategoryCompiler(None, "foo") self.compiler.named_objs['bar'] = 'baz' - self.proxy = _CompilerProxy('foo') + self.proxy = _CategoryCompilerProxy('foo') def test_proxy(self): # (NOT API) the _proxy should be the registered compiler @@ -156,17 +158,17 @@ def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): # duplicate_of: existing # duplicate_from: which part of the plugin has the duplicated name if duplicate_from == 'canonical': - plugin = CompilerPlugin(Mock(compiler_name=duplicate_of), + plugin = CategoryPlugin(Mock(compiler_name=duplicate_of), aliases=['foo']) else: - plugin = CompilerPlugin(Mock(compiler_name='foo'), + plugin = CategoryPlugin(Mock(compiler_name='foo'), aliases=[duplicate_of]) compilers = {'canonical': "FOO"} aliases = {'alias': 'canonical'} with patch.dict(COMPILER_LOC, compilers) as compilers_,\ patch.dict(BASE + "_ALIASES", aliases) as aliases_: - with pytest.raises(CompilerRegistrationError): + with pytest.raises(CategoryCompilerRegistrationError): _register_compiler_plugin(plugin) @pytest.mark.parametrize('compiler_exists', [True, False]) @@ -224,10 +226,10 @@ def test_sort_user_categories(): def test_do_compile(): # compiler should correctly compile a basic input dict compilers = { - 'foo': Compiler({ + 'foo': CategoryCompiler({ 'baz': lambda dct: "BAZ" * dct['x'] }, 'foo'), - 'bar': Compiler({ + 'bar': CategoryCompiler({ 'qux': lambda dct: "QUX" * dct['x'] }, 'bar'), } diff --git a/paths_cli/tests/compiling/utils.py b/paths_cli/tests/compiling/utils.py index b181311d..7f753f5e 100644 --- a/paths_cli/tests/compiling/utils.py +++ b/paths_cli/tests/compiling/utils.py @@ -1,9 +1,9 @@ -from paths_cli.compiling.core import Compiler +from paths_cli.compiling.core import CategoryCompiler def mock_compiler(compiler_name, type_dispatch=None, named_objs=None): if type_dispatch is None: type_dispatch = {} - compiler = Compiler(type_dispatch, compiler_name) + compiler = CategoryCompiler(type_dispatch, compiler_name) if named_objs is not None: compiler.named_objs = named_objs return compiler From 4e748999e6a4d1d9a3af5ba84de016c32fa58636 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 8 Sep 2021 12:34:02 -0400 Subject: [PATCH 121/251] Rename: compiler_name => category --- paths_cli/compiling/core.py | 6 ++-- paths_cli/compiling/plugins.py | 14 ++++---- paths_cli/compiling/root_compiler.py | 36 +++++++++---------- paths_cli/tests/compiling/test_core.py | 4 +-- paths_cli/tests/compiling/test_plugins.py | 2 +- .../tests/compiling/test_root_compiler.py | 12 +++---- paths_cli/tests/compiling/utils.py | 4 +-- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 639d3b3d..23237f6e 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -145,7 +145,7 @@ class InstanceCompilerPlugin(OPSPlugin): version of the OPS CLI requires for this functionality """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" - compiler_name = None + category = None error_on_duplicate = False # TODO: temporary def __init__(self, builder, parameters, name=None, aliases=None, requires_ops=(1, 0), requires_cli=(0, 3)): @@ -166,8 +166,8 @@ def __init__(self, builder, parameters, name=None, aliases=None, @property def schema_name(self): - if not self.name.endswith(self.compiler_name): - schema_name = f"{self.name}-{self.compiler_name}" + if not self.name.endswith(self.category): + schema_name = f"{self.name}-{self.category}" else: schema_name = self.name return schema_name diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index 975ef0f6..54bac0dd 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -16,7 +16,7 @@ def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), @property def name(self): - return self.plugin_class.compiler_name + return self.plugin_class.category def __repr__(self): return (f"CompilerPlugin({self.plugin_class.__name__}, " @@ -24,19 +24,19 @@ def __repr__(self): class EngineCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'engine' + category = 'engine' class CVCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'cv' + category = 'cv' class VolumeCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'volume' + category = 'volume' class NetworkCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'network' + category = 'network' class SchemeCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'scheme' + category = 'scheme' class StrategyCompilerPlugin(InstanceCompilerPlugin): - compiler_name = 'strategy' + category = 'strategy' diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index f66adcad..2bd0bbef 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -51,27 +51,27 @@ def _canonical_name(alias): alias_to_canonical.update({pname: pname for pname in _COMPILERS}) return alias_to_canonical.get(alias, None) -def _get_compiler(compiler_name): +def _get_compiler(category): """ - _get_compiler must only be used after the compilers have been + _get_compiler must only be used after the CategoryCompilers have been registered. It will automatically create a compiler for any unknown - ``compiler_name.`` + ``category.`` """ - if compiler_name is None: + if category is None: if None not in _COMPILERS: _COMPILERS[None] = CategoryCompiler(None, None) return _COMPILERS[None] - canonical_name = _canonical_name(compiler_name) + canonical_name = _canonical_name(category) # create a new compiler if none exists if canonical_name is None: - canonical_name = compiler_name - _COMPILERS[compiler_name] = CategoryCompiler(None, compiler_name) + canonical_name = category + _COMPILERS[category] = CategoryCompiler(None, category) return _COMPILERS[canonical_name] def _register_compiler_plugin(plugin): DUPLICATE_ERROR = CategoryCompilerRegistrationError( - f"The name {plugin.name} has been reserved by another compiler" + f"The category '{plugin.name}' has been reserved by another plugin" ) if plugin.name in _COMPILERS or plugin.name in _ALIASES: raise DUPLICATE_ERROR @@ -93,15 +93,15 @@ def _register_compiler_plugin(plugin): # the loading of the compiler by using a proxy object. class _CategoryCompilerProxy: - def __init__(self, compiler_name): - self.compiler_name = compiler_name + def __init__(self, category): + self.category = category @property def _proxy(self): - canonical_name = _canonical_name(self.compiler_name) + canonical_name = _canonical_name(self.category) if canonical_name is None: - raise RuntimeError("No compiler registered for " - f"'{self.compiler_name}'") + raise RuntimeError("No CategoryCompiler registered for " + f"'{self.category}'") return _get_compiler(canonical_name) @property @@ -111,17 +111,17 @@ def named_objs(self): def __call__(self, *args, **kwargs): return self._proxy(*args, **kwargs) -def compiler_for(compiler_name): +def compiler_for(category): """Delayed compiler calling. Use this when you need to use a compiler as the loader for a parameter. Parameters ---------- - compiler_name : str - the name of the compiler to use + category : str + the category to use (:class:`.CategoryCompiler` name or alias) """ - return _CategoryCompilerProxy(compiler_name) + return _CategoryCompilerProxy(category) ### Registering builder plugins and user-facing register_plugins ########### @@ -143,7 +143,7 @@ def _get_registration_names(plugin): return ordered_names def _register_builder_plugin(plugin): - compiler = _get_compiler(plugin.compiler_name) + compiler = _get_compiler(plugin.category) for name in _get_registration_names(plugin): compiler.register_builder(plugin, name) diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index abffc54c..6a4aa215 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -143,12 +143,12 @@ def setup(self): name='demo', aliases=['foo', 'bar'], ) - self.instance_builder.compiler_name = 'demo' + self.instance_builder.category = 'demo' self.input_dict = {'req_param': "qux", 'opt_override': 25} def test_schema_name(self): assert self.instance_builder.schema_name == 'demo' - self.instance_builder.compiler_name = 'foo' + self.instance_builder.category = 'foo' assert self.instance_builder.schema_name == 'demo-foo' self.instance_builder.name = 'demo-foo' assert self.instance_builder.schema_name == 'demo-foo' diff --git a/paths_cli/tests/compiling/test_plugins.py b/paths_cli/tests/compiling/test_plugins.py index e388271d..79b698b4 100644 --- a/paths_cli/tests/compiling/test_plugins.py +++ b/paths_cli/tests/compiling/test_plugins.py @@ -4,7 +4,7 @@ class TestCompilerPlugin: def setup(self): - self.plugin_class = Mock(compiler_name='foo') + self.plugin_class = Mock(category='foo') self.plugin = CategoryPlugin(self.plugin_class) self.aliased_plugin = CategoryPlugin(self.plugin_class, aliases=['bar']) diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 8cab5786..ba707e04 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -21,13 +21,13 @@ def foo_compiler(): @pytest.fixture def foo_compiler_plugin(): - return CategoryPlugin(Mock(compiler_name='foo'), ['bar']) + return CategoryPlugin(Mock(category='foo'), ['bar']) @pytest.fixture def foo_baz_builder_plugin(): builder = InstanceCompilerPlugin(lambda: "FOO" , [], name='baz', aliases=['qux']) - builder.compiler_name = 'foo' + builder.category = 'foo' return builder ### CONSTANTS ############################################################## @@ -63,7 +63,7 @@ def test_proxy(self): def test_proxy_nonexisting(self): # _proxy should error if the no compiler is registered - with pytest.raises(RuntimeError, match="No compiler registered"): + with pytest.raises(RuntimeError, match="No CategoryCompiler"): self.proxy._proxy def test_named_objs(self): @@ -83,7 +83,7 @@ def test_compiler_for_nonexisting(): assert 'foo' not in compilers proxy = compiler_for('foo') assert 'foo' not in compilers - with pytest.raises(RuntimeError, match="No compiler registered"): + with pytest.raises(RuntimeError, match="No CategoryCompiler"): proxy._proxy def test_compiler_for_existing(foo_compiler): @@ -158,10 +158,10 @@ def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): # duplicate_of: existing # duplicate_from: which part of the plugin has the duplicated name if duplicate_from == 'canonical': - plugin = CategoryPlugin(Mock(compiler_name=duplicate_of), + plugin = CategoryPlugin(Mock(category=duplicate_of), aliases=['foo']) else: - plugin = CategoryPlugin(Mock(compiler_name='foo'), + plugin = CategoryPlugin(Mock(category='foo'), aliases=[duplicate_of]) compilers = {'canonical': "FOO"} diff --git a/paths_cli/tests/compiling/utils.py b/paths_cli/tests/compiling/utils.py index 7f753f5e..37ef010f 100644 --- a/paths_cli/tests/compiling/utils.py +++ b/paths_cli/tests/compiling/utils.py @@ -1,9 +1,9 @@ from paths_cli.compiling.core import CategoryCompiler -def mock_compiler(compiler_name, type_dispatch=None, named_objs=None): +def mock_compiler(category, type_dispatch=None, named_objs=None): if type_dispatch is None: type_dispatch = {} - compiler = CategoryCompiler(type_dispatch, compiler_name) + compiler = CategoryCompiler(type_dispatch, category) if named_objs is not None: compiler.named_objs = named_objs return compiler From 03182270ff66f8c21e3794277896e9cbfef98491 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 8 Sep 2021 13:52:17 -0400 Subject: [PATCH 122/251] refactor for reusable get_installed_plugins --- paths_cli/cli.py | 15 +++++---------- paths_cli/commands/compile.py | 26 +++++++++----------------- paths_cli/utils.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 27 deletions(-) diff --git a/paths_cli/cli.py b/paths_cli/cli.py index 5d394008..cb9dac2c 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -15,7 +15,7 @@ from .plugin_management import (FilePluginLoader, NamespacePluginLoader, OPSCommandPlugin) -from .utils import app_dir_plugins +from .utils import get_installed_plugins CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @@ -27,15 +27,10 @@ class OpenPathSamplingCLI(click.MultiCommand): def __init__(self, *args, **kwargs): # the logic here is all about loading the plugins commands = str(pathlib.Path(__file__).parent.resolve() / 'commands') - self.plugin_loaders = [ - FilePluginLoader(commands, OPSCommandPlugin), - FilePluginLoader(app_dir_plugins(posix=False), OPSCommandPlugin), - FilePluginLoader(app_dir_plugins(posix=True), OPSCommandPlugin), - NamespacePluginLoader('paths_cli_plugins', OPSCommandPlugin) - ] - - plugins = sum([loader() for loader in self.plugin_loaders], []) - + plugins = get_installed_plugins( + default_loader=FilePluginLoader(commands, OPSCommandPlugin), + plugin_types=OPSCommandPlugin + ) self._get_command = {} self._sections = collections.defaultdict(list) self.plugins = [] diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 0df08d8c..29282171 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -7,11 +7,9 @@ from paths_cli.compiling.plugins import ( CategoryPlugin, InstanceCompilerPlugin ) -from paths_cli.plugin_management import ( - NamespacePluginLoader, FilePluginLoader -) +from paths_cli.plugin_management import NamespacePluginLoader import importlib -from paths_cli.utils import app_dir_plugins +from paths_cli.utils import get_installed_plugins # this is just to handle a nicer error def import_module(module_name, format_type=None, install=None): @@ -40,7 +38,7 @@ def load_json(f): # https://github.com/toml-lang/toml/issues/553#issuecomment-444814690 # Conflicts with rules preventing mixed types in arrays. This seems to have # relaxed in TOML 1.0, but the toml package doesn't support the 1.0 -# standard. We'll add toml in once the pacakge supports the standard. +# standard. We'll add toml in once the package supports the standard. EXTENSIONS = { 'yaml': load_yaml, @@ -57,17 +55,6 @@ def select_loader(filename): except KeyError: raise RuntimeError(f"Unknown file extension: {ext}") -def load_plugins(): - plugin_types = (InstanceCompilerPlugin, CategoryPlugin) - plugin_loaders = [ - NamespacePluginLoader('paths_cli.compiling', plugin_types), - FilePluginLoader(app_dir_plugins(posix=False), plugin_types), - FilePluginLoader(app_dir_plugins(posix=True), plugin_types), - NamespacePluginLoader('paths_cli_plugins', plugin_types) - ] - plugins = sum([loader() for loader in plugin_loaders], []) - return plugins - @click.command( 'compile', ) @@ -78,7 +65,12 @@ def compile_(input_file, output_file): with open(input_file, mode='r') as f: dct = loader(f) - plugins = load_plugins() + plugin_types = (InstanceCompilerPlugin, CategoryPlugin) + plugins = get_installed_plugins( + default_loader=NamespacePluginLoader('paths_cli.compiling', + plugin_types), + plugin_types=plugin_types + ) register_plugins(plugins) objs = do_compile(dct) diff --git a/paths_cli/utils.py b/paths_cli/utils.py index 8ff1e826..cbfe3cde 100644 --- a/paths_cli/utils.py +++ b/paths_cli/utils.py @@ -1,6 +1,7 @@ import importlib import pathlib import click +from .plugin_management import FilePluginLoader, NamespacePluginLoader def tag_final_result(result, storage, tag='final_conditions'): """Save results to a tag in storage. @@ -31,3 +32,14 @@ def app_dir_plugins(posix): # covered as smoke tests (too OS dependent) return str(pathlib.Path( click.get_app_dir("OpenPathSampling", force_posix=posix) ).resolve() / 'cli-plugins') + + +def get_installed_plugins(default_loader, plugin_types): + loaders = [default_loader] + [ + FilePluginLoader(app_dir_plugins(posix=False), plugin_types), + FilePluginLoader(app_dir_plugins(posix=True), plugin_types), + NamespacePluginLoader('paths_cli_plugins', plugin_types) + + ] + plugins = set(sum([loader() for loader in loaders], [])) + return list(plugins) From 3daa54262302f8c98ae5c7b335862443d06c5fd7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 8 Sep 2021 17:17:46 -0400 Subject: [PATCH 123/251] add docs/compiling.rst --- docs/compiling.rst | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 docs/compiling.rst diff --git a/docs/compiling.rst b/docs/compiling.rst new file mode 100644 index 00000000..b9469667 --- /dev/null +++ b/docs/compiling.rst @@ -0,0 +1,43 @@ + +Compiling +========= + +Let's start with an overview of terminology.: + +* **Compiling**: The process of converting input in a text format, + such as YAML or JSON, into OPS objects. Here we're focused on non-Python + alternatives to using the standard Python interpreter to compile objects. +* **Category**: The base category of object to be created (engine, CV, + volume, etc.) +* **Builder**: We will refer to a builder function, which creates an + instance of a specific + +Everything is created with plugins. There are two types of plugins used in +the ``compiling`` subpackage: + +* ``InstanceCompilerPlugin``: This is what you'll normally work with. These + convert the input text to an instance of a specific OPS object (for + example, an ``OpenMMEngine`` or an ``MDTrajFunctionCV``. In general, you + do not create subclasses of ``InstanceCompilerPlugin`` -- there are + subclasses specialized to engines (``EngineCompilerPlugin``), to CVs + (``CVCompilerPlugin``), etc. You create *instances* of those subclasses. + You write your builder function, and wrap in with in an instance of an + ``InstanceCompilerPlugin``. +* ``CategoryPlugin``: These manage the plugins associated with a given + features. Contributors will almost never need to create one of these. + The only case in which you would need to create one of these is if you're + creating a new *category* of object, i.e., something like an engine where + users will have multiple options at the command line. + +Other useful classes defined in the ``compiling`` subpackage include: + +* ``Builder``: The ``Builder`` class is a convenience for creating builder + functions. It takes either a callable or a string as input, where a string + is treated as a path to an object to import at runtime. It also allows + takes parameters ``remapper`` and ``after_build``, which are callables + that act on the input dictionary before object creation (``remapper``) and + on the created object after object creations (``after_build``). +* ``CategoryCompiler``: This class manages plugins for a given category, as + well as tracking named objects of that type. These are created + automatically when plugins are registered; users do not need to create + these. From 7fce8a5bfc4c04956bb7abd72f7a8c7450ef0ba9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 8 Sep 2021 20:10:31 -0400 Subject: [PATCH 124/251] compiling/__init__; and always error on duplicate --- paths_cli/compiling/__init__.py | 11 +++++++++++ paths_cli/compiling/core.py | 9 +++------ paths_cli/compiling/plugins.py | 1 - paths_cli/plugin_management.py | 6 +----- paths_cli/tests/compiling/test_core.py | 8 +++++++- 5 files changed, 22 insertions(+), 13 deletions(-) diff --git a/paths_cli/compiling/__init__.py b/paths_cli/compiling/__init__.py index 8b137891..bbd3e184 100644 --- a/paths_cli/compiling/__init__.py +++ b/paths_cli/compiling/__init__.py @@ -1 +1,12 @@ +from .core import Parameter, Builder, InstanceCompilerPlugin +from .root_compiler import (clean_input_key, compiler_for, register_plugins, + do_compile) +from .plugins import CategoryPlugin +from .errors import InputError + +# OPS-specific +from .plugins import ( + EngineCompilerPlugin, CVCompilerPlugin, VolumeCompilerPlugin, + NetworkCompilerPlugin, SchemeCompilerPlugin, StrategyCompilerPlugin, +) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 23237f6e..574d404b 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -146,7 +146,6 @@ class InstanceCompilerPlugin(OPSPlugin): """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" category = None - error_on_duplicate = False # TODO: temporary def __init__(self, builder, parameters, name=None, aliases=None, requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) @@ -241,8 +240,6 @@ def __call__(self, dct): class CategoryCompiler: """Generic compile class; instances for each category""" - # error_on_duplicate = False # TODO: temporary - error_on_duplicate = True def __init__(self, type_dispatch, label): if type_dispatch is None: type_dispatch = {} @@ -282,11 +279,11 @@ def register_object(self, obj, name): def register_builder(self, builder, name): if name in self.type_dispatch: if self.type_dispatch[name] is builder: - return # nothing to do here! + # if it's the identical object, just skip it + return msg = (f"'{name}' is already registered " f"with {self.label}") - if self.error_on_duplicate: - raise RuntimeError(msg) + raise RuntimeError(msg) else: self.type_dispatch[name] = builder diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index 54bac0dd..c1b5954e 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -5,7 +5,6 @@ class CategoryPlugin(OPSPlugin): """ Category plugins only need to be made for top-level """ - error_on_duplicate = False # TODO: temporary def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), requires_cli=(0,4)): super().__init__(requires_ops, requires_cli) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index e8f7e152..01f03b32 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -20,7 +20,6 @@ class Plugin(object): tuple representing hte minimum allowed version of the command line interface application """ - error_on_duplicate = True def __init__(self, requires_lib, requires_cli): self.requires_lib = requires_lib self.requires_cli = requires_cli @@ -39,10 +38,7 @@ def attach_metadata(self, location, plugin_type): f"The plugin {repr(self)} has been previously " "registered with different metadata." ) - if self.error_on_duplicate: - raise PluginRegistrationError(msg) - else: - warnings.warn(msg) + raise PluginRegistrationError(msg) self.location = location self.plugin_type = plugin_type diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index 6a4aa215..f4c6718a 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -290,7 +290,7 @@ def test_register_builder(self): assert self.compiler(input_dict) == 10 def test_register_builder_duplicate(self): - # if an attempt is made to registered a builder with a name that is + # if an attempt is made to registere a builder with a name that is # already in use, a RuntimeError is raised orig = self.compiler.type_dispatch['foo'] with pytest.raises(RuntimeError, match="already registered"): @@ -298,6 +298,12 @@ def test_register_builder_duplicate(self): assert self.compiler.type_dispatch['foo'] is orig + def test_register_builder_identical(self): + # if an attempt is made to register a builder that has already been + # registered, nothin happens (since it is already registered) + orig = self.compiler.type_dispatch['foo'] + self.compiler.register_builder(orig, 'foo') + @pytest.mark.parametrize('input_type', ['str', 'dict']) def test_compile(self, input_type): # the compile method should work whether the input is a dict From 9d61206ba01345ae9040bcaf2b71a8a5210a3b71 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 23 Sep 2021 07:55:03 -0400 Subject: [PATCH 125/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/commands/compile.py | 4 ++-- paths_cli/compiling/core.py | 11 ++++------- paths_cli/compiling/root_compiler.py | 2 +- paths_cli/compiling/schemes.py | 2 +- paths_cli/compiling/strategies.py | 2 +- paths_cli/compiling/topology.py | 8 +------- paths_cli/tests/commands/test_compile.py | 9 ++------- paths_cli/tests/compiling/test_cvs.py | 2 +- paths_cli/tests/compiling/test_engines.py | 1 - paths_cli/tests/compiling/test_topology.py | 2 +- paths_cli/tests/compiling/test_volumes.py | 2 +- 11 files changed, 15 insertions(+), 30 deletions(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 29282171..fb55fbd6 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -19,9 +19,9 @@ def import_module(module_name, format_type=None, install=None): if format_type is None: format_type = module_name - msg = "Unable to find a compiler for f{format_type} on your system." + msg = f"Unable to find a compiler for {format_type} on your system." if install is not None: - msg += " Please install f{install} to use this format." + msg += f" Please install {install} to use this format." raise MissingIntegrationError(msg) return mod diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 574d404b..3267d1f2 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -58,10 +58,7 @@ def description(self): @staticmethod def _get_from_loader(loader, attr_name, attr): if attr is None: - try: - attr = getattr(loader, attr_name) - except AttributeError: - pass + attr = getattr(loader, attr_name, None) return attr def __call__(self, *args, **kwargs): @@ -98,7 +95,7 @@ class Builder: remapper : Callable[[Dict], Dict], optional callable to remap the the mapping of ??? after_build : Callable[[Any, Dict], Any], optional - ccallable to update the created object with any additional + callable to update the created object with any additional information from the original dictionary. """ def __init__(self, builder, *, remapper=None, after_build=None): @@ -217,8 +214,8 @@ def compile_attrs(self, dct): except KeyError: raise InputError(f"'{self.builder_name}' missing required " f"parameter '{attr}'") - self.logger.debug(f"{attr}: {input_dct[attr]}") - new_dct[attr] = func(input_dct[attr]) + self.logger.debug(f"{attr}: {value}") + new_dct[attr] = func(value) optionals = set(self.optional_attributes) & set(dct) diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index 2bd0bbef..edf9c2c8 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -168,7 +168,7 @@ def _sort_user_categories(user_categories): """Organize user input categories into compile order. "Cateogories" are the first-level keys in the user input file (e.g., - 'engines', 'cvs', etc.) There must be one CategoryCompiler per category. + 'engines', 'cvs', etc.). There must be one CategoryCompiler per category. """ user_to_canonical = {user_key: _canonical_name(user_key) for user_key in user_categories} diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index c5923990..22229654 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -43,7 +43,7 @@ def __call__(self, **dct): scheme = builder(**dct) for strat in strategies: scheme.append(strat) - # self.logger.debug(f"strategies: {scheme.strategies}") + self.logger.debug(f"strategies: {scheme.strategies}") return scheme diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 23145cc9..9d00de5e 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -34,7 +34,7 @@ def _group_parameter(group_name): SP_SELECTOR_PARAMETER, ENGINE_PARAMETER, SHOOTING_GROUP_PARAMETER, - Parameter('replace', bool, default=True) + REPLACE_TRUE_PARAMETER ], name='one-way-shooting', ) diff --git a/paths_cli/compiling/topology.py b/paths_cli/compiling/topology.py index 08425d7d..238a043f 100644 --- a/paths_cli/compiling/topology.py +++ b/paths_cli/compiling/topology.py @@ -4,16 +4,10 @@ def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" - # from paths_cli.compiling.engines import engine_compiler engine_compiler = compiler_for('engine') if dct in engine_compiler.named_objs: engine = engine_compiler.named_objs[dct] - try: - return engine.topology - except AttributeError: # no-cov - # how could this happen? passing is correct, to raise the - # InputError from MultiStrategyBuilder, but how to test? - pass + return getattr(engine, 'topology', None) def get_topology_from_file(dct): """If given the name of a file, use that to create the topology""" diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index d8ad1386..6efb5cda 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -29,7 +29,7 @@ def test_loaders(mod_name, tmpdir): dump_getter = { 'json': _std_dump, 'yaml': _std_dump, - 'toml': _std_dump, + # 'toml': _std_dump, }[mod_name] loader = { 'json': load_json, @@ -83,7 +83,7 @@ def test_compile(ad_openmm, test_data_dir): traceback.print_tb(result.exc_info[2]) print(result.exception) print(result.exc_info) - print(result.output) + print(result.output) assert result.exit_code == 0 assert os.path.exists(str(ad_openmm / 'setup.db')) import openpathsampling as paths @@ -100,8 +100,3 @@ def test_compile(ad_openmm, test_data_dir): from openpathsampling.experimental.storage.monkey_patches import unpatch paths = unpatch(paths) paths.InterfaceSet.simstore = False - import importlib - importlib.reload(paths.netcdfplus) - importlib.reload(paths.collectivevariable) - importlib.reload(paths.collectivevariables) - importlib.reload(paths) diff --git a/paths_cli/tests/compiling/test_cvs.py b/paths_cli/tests/compiling/test_cvs.py index 166aa428..8f53bb27 100644 --- a/paths_cli/tests/compiling/test_cvs.py +++ b/paths_cli/tests/compiling/test_cvs.py @@ -39,5 +39,5 @@ def test_build_mdtraj_function_cv(self): def test_bad_mdtraj_function_name(self): yml = self.yml.format(kwargs=self.kwargs, func="foo") dct = yaml.load(yml, Loader=yaml.FullLoader) - with pytest.raises(InputError): + with pytest.raises(InputError, match="foo"): cv = MDTRAJ_CV_PLUGIN(dct) diff --git a/paths_cli/tests/compiling/test_engines.py b/paths_cli/tests/compiling/test_engines.py index 249e574c..f6079ea7 100644 --- a/paths_cli/tests/compiling/test_engines.py +++ b/paths_cli/tests/compiling/test_engines.py @@ -18,7 +18,6 @@ def setup(self): "integrator: integrator.xml", "topology: ad.pdb", "n_steps_per_frame: 10", "n_frames_max: 10000" ]) - pass def teardown(self): os.chdir(self.cwd) diff --git a/paths_cli/tests/compiling/test_topology.py b/paths_cli/tests/compiling/test_topology.py index 523c7d53..58345aca 100644 --- a/paths_cli/tests/compiling/test_topology.py +++ b/paths_cli/tests/compiling/test_topology.py @@ -28,5 +28,5 @@ def test_build_topology_fail(self): patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' compilers = {'engine': mock_compiler('engine')} with patch.dict(patch_loc, compilers): - with pytest.raises(InputError): + with pytest.raises(InputError, match="foo"): topology = build_topology('foo') diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index 3eabdd3c..19550899 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -21,7 +21,7 @@ def setup(self): } self.func = { - 'inline': "\n ".join(["name: foo", "type: mdtraj"]), # TODO + 'inline': "\n ".join(["name: foo", "type: mdtraj"]), 'external': 'foo' } From 823ef0c5202dc2d963eb40b7490db1564c4e159c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 23 Sep 2021 13:04:00 -0400 Subject: [PATCH 126/251] Update paths_cli/tests/compiling/test_volumes.py Co-authored-by: Sander Roet --- paths_cli/tests/compiling/test_volumes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index 19550899..9e49f6dc 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -90,7 +90,7 @@ def test_build_combo_volume(self, combo, inline): descriptions = {"A": desc_A, "B": desc_B} subvol_yaml = [' - A', ' - B'] - yml = "\n".join(["type: {combo}", "name: bar", "subvolumes:"] + yml = "\n".join([f"type: {combo}", "name: bar", "subvolumes:"] + subvol_yaml) combo_class = {'union': paths.UnionVolume, From 454ff28cb566a17cc081710b6d12d0e46ea39440 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 23 Sep 2021 14:01:35 -0400 Subject: [PATCH 127/251] some fixes from review --- paths_cli/commands/compile.py | 5 ++++- paths_cli/compiling/core.py | 11 +++++++++-- paths_cli/compiling/cvs.py | 4 ++-- paths_cli/compiling/engines.py | 4 ++-- paths_cli/compiling/schemes.py | 1 - paths_cli/compiling/shooting.py | 4 ++-- paths_cli/tests/commands/test_compile.py | 1 - paths_cli/tests/commands/test_contents.py | 2 -- paths_cli/tests/compiling/test_engines.py | 3 ++- paths_cli/tests/compiling/test_shooting.py | 3 ++- 10 files changed, 23 insertions(+), 15 deletions(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index fb55fbd6..7cfdf2f5 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -10,6 +10,7 @@ from paths_cli.plugin_management import NamespacePluginLoader import importlib from paths_cli.utils import get_installed_plugins +from paths_cli.commands.contents import report_all_tables # this is just to handle a nicer error def import_module(module_name, format_type=None, install=None): @@ -74,9 +75,11 @@ def compile_(input_file, output_file): register_plugins(plugins) objs = do_compile(dct) - print(objs) + print(f"Saving {len(objs)} user-specified objects to {output_file}....") storage = OUTPUT_FILE.get(output_file) storage.save(objs) + report_all_tables(storage) + PLUGIN = OPSCommandPlugin( command=compile_, diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 3267d1f2..26f92c83 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -85,7 +85,9 @@ class Builder: Additionally, this class provides hooks for functions that run before or after the main builder function. This allows many objects to be built by - implementing simple functions and hooking themn together with Builder. + implementing simple functions and hooking themn together with Builder, + which can act as a general adaptor between user input and the underlying + functions. Parameters ---------- @@ -93,7 +95,12 @@ class Builder: primary callable to build an object, or string representing the fully-qualified path to a callable remapper : Callable[[Dict], Dict], optional - callable to remap the the mapping of ??? + Callable to remap the input dict (as parsed from user input) to a + dict of keyword arguments for the builder function. This can be + used, e.g., to rename user-facing keys to the internally-used names, + or to add structure if, for example, inputs to the builder function + require structures (such as a dict) that are flattened in user + input, or vice-versa. after_build : Callable[[Any, Dict], Any], optional callable to update the created object with any additional information from the original dictionary. diff --git a/paths_cli/compiling/cvs.py b/paths_cli/compiling/cvs.py index 82488198..9a78699d 100644 --- a/paths_cli/compiling/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -21,7 +21,7 @@ def __call__(self, source): # on ImportError, we leave the error unchanged return func -def cv_kwargs_remapper(dct): +def _cv_kwargs_remapper(dct): kwargs = dct.pop('kwargs', {}) dct.update(kwargs) return dct @@ -31,7 +31,7 @@ def cv_kwargs_remapper(dct): MDTRAJ_CV_PLUGIN = CVCompilerPlugin( builder=Builder('openpathsampling.experimental.storage.' 'collective_variables.MDTrajFunctionCV', - remapper=cv_kwargs_remapper), + remapper=_cv_kwargs_remapper), parameters=[ Parameter('topology', build_topology), Parameter('func', AllowedPackageHandler('mdtraj'), diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 3848f824..66d7aec1 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -20,7 +20,7 @@ def load_openmm_xml(filename): return obj -def openmm_options(dct): +def _openmm_options(dct): n_steps_per_frame = dct.pop('n_steps_per_frame') n_frames_max = dct.pop('n_frames_max') options = {'n_steps_per_frame': n_steps_per_frame, @@ -46,7 +46,7 @@ def openmm_options(dct): OPENMM_PLUGIN = EngineCompilerPlugin( builder=Builder('openpathsampling.engines.openmm.Engine', - remapper=openmm_options), + remapper=_openmm_options), parameters=OPENMM_PARAMETERS, name='openmm', ) diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index 22229654..0a0d446f 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -43,7 +43,6 @@ def __call__(self, **dct): scheme = builder(**dct) for strat in strategies: scheme.append(strat) - self.logger.debug(f"strategies: {scheme.strategies}") return scheme diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index 1b419190..be474785 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -10,7 +10,7 @@ name='uniform', ) -def remapping_gaussian_stddev(dct): +def _remapping_gaussian_stddev(dct): dct['alpha'] = 0.5 / dct.pop('stddev')**2 dct['collectivevariable'] = dct.pop('cv') dct['l_0'] = dct.pop('mean') @@ -18,7 +18,7 @@ def remapping_gaussian_stddev(dct): build_gaussian_selector = InstanceCompilerPlugin( builder=Builder('openpathsampling.GaussianBiasSelector', - remapper=remapping_gaussian_stddev), + remapper=_remapping_gaussian_stddev), parameters=[ Parameter('cv', compiler_for('cv')), Parameter('mean', custom_eval), diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 6efb5cda..883015e2 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -84,7 +84,6 @@ def test_compile(ad_openmm, test_data_dir): print(result.exception) print(result.exc_info) print(result.output) - assert result.exit_code == 0 assert os.path.exists(str(ad_openmm / 'setup.db')) import openpathsampling as paths from openpathsampling.experimental.storage import ( diff --git a/paths_cli/tests/commands/test_contents.py b/paths_cli/tests/commands/test_contents.py index ca8d2380..8bad0bec 100644 --- a/paths_cli/tests/commands/test_contents.py +++ b/paths_cli/tests/commands/test_contents.py @@ -38,7 +38,6 @@ def test_contents(tps_fixture): f"Snapshots: {2*len(init_conds[0])} unnamed items", "" ] assert_click_success(results) - assert results.exit_code == 0 assert results.output.split('\n') == expected for truth, beauty in zip(expected, results.output.split('\n')): assert truth == beauty @@ -75,7 +74,6 @@ def test_contents_table(tps_fixture, table): }[table] assert results.output.split("\n") == expected assert_click_success(results) - assert results.exit_code == 0 def test_contents_table_error(): runner = CliRunner() diff --git a/paths_cli/tests/compiling/test_engines.py b/paths_cli/tests/compiling/test_engines.py index f6079ea7..aebe71b8 100644 --- a/paths_cli/tests/compiling/test_engines.py +++ b/paths_cli/tests/compiling/test_engines.py @@ -3,6 +3,7 @@ import os from paths_cli.compiling.engines import * +from paths_cli.compiling.engines import _openmm_options from paths_cli.compiling.errors import InputError import openpathsampling as paths @@ -53,7 +54,7 @@ def test_load_openmm_xml(self, tmpdir): def test_openmm_options(self): dct = yaml.load(self.yml, yaml.FullLoader) - dct = openmm_options(dct) + dct = _openmm_options(dct) assert dct == {'type': 'openmm', 'name': 'engine', 'system': 'system.xml', 'integrator': 'integrator.xml', diff --git a/paths_cli/tests/compiling/test_shooting.py b/paths_cli/tests/compiling/test_shooting.py index a6001f86..c8d0da3a 100644 --- a/paths_cli/tests/compiling/test_shooting.py +++ b/paths_cli/tests/compiling/test_shooting.py @@ -1,6 +1,7 @@ import pytest from paths_cli.compiling.shooting import * +from paths_cli.compiling.shooting import _remapping_gaussian_stddev import openpathsampling as paths from paths_cli.tests.compiling.utils import mock_compiler @@ -13,7 +14,7 @@ def test_remapping_gaussian_stddev(cv_and_states): cv, _, _ = cv_and_states dct = {'cv': cv, 'mean': 1.0, 'stddev': 2.0} expected = {'collectivevariable': cv, 'l_0': 1.0, 'alpha': 0.125} - results = remapping_gaussian_stddev(dct) + results = _remapping_gaussian_stddev(dct) assert results == expected def test_build_gaussian_selector(cv_and_states): From 571d283c5c4a6e4ae440a3abb6e3b512ecadee9c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 24 Sep 2021 11:22:59 -0400 Subject: [PATCH 128/251] redo reload modules after test OPS#1065 isn't in releases yet --- paths_cli/tests/commands/test_compile.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/paths_cli/tests/commands/test_compile.py b/paths_cli/tests/commands/test_compile.py index 883015e2..f4d1f0da 100644 --- a/paths_cli/tests/commands/test_compile.py +++ b/paths_cli/tests/commands/test_compile.py @@ -99,3 +99,10 @@ def test_compile(ad_openmm, test_data_dir): from openpathsampling.experimental.storage.monkey_patches import unpatch paths = unpatch(paths) paths.InterfaceSet.simstore = False + # TODO: this lines won't be necessary once OPS releases contain + # openpathsampling/openpathsampling#1065 + import importlib + importlib.reload(paths.netcdfplus) + importlib.reload(paths.collectivevariable) + importlib.reload(paths.collectivevariables) + importlib.reload(paths) From 8904fd3b7606977d98b4f7ac73b968808cf552aa Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 25 Sep 2021 10:54:01 -0400 Subject: [PATCH 129/251] complete skipped tests in compiling/test_core --- paths_cli/tests/compiling/test_core.py | 62 +++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index f4c6718a..6c8be07e 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -6,6 +6,8 @@ import numpy.testing as npt from paths_cli.compiling.core import * +from paths_cli.compiling import compiler_for +from paths_cli.tests.compiling.utils import mock_compiler class MockNamedObject: # used in the tests for CategoryCompiler._compile_dict and @@ -187,10 +189,30 @@ def test_compile_attrs(self): assert compile_attrs(self.input_dict) == expected def test_compile_attrs_compiler_integration(self): - # compile_attrs gives the same object as already existing in a - # compiler if one of the parameters uses that compiler to load a - # named object - pytest.skip() + # compile_attrs gives the existing named object (is-identity) if one + # of the parameters uses that compiler to load a named object + user_input = {'foo': 'named_foo'} + # full_input = {'foo': {'name': 'named_foo', + # 'type': 'baz', + # 'bar': 'should see this'}} + bar_plugin = InstanceCompilerPlugin( + builder=lambda foo: 'in bar: should not see this', + parameters=[Parameter('foo', compiler_for('foo'))], + ) + foo_plugin = InstanceCompilerPlugin( + builder=lambda: 'in foo: should not see this', + parameters=[], + ) + named_objs = {'named_foo': 'should see this'} + type_dispatch = {'baz': foo_plugin} + PATCH_LOC = 'paths_cli.compiling.root_compiler._COMPILERS' + compiler = mock_compiler('foo', type_dispatch=type_dispatch, + named_objs=named_objs) + with patch.dict(PATCH_LOC, {'foo': compiler}): + compiled = bar_plugin.compile_attrs(user_input) + + # maps attr name 'foo' to the previous existing object + assert compiled == {'foo': 'should see this'} def test_compile_attrs_missing_required(self): # an InputError should be raised if a required parameter is missing @@ -304,16 +326,44 @@ def test_register_builder_identical(self): orig = self.compiler.type_dispatch['foo'] self.compiler.register_builder(orig, 'foo') + @staticmethod + def _validate_obj(obj, input_type): + if input_type == 'str': + assert obj == 'bar' + elif input_type == 'dict': + assert obj.data == 'qux' + else: + raise RuntimeError("Error in test setup") + @pytest.mark.parametrize('input_type', ['str', 'dict']) def test_compile(self, input_type): # the compile method should work whether the input is a dict # representing an object to be compiled or string name for an # already-compiled object - pytest.skip() + self._mock_register_obj() + input_data = { + 'str': 'foo', + 'dict': {'type': 'foo', 'data': 'qux'} + }[input_type] + obj = self.compiler.compile(input_data) + self._validate_obj(obj, input_type) @pytest.mark.parametrize('input_type', ['str', 'dict']) @pytest.mark.parametrize('as_list', [True, False]) def test_call(self, input_type, as_list): # the call method should work whether the input is a single object # or a list of objects (as well as whether string or dict) - pytest.skip() + self._mock_register_obj() + input_data = { + 'str': 'foo', + 'dict': {'type': 'foo', 'data': 'qux'} + }[input_type] + if as_list: + input_data = [input_data] + + obj = self.compiler(input_data) + if as_list: + assert isinstance(obj, list) + assert len(obj) == 1 + obj = obj[0] + self._validate_obj(obj, input_type) From fb518ba17fe5f7d65e274a79f1424ebd95cdcda6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 25 Sep 2021 12:17:17 -0400 Subject: [PATCH 130/251] finish skipped tests in test_root_compiler --- paths_cli/compiling/root_compiler.py | 4 +-- .../tests/compiling/test_root_compiler.py | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index edf9c2c8..359b1a84 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -108,8 +108,8 @@ def _proxy(self): def named_objs(self): return self._proxy.named_objs - def __call__(self, *args, **kwargs): - return self._proxy(*args, **kwargs) + def __call__(self, dct): + return self._proxy(dct) def compiler_for(category): """Delayed compiler calling. diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index ba707e04..8e125547 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -6,6 +6,7 @@ _register_builder_plugin, _register_compiler_plugin, _sort_user_categories, _CategoryCompilerProxy, _COMPILERS, _ALIASES ) +from paths_cli.tests.compiling.utils import mock_compiler from unittest.mock import Mock, PropertyMock, patch from paths_cli.compiling.core import ( CategoryCompiler, InstanceCompilerPlugin @@ -21,7 +22,7 @@ def foo_compiler(): @pytest.fixture def foo_compiler_plugin(): - return CategoryPlugin(Mock(category='foo'), ['bar']) + return CategoryPlugin(Mock(category='foo', __name__='foo'), ['bar']) @pytest.fixture def foo_baz_builder_plugin(): @@ -73,7 +74,17 @@ def test_named_objs(self): def test_call(self): # the `__call__` method should work in the proxy - pytest.skip() + def _bar_dispatch(dct): + return dct['baz'] * dct['qux'] + + foo_compiler = mock_compiler( + category='foo', + type_dispatch={'bar': _bar_dispatch}, + ) + proxy = _CategoryCompilerProxy('foo') + user_input = {'type': 'bar', 'baz': 'baz', 'qux': 2} + with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): + assert proxy(user_input) == "bazbaz" def test_compiler_for_nonexisting(): # if nothing is ever registered with the compiler, then compiler_for @@ -141,7 +152,9 @@ def test_get_registration_names(canonical, aliases, expected): def test_register_compiler_plugin(foo_compiler_plugin): # _register_compiler_plugin should register compilers that don't exist compilers = {} - with patch.dict(COMPILER_LOC, compilers): + aliases = {} + with patch.dict(COMPILER_LOC, compilers) as _compiler, \ + patch.dict(BASE + "_ALIASES", aliases) as _alias: assert 'foo' not in compilers _register_compiler_plugin(foo_compiler_plugin) assert 'foo' in _COMPILERS @@ -199,9 +212,18 @@ def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): assert builder.called_once_with(foo_baz_builder_plugin) assert compiler.called_once_with(foo_compiler_plugin) -def test_register_plugins_integration(): +def test_register_plugins_integration(foo_compiler_plugin, + foo_baz_builder_plugin): # register_plugins should correctly register plugins - pytest.skip() + compilers = {} + aliases = {} + with patch.dict(COMPILER_LOC, compilers) as _compiler, \ + patch.dict(BASE + "_ALIASES", aliases) as _alias: + assert 'foo' not in _COMPILERS + register_plugins([foo_compiler_plugin, foo_baz_builder_plugin]) + assert 'foo' in _COMPILERS + type_dispatch = _COMPILERS['foo'].type_dispatch + assert type_dispatch['baz'] is foo_baz_builder_plugin def test_sort_user_categories(): # sorted user categories should match the expected compile order From e463087ea68fcb445008d465ef92099e93c502d8 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 11:20:26 -0400 Subject: [PATCH 131/251] add compat.openmm to handle OpenMM imports --- paths_cli/compat/openmm.py | 18 +++++++++++++++ paths_cli/compiling/engines.py | 9 ++------ paths_cli/tests/compiling/test_cvs.py | 2 +- paths_cli/tests/compiling/test_engines.py | 9 ++++---- paths_cli/tests/wizard/conftest.py | 27 +++++++---------------- paths_cli/tests/wizard/test_openmm.py | 13 +---------- paths_cli/wizard/openmm.py | 12 +--------- 7 files changed, 36 insertions(+), 54 deletions(-) create mode 100644 paths_cli/compat/openmm.py diff --git a/paths_cli/compat/openmm.py b/paths_cli/compat/openmm.py new file mode 100644 index 00000000..5a7a5700 --- /dev/null +++ b/paths_cli/compat/openmm.py @@ -0,0 +1,18 @@ +# should be able to remove this try block when we drop OpenMM < 7.6 +try: + import openmm as mm + from openmm import unit +except ImportError: + try: + from simtk import openmm as mm + from simtk import unit + except ImportError: + HAS_OPENMM = False + mm = None + u = None + else: # -no-cov- + HAS_OPENMM = True +else: + HAS_OPENMM = True + + diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 66d7aec1..83ed9929 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -4,14 +4,9 @@ from .tools import custom_eval_int from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin -try: - from simtk import openmm as mm -except ImportError: - HAS_OPENMM = False -else: - HAS_OPENMM = True - def load_openmm_xml(filename): + from openpathsampling.integration_tools import HAS_OPENMM + from openpathsampling.integration_tools import openmm as mm if not HAS_OPENMM: # -no-cov- raise RuntimeError("OpenMM does not seem to be installed") diff --git a/paths_cli/tests/compiling/test_cvs.py b/paths_cli/tests/compiling/test_cvs.py index 8f53bb27..bef767e5 100644 --- a/paths_cli/tests/compiling/test_cvs.py +++ b/paths_cli/tests/compiling/test_cvs.py @@ -24,7 +24,7 @@ def setup(self): self.kwargs = "indices: [[4, 6, 8, 14]]" def test_build_mdtraj_function_cv(self): - _ = pytest.importorskip('simtk.unit') + _ = pytest.importorskip('mdtraj') yml = self.yml.format(kwargs=self.kwargs, func="compute_dihedrals") dct = yaml.load(yml, Loader=yaml.FullLoader) cv = MDTRAJ_CV_PLUGIN(dct) diff --git a/paths_cli/tests/compiling/test_engines.py b/paths_cli/tests/compiling/test_engines.py index aebe71b8..c1348dc9 100644 --- a/paths_cli/tests/compiling/test_engines.py +++ b/paths_cli/tests/compiling/test_engines.py @@ -5,12 +5,12 @@ from paths_cli.compiling.engines import * from paths_cli.compiling.engines import _openmm_options from paths_cli.compiling.errors import InputError +from paths_cli.compat.openmm import HAS_OPENMM, mm, unit import openpathsampling as paths from openpathsampling.engines import openmm as ops_openmm import mdtraj as md - class TestOpenMMEngineBuilder(object): def setup(self): self.cwd = os.getcwd() @@ -24,9 +24,9 @@ def teardown(self): os.chdir(self.cwd) def _create_files(self, tmpdir): - mm = pytest.importorskip('simtk.openmm') + if not HAS_OPENMM: + pytest.skip('openmm not installed') openmmtools = pytest.importorskip('openmmtools') - unit = pytest.importorskip('simtk.unit') ad = openmmtools.testsystems.AlanineDipeptideVacuum() integrator = openmmtools.integrators.VVVRIntegrator( 300*unit.kelvin, 1.0/unit.picosecond, 2.0*unit.femtosecond @@ -41,7 +41,8 @@ def _create_files(self, tmpdir): trj.save(os.path.join(tmpdir, "ad.pdb")) def test_load_openmm_xml(self, tmpdir): - mm = pytest.importorskip('simtk.openmm') + if not HAS_OPENMM: + pytest.skip('openmm not installed') self._create_files(tmpdir) os.chdir(tmpdir) for fname in ['system.xml', 'integrator.xml', 'ad.pdb']: diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index 1e240976..38a55af0 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -3,20 +3,7 @@ import openpathsampling as paths import mdtraj as md -# should be able to remove this try block when we drop OpenMM < 7.6 -try: - import openmm as mm - from openmm import unit as u -except ImportError: - try: - from simtk import openmm as mm - from simtk import unit as u - except ImportError: - HAS_OPENMM = False - else: # -no-cov- - HAS_OPENMM = True -else: - HAS_OPENMM = True +from paths_cli.compat.openmm import HAS_OPENMM, mm, unit # TODO: this isn't wizard-specific, and should be moved somwhere more # generally useful (like, oh, maybe openpathsampling.tests.fixtures?) @@ -34,12 +21,14 @@ def ad_openmm(tmpdir): md = pytest.importorskip('mdtraj') testsystem = openmmtools.testsystems.AlanineDipeptideVacuum() integrator = openmmtools.integrators.VVVRIntegrator( - 300 * u.kelvin, - 1.0 / u.picosecond, - 2.0 * u.femtosecond + 300 * unit.kelvin, + 1.0 / unit.picosecond, + 2.0 * unit.femtosecond + ) + traj = md.Trajectory( + [testsystem.positions.value_in_unit(unit.nanometer)], + topology=testsystem.mdtraj_topology ) - traj = md.Trajectory([testsystem.positions.value_in_unit(u.nanometer)], - topology=testsystem.mdtraj_topology) files = {'integrator.xml': integrator, 'system.xml': testsystem.system} with tmpdir.as_cwd(): diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 9c94e869..3dd98af9 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -8,18 +8,7 @@ _load_openmm_xml, _load_topology, openmm, OPENMM_SERIALIZATION_URL ) -# should be able to remove this try block when we drop OpenMM < 7.6 -try: - import openmm as mm -except ImportError: - try: - from simtk import openmm as mm - except ImportError: - HAS_OPENMM = False - else: - HAS_OPENMM = True # -no-cov- -else: - HAS_OPENMM = True +from paths_cli.compat.openmm import mm, HAS_OPENMM def test_helper_url(): assert_url(OPENMM_SERIALIZATION_URL) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 7341070f..22c786a2 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -2,17 +2,7 @@ from paths_cli.wizard.core import get_object # should be able to simplify this try block when we drop OpenMM < 7.6 -try: - import openmm as mm -except ImportError: - try: - from simtk import openmm as mm - except ImportError: - HAS_OPENMM = False - else: # -no-cov- - HAS_OPENMM = True -else: - HAS_OPENMM = True +from paths_cli.compat.openmm import mm, HAS_OPENMM OPENMM_SERIALIZATION_URL=( "http://docs.openmm.org/latest/api-python/generated/" From 2dad4d81a54c49a49ddc47aff31cdb88df63af92 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 11:23:49 -0400 Subject: [PATCH 132/251] fix name of unit when None --- paths_cli/compat/openmm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/compat/openmm.py b/paths_cli/compat/openmm.py index 5a7a5700..743a7ad5 100644 --- a/paths_cli/compat/openmm.py +++ b/paths_cli/compat/openmm.py @@ -9,7 +9,7 @@ except ImportError: HAS_OPENMM = False mm = None - u = None + unit = None else: # -no-cov- HAS_OPENMM = True else: From 0b106e6b06b0d9be003d46c40e6bbf2dbc7d857a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 11:32:28 -0400 Subject: [PATCH 133/251] fix skiptest --- paths_cli/tests/compiling/test_cvs.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/paths_cli/tests/compiling/test_cvs.py b/paths_cli/tests/compiling/test_cvs.py index bef767e5..8b378965 100644 --- a/paths_cli/tests/compiling/test_cvs.py +++ b/paths_cli/tests/compiling/test_cvs.py @@ -11,6 +11,8 @@ import MDTrajFunctionCV import mdtraj as md +from paths_cli.compat.openmm import HAS_OPENMM + class TestMDTrajFunctionCV: def setup(self): @@ -24,7 +26,8 @@ def setup(self): self.kwargs = "indices: [[4, 6, 8, 14]]" def test_build_mdtraj_function_cv(self): - _ = pytest.importorskip('mdtraj') + if not HAS_OPENMM: + pytest.skip("Requires OpenMM for ops_load_trajectory") yml = self.yml.format(kwargs=self.kwargs, func="compute_dihedrals") dct = yaml.load(yml, Loader=yaml.FullLoader) cv = MDTRAJ_CV_PLUGIN(dct) From ff4bc7806406ba31cb6e6b67d859e0e17d14f3e9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 11:52:43 -0400 Subject: [PATCH 134/251] use custom_eval_int_strict_pos --- paths_cli/compiling/engines.py | 6 +++--- paths_cli/compiling/tools.py | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 83ed9929..461c294e 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -1,7 +1,7 @@ from .topology import build_topology from .core import Builder from paths_cli.compiling.core import Parameter -from .tools import custom_eval_int +from .tools import custom_eval_int_strict_pos from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin def load_openmm_xml(filename): @@ -32,9 +32,9 @@ def _openmm_options(dct): description="XML file with the OpenMM system"), Parameter('integrator', load_openmm_xml, json_type='string', description="XML file with the OpenMM integrator"), - Parameter('n_steps_per_frame', custom_eval_int, + Parameter('n_steps_per_frame', custom_eval_int_strict_pos, description="number of MD steps per saved frame"), - Parameter("n_frames_max", custom_eval_int, + Parameter("n_frames_max", custom_eval_int_strict_pos, description=("maximum number of frames before aborting " "trajectory")), ] diff --git a/paths_cli/compiling/tools.py b/paths_cli/compiling/tools.py index 55ec19bc..28778b38 100644 --- a/paths_cli/compiling/tools.py +++ b/paths_cli/compiling/tools.py @@ -22,6 +22,12 @@ def custom_eval_int(obj, named_objs=None): val = custom_eval(obj, named_objs) return int(val) +def custom_eval_int_strict_pos(obj, named_objs=None): + val = custom_eval_int(obj, named_objs) + if val <= 0: + raise InputError(f"Positive integer required; found {val}") + return val + class UnknownAtomsError(RuntimeError): pass From 63a0f337684c01f882308db2f01fb2cc6f37713e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 13:00:56 -0400 Subject: [PATCH 135/251] bring coverage back to 100% --- paths_cli/compat/openmm.py | 2 +- paths_cli/compiling/tools.py | 1 + paths_cli/tests/compiling/test_tools.py | 8 ++++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/paths_cli/compat/openmm.py b/paths_cli/compat/openmm.py index 743a7ad5..ccd53174 100644 --- a/paths_cli/compat/openmm.py +++ b/paths_cli/compat/openmm.py @@ -5,7 +5,7 @@ except ImportError: try: from simtk import openmm as mm - from simtk import unit + from simtk import unit # -no-cov- except ImportError: HAS_OPENMM = False mm = None diff --git a/paths_cli/compiling/tools.py b/paths_cli/compiling/tools.py index 28778b38..c31a6e0d 100644 --- a/paths_cli/compiling/tools.py +++ b/paths_cli/compiling/tools.py @@ -1,4 +1,5 @@ import numpy as np +from .errors import InputError def custom_eval(obj, named_objs=None): """Parse user input to allow simple math. diff --git a/paths_cli/tests/compiling/test_tools.py b/paths_cli/tests/compiling/test_tools.py index 2015436f..8b9c06d6 100644 --- a/paths_cli/tests/compiling/test_tools.py +++ b/paths_cli/tests/compiling/test_tools.py @@ -2,6 +2,7 @@ import numpy.testing as npt import numpy as np import math +from paths_cli.compiling.errors import InputError from paths_cli.compiling.tools import * @@ -13,6 +14,13 @@ def test_custom_eval(expr, expected): npt.assert_allclose(custom_eval(expr), expected) +def test_custom_eval_int(): + assert custom_eval_int('5') == 5 + +def test_custom_eval_int_strict_pos_error(): + with pytest.raises(InputError): + custom_eval_int_strict_pos(-1) + def test_mdtraj_parse_atomlist_bad_input(): with pytest.raises(TypeError, match="not integers"): mdtraj_parse_atomlist("['a', 'b']", n_atoms=2) From 3fd2509c19895f807c2c202cf84b400134643964 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 26 Sep 2021 13:53:16 -0400 Subject: [PATCH 136/251] don't use endswith on possible None --- paths_cli/compiling/core.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 26f92c83..c4ce6d64 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -169,7 +169,9 @@ def __init__(self, builder, parameters, name=None, aliases=None, @property def schema_name(self): - if not self.name.endswith(self.category): + # TODO: not exactly clear what the schema name should be if category + # is None -- don't think it actually exists in that case + if self.category and not self.name.endswith(self.category): schema_name = f"{self.name}-{self.category}" else: schema_name = self.name From 854137c2beb886dbe7c7c7cd16e1d32bff666a41 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 27 Sep 2021 12:27:42 -0400 Subject: [PATCH 137/251] cleanup per review comments --- paths_cli/compiling/engines.py | 3 +-- paths_cli/tests/compiling/test_tools.py | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 461c294e..28c067ef 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -5,8 +5,7 @@ from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin def load_openmm_xml(filename): - from openpathsampling.integration_tools import HAS_OPENMM - from openpathsampling.integration_tools import openmm as mm + from paths_cli.compat.openmm import HAS_OPENMM, mm if not HAS_OPENMM: # -no-cov- raise RuntimeError("OpenMM does not seem to be installed") diff --git a/paths_cli/tests/compiling/test_tools.py b/paths_cli/tests/compiling/test_tools.py index 8b9c06d6..20e954e9 100644 --- a/paths_cli/tests/compiling/test_tools.py +++ b/paths_cli/tests/compiling/test_tools.py @@ -17,9 +17,10 @@ def test_custom_eval(expr, expected): def test_custom_eval_int(): assert custom_eval_int('5') == 5 -def test_custom_eval_int_strict_pos_error(): +@pytest.mark.parametrize('inp', [0, -1]) +def test_custom_eval_int_strict_pos_error(inp): with pytest.raises(InputError): - custom_eval_int_strict_pos(-1) + custom_eval_int_strict_pos(inp) def test_mdtraj_parse_atomlist_bad_input(): with pytest.raises(TypeError, match="not integers"): From a43b2c003bcf50aea6c0ac0d2938dc72a5c35ab7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 27 Sep 2021 12:28:15 -0400 Subject: [PATCH 138/251] Update paths_cli/compiling/core.py Co-authored-by: Sander Roet --- paths_cli/compiling/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index c4ce6d64..188c8f5c 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -85,7 +85,7 @@ class Builder: Additionally, this class provides hooks for functions that run before or after the main builder function. This allows many objects to be built by - implementing simple functions and hooking themn together with Builder, + implementing simple functions and hooking them together with Builder, which can act as a general adaptor between user input and the underlying functions. From f1df73f1a2c4e18eda84e3657d72c02f3be4056f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 30 Sep 2021 12:15:20 -0400 Subject: [PATCH 139/251] Wizard rewrite for engines --- paths_cli/wizard/engines.py | 25 ++++++- paths_cli/wizard/helper.py | 50 +++++++++++++- paths_cli/wizard/load_from_ops.py | 2 + paths_cli/wizard/openmm.py | 69 ++++++++++++++++++- paths_cli/wizard/parameters.py | 2 +- paths_cli/wizard/wizard.py | 41 ++++++++++- paths_cli/wizard/wrap_compilers.py | 106 +++++++++++++++++++++++++++++ 7 files changed, 284 insertions(+), 11 deletions(-) create mode 100644 paths_cli/wizard/wrap_compilers.py diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index f3b0c4b4..05e3b0ed 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -4,6 +4,8 @@ ) from functools import partial +from paths_cli.wizard.wrap_compilers import WrapCategory + SUPPORTED_ENGINES = {} for module in [openmm]: SUPPORTED_ENGINES.update(module.SUPPORTED) @@ -24,8 +26,27 @@ def engines(wizard): engine = SUPPORTED_ENGINES[eng_name](wizard) return engine +_ENGINE_HELP = "An engine describes how you'll do the actual dynamics." +ENGINE_PLUGIN = WrapCategory( + name='engines', + ask="What will you use for the underlying engine?", + intro=("Let's make an engine. " + _ENGINE_HELP + " Most of the " + "details are given in files that depend on the specific " + "type of engine."), + helper=_ENGINE_HELP +) + + if __name__ == "__main__": from paths_cli.wizard import wizard - wiz = wizard.Wizard({'engines': ('engines', 1, '=')}) - wiz.run_wizard() + wiz = wizard.Wizard([]) + choices = { + "OpenMM": openmm.OPENMM_PLUGIN, + load_label: partial(load_from_ops, + store_name='engines', + obj_name='engine') + } + ENGINE_PLUGIN.choices = choices + engine = ENGINE_PLUGIN(wiz) + print(engine) diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index e8464259..0f4a9912 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -15,11 +15,29 @@ def force_exit(cmd, ctx): 'q': raise_quit, 'quit': raise_quit, '!q': force_exit, + '!quit': force_exit, 'restart': raise_restart, 'fuck': raise_restart, # easter egg ;-) # TODO: add ls, perhaps others? } +_QUIT_HELP = ("The !quit command (or !q) will quit the wizard, but only " + "after it checks whether you want to save the objects you've " + "created.") +_FORCE_QUIT_HELP = ("The !!quit command (or !!q) will immediately force " + "quit the wizard.") +_RESTART_HELP = ("The !restart command will restart the creation of the " + "current object in the wizard. Note that for objects that " + "contain other objects (such as state definitions, which " + "may be made of multiple volumes) this will restart from " + "the outermost object (e.g., the state).") +COMMAND_HELP_STR = { + 'q': _QUIT_HELP, + 'quit': _QUIT_HELP, + '!q': _FORCE_QUIT_HELP, + '!quit': _FORCE_QUIT_HELP, + 'restart': _RESTART_HELP, +} class Helper: def __init__(self, help_func): @@ -29,14 +47,42 @@ def __init__(self, help_func): help_func = lambda args, ctx: text self.helper = help_func self.commands = HELPER_COMMANDS.copy() # allows per-instance custom + self.command_help_str = COMMAND_HELP_STR.copy() + self.listed_commands = ['quit', '!quit', 'restart'] + + def command_help(self, help_args, context): + if help_args == "": + result = "The following commands can be used:\n" + result += "\n".join([f"* !{cmd}" + for cmd in self.listed_commands]) + else: + try: + result = self.command_help_str[help_args] + except KeyError: + result = f"No help for !{help_args}." + + return result + def run_command(self, command, context): cmd_split = command.split() key = cmd_split[0] args = " ".join(cmd_split[1:]) - return self.commands[key](args, context) + try: + cmd = self.commands[key] + except KeyError: + return f"Unknown command: {key}" + + return cmd(args, context) def get_help(self, help_args, context): + # TODO: add default help (for ?!, etc) + if help_args != "" and help_args[0] == '!': + return self.command_help(help_args[1:], context) + + if self.helper is None: + return "Sorry, no help available here." + return self.helper(help_args, context) def __call__(self, user_input, context=None): @@ -45,5 +91,3 @@ def __call__(self, user_input, context=None): func = {'?': self.get_help, '!': self.run_command}[starter] return func(args, context) - - diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index a65ac313..6308aeaf 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -45,3 +45,5 @@ def load_from_ops(wizard, store_name, obj_name): storage = _get_ops_storage(wizard) obj = _get_ops_object(wizard, storage, store_name, obj_name) return obj + + diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 9e94e677..ce7d4d2a 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -13,6 +13,69 @@ SimpleParameter, InstanceBuilder, load_custom_eval, CUSTOM_EVAL_ERROR ) +from paths_cli.wizard.wrap_compilers import ( + WizardProxyParameter, WrapCompilerWizardPlugin +) + +from paths_cli.compiling.engines import OPENMM_PLUGIN as OPENMM_COMPILING + +_where_is_xml = "Where is the XML file for your OpenMM {obj_type}?" +_xml_help = ( + "You can write OpenMM objects like systems and integrators to XML " + "files using the XMLSerializer class. Learn more here:\n" + + OPENMM_SERIALIZATION_URL +) + +_OPENMM_REQS = ("To use OpenMM in the OPS wizard, you'll need to provide a " + "file with topology information (usually a PDB), as well " + "as XML versions of your OpenMM integrator and system " + "objects.") + +OPENMM_PLUGIN = WrapCompilerWizardPlugin( + name="OpenMM", + category="engines", + description=("OpenMM is an GPU-accelerated library for molecular " + "dynamics. " + _OPENMM_REQS), + intro="Great! OpenMM gives you a lots of flexibility" + _OPENMM_REQS, + parameters=[ + WizardProxyParameter( + name='topology', + ask="Where is a PDB file describing your system?", + helper=("We use a PDB file to set up the OpenMM simulation. " + "Please provide the path to your PDB file."), + error=FILE_LOADING_ERROR_MSG, + ), + WizardProxyParameter( + name='integrator', + ask=_where_is_xml.format(obj_type='integrator'), + helper=_xml_help, + error=FILE_LOADING_ERROR_MSG, + ), + WizardProxyParameter( + name='system', + ask=_where_is_xml.format(obj_type="system"), + helper=_xml_help, + error=FILE_LOADING_ERROR_MSG, + ), + WizardProxyParameter( + name='n_steps_per_frame', + ask="How many MD steps per saved frame?", + helper=("Your integrator has a time step associated with it. " + "We need to know how many time steps between the " + "frames we actually save during the run."), + error=CUSTOM_EVAL_ERROR, + ), + WizardProxyParameter( + name='n_frames_max', + ask="How many frames before aborting a trajectory?", + helper=None, + error=CUSTOM_EVAL_ERROR, + ), + ], + compiler_plugin=OPENMM_COMPILING, +) + + ### TOPOLOGY def _topology_loader(filename): @@ -33,8 +96,7 @@ def _topology_loader(filename): _xml_help = ( "You can write OpenMM objects like systems and integrators to XML " "files using the XMLSerializer class. Learn more here:\n" - "http://docs.openmm.org/latest/api-python/generated/" - "simtk.openmm.openmm.XmlSerializer.html" + + OPENMM_SERIALIZATION_URL ) # TODO: switch to using load_openmm_xml from input file setup def _openmm_xml_loader(xml): @@ -174,5 +236,6 @@ def openmm(wizard): if __name__ == "__main__": from paths_cli.wizard.wizard import Wizard wizard = Wizard([]) - engine = openmm_builder(wizard) + # engine = openmm_builder(wizard) + engine = OPENMM_PLUGIN(wizard) print(engine) diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 134a1cfc..c804cc26 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -1,4 +1,4 @@ -from paths_cli.parsing.tools import custom_eval +from paths_cli.compiling.tools import custom_eval import importlib from paths_cli.wizard.helper import Helper diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index dc4fa03e..25774eee 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -72,8 +72,8 @@ def _speak(self, content, preface): def ask(self, question, options=None, default=None, helper=None): result = self.console.input("🧙 " + question + " ") self.console.print() - if helper and result.startswith("?"): - helper(self, result) + if helper and result[0] in ["?", "!"]: + self.say(helper(result)) return return result @@ -90,6 +90,32 @@ def bad_input(self, content, preface="👺 "): # just changes the default preface; maybe print 1st line red? self.say(content, preface) + @get_object + def ask_enumerate_dict(self, question, options, helper=None): + self.say(question) + opt_string = "\n".join([f" {(i+1):>3}. {opt}" + for i, opt in enumerate(options)]) + self.say(opt_string, preface=" "*3) + choice = self.ask("Please select an option:", helper=helper) + + # indicates input was handled by helper -- so ask again + if choice is None: + return None + + # select by string + if choice in options: + return options[choice] + + # select by number + try: + num = int(choice) - 1 + result = list(options.values())[num] + except Exception: + self.bad_input(f"Sorry, '{choice}' is not a valid option.") + result = None + + return result + def ask_enumerate(self, question, options): """Ask the user to select from a list of options""" self.say(question) @@ -113,6 +139,17 @@ def ask_enumerate(self, question, options): return result + @get_object + def ask_load(self, question, loader, helper=None): + as_str = self.ask(question, helper=helper) + try: + result = loader(as_str) + except Exception as e: + self.exception(f"Sorry, I couldn't understand the input " + f"'{as_str}'.", e) + return + return result + # this should match the args for wizard.ask @get_object def ask_custom_eval(self, question, options=None, default=None, diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py new file mode 100644 index 00000000..f275fd82 --- /dev/null +++ b/paths_cli/wizard/wrap_compilers.py @@ -0,0 +1,106 @@ +NO_PARAMETER_LOADED = object() + +from .helper import Helper + +class WizardProxyParameter: + def __init__(self, name, ask, helper, error): + self.name = name + self.ask = ask + self.helper = Helper(helper) + self.error = error + self.loader = None + + def register_loader(self, loader): + if self.loader is not None: + raise RuntimeError("Already have a loader for this parameter") + self.loader = loader + + def _process_input(self, user_str): + if user_str[0] in ['?', '!']: + pass + + def __call__(self, wizard): + obj = NO_PARAMETER_LOADED + while obj is NO_PARAMETER_LOADED: + obj = wizard.ask_load(self.ask, self.loader, self.helper) + + return obj + + +class WrapCompilerWizardPlugin: + def __init__(self, name, category, parameters, compiler_plugin, + prerequisite=None, intro=None, description=None): + self.parameters = parameters + self.compiler_plugin = compiler_plugin + self.prerequisite = prerequisite + self.intro = intro + self.description = description + loaders = {p.name: p.loader for p in self.compiler_plugin.parameters} + for param in self.parameters: + param.register_loader(loaders[param.name]) + + def __call__(self, wizard): + if self.intro is not None: + wizard.say(self.intro) + + if self.prerequisite is not None: + self.prerequisite(wizard) + + dct = {param.name: param(wizard) for param in self.parameters} + result = self.compiler_plugin.builder(**dct) + + return result + +class CategoryHelpFunc: + def __init__(self, category, basic_help=None): + self.category = category + if basic_help is None: + basic_help = f"Sorry, no help available for {category.name}." + self.basic_help = basic_help + + def __call__(self, help_args, context): + if not help_args: + return self.basic_help + help_dict = {} + for num, (name, obj) in enumerate(self.category.choices.items()): + try: + help_str = obj.description + except Exception: + help_str = f"Sorry, no help available for '{name}'." + help_dict[str(num+1)] = help_str + help_dict[name] = help_str + + try: + result = help_dict[help_args] + except KeyError: + result = None + return result + + +class WrapCategory: + def __init__(self, name, ask, helper=None, intro=None): + self.name = name + if isinstance(intro, str): + intro = [intro] + self.intro = intro + self.ask = ask + if helper is None: + helper = Helper(CategoryHelpFunc(self)) + if isinstance(helper, str): + helper = Helper(CategoryHelpFunc(self, helper)) + + self.helper = helper + self.choices = {} + + def register_plugin(self, plugin): + self.choices[plugin.name] = plugin + + def __call__(self, wizard): + for line in self.intro: + wizard.say(line) + + selected = wizard.ask_enumerate_dict(self.ask, self.choices, + self.helper) + obj = selected(wizard) + return obj + From ac15c65bb5d6bc8e94b4fe092db46b9e6d316673 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 2 Oct 2021 13:57:01 -0400 Subject: [PATCH 140/251] clean up engines; rewrite cvs --- paths_cli/wizard/cvs.py | 166 ++++++++++++++++--- paths_cli/wizard/engines.py | 19 +-- paths_cli/wizard/load_from_ops.py | 11 ++ paths_cli/wizard/openmm.py | 246 +++++------------------------ paths_cli/wizard/parameters.py | 188 +++++++++++++++------- paths_cli/wizard/wizard.py | 9 +- paths_cli/wizard/wrap_compilers.py | 38 ++--- 7 files changed, 357 insertions(+), 320 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 9b7f4ed9..a156a405 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,12 +1,22 @@ from paths_cli.wizard.engines import engines from paths_cli.compiling.tools import custom_eval, mdtraj_parse_atomlist -from paths_cli.wizard.load_from_ops import load_from_ops +from paths_cli.wizard.load_from_ops import load_from_ops, LoadFromOPS from paths_cli.wizard.load_from_ops import LABEL as _load_label from paths_cli.wizard.core import get_object +import paths_cli.wizard from functools import partial +from collections import namedtuple import numpy as np +from paths_cli.wizard.parameters import ( + WizardObjectPlugin, FromWizardPrerequisite +) + +from paths_cli.wizard.helper import Helper + +from paths_cli.wizard.wrap_compilers import WrapCategory + try: import mdtraj as md except ImportError: # no-cov @@ -19,18 +29,6 @@ "[{list_range_natoms}]" ) -def _atom_indices_parameter(kwarg_name, cv_does_str, cv_uses_str, n_atoms): - return Parameter( - name=kwarg_name, - ask=f"Which atoms do you want to {cv_user_str}?", - loader=..., - helper=_ATOM_INDICES_HELP_STR.format( - list_range_natoms=str(list(range(n_atoms))) - ), - error="Sorry, I didn't understand '{user_str}'.", - autohelp=True - ) - def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov wizard.say("You should specify atom indices enclosed in double " "brackets, e.g, [" + str(list(range(n_atoms))) + "]") @@ -44,6 +42,22 @@ def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov # "chain in your topology, even if its name in the PDB file " # "is B.") +TOPOLOGY_CV_PREREQ = FromWizardPrerequisite( + name='topology', + create_func=paths_cli.wizard.engines.ENGINE_PLUGIN, + category='engines', + obj_name='engine', + n_required=1, + say_create=("Hey, you need to define an MD engine before you create " + "CVs that refer to it. Let's do that now!"), + say_select=("You have defined multiple engines, and need to pick one " + "to use to get a topology for your CV."), + say_finish="Now let's get back to defining your CV.", + load_func=lambda engine: engine.topology +) + + + def _get_topology(wizard): from paths_cli.wizard.engines import engines topology = None @@ -71,7 +85,9 @@ def _get_topology(wizard): @get_object def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): - helper = partial(mdtraj_atom_helper, n_atoms=n_atoms) + helper = Helper(_ATOM_INDICES_HELP_STR.format( + list_range_natoms=list(range(n_atoms)) + )) # switch to get_custom_eval atoms_str = wizard.ask(f"Which atoms do you want to {cv_user_str}?", helper=helper) @@ -84,6 +100,85 @@ def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): return arr +_MDTrajParams = namedtuple("_MDTrajParams", ['period', 'n_atoms', + 'kwarg_name', 'cv_user_str']) + +def _mdtraj_cv_builder(wizard, prereqs, func_name): + from openpathsampling.experimental.storage.collective_variables import \ + MDTrajFunctionCV + dct = TOPOLOGY_CV_PREREQ(wizard) + topology = dct['topology'][0] + # TODO: add helpers + (period_min, period_max), n_atoms, kwarg_name, cv_user_str = { + 'compute_distances': _MDTrajParams( + period=(None, None), + n_atoms=2, + kwarg_name='atom_pairs', + cv_user_str="measure the distance between" + ), + 'compute_angles': _MDTrajParams( + period=(-np.pi, np.pi), + n_atoms=3, + kwarg_name='angle_indices', + cv_user_str="use to define the angle" + ), + 'compute_dihedrals': _MDTrajParams( + period=(-np.pi, np.pi), + n_atoms=4, + kwarg_name='indices', + cv_user_str="use to define the dihedral angle" + ) + }[func_name] + + indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms, + cv_user_str=cv_user_str) + func = getattr(md, func_name) + kwargs = {kwarg_name: indices} + return MDTrajFunctionCV(func, topology, period_min=period_min, + period_max=period_max, **kwargs) + +_MDTRAJ_INTRO = "We'll make a CV that measures the {user_str}." + +def _mdtraj_summary(cv): + func = cv.func + topology = cv.topology + indices = list(cv.kwargs.values())[0] + atoms_str = " ".join([str(topology.mdtraj.atom(i)) for i in indices[0]]) + summary = (f" Function: {func.__name__}\n" + f" Atoms: {atoms_str}\n" + f" Topology: {repr(topology.mdtraj)}") + return summary + +if HAS_MDTRAJ: + MDTRAJ_DISTANCE = WizardObjectPlugin( + name='Distance', + category='cvs', + builder=partial(_mdtraj_cv_builder, func_name='compute_distances'), + prerequisite=TOPOLOGY_CV_PREREQ, + intro=_MDTRAJ_INTRO.format(user_str="distance between two atoms"), + description="This CV will calculate the distance between two atoms.", + summary=_mdtraj_summary, + ) + + MDTRAJ_ANGLE = WizardObjectPlugin( + name="Angle", + category='cvs', + builder=partial(_mdtraj_cv_builder, func_name='compute_angles'), + prerequisite=TOPOLOGY_CV_PREREQ, + intro=_MDTRAJ_INTRO.format(user_str="angle made by three atoms"), + description=..., + summary=_mdtraj_summary, + ) + + MDTRAJ_DIHEDRAL = WizardObjectPlugin( + name="Dihedral", + category='cvs', + builder=partial(_mdtraj_cv_builder, func_name='compute_dihedrals'), + prerequisite=TOPOLOGY_CV_PREREQ, + intro=_MDTRAJ_INTRO, + description=..., + summary=_mdtraj_summary, + ) def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, kwarg_name, n_atoms, period): @@ -142,7 +237,7 @@ def dihedral(wizard): def rmsd(wizard): raise NotImplementedError("RMSD has not yet been implemented") -def coordinate(wizard): +def coordinate(wizard, prereqs=None): # TODO: atom_index should be from wizard.ask_custom_eval from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV @@ -167,25 +262,44 @@ def coordinate(wizard): cv = CoordinateFunctionCV(lambda snap: snap.xyz[atom_index][coord]) return cv +COORDINATE_CV = WizardObjectPlugin( + name="Coordinate", + category="cvs", + builder=coordinate, + description=("Create a CV based on a specific coordinate (for a " + "specific atom)."), +) + +CV_FROM_FILE = LoadFromOPS('cvs', 'CV') SUPPORTED_CVS = {} if HAS_MDTRAJ: SUPPORTED_CVS.update({ - 'Distance': distance, - 'Angle': angle, - 'Dihedral': dihedral, + 'Distance': MDTRAJ_DISTANCE, + 'Angle': MDTRAJ_ANGLE, + 'Dihedral': MDTRAJ_DIHEDRAL, # 'RMSD': rmsd, }) SUPPORTED_CVS.update({ - 'Coordinate': coordinate, + 'Coordinate': COORDINATE_CV, # 'Python script': ..., - _load_label: partial(load_from_ops, - store_name='cvs', - obj_name='CV'), + _load_label: CV_FROM_FILE, }) +CV_PLUGIN = WrapCategory( + name='cvs', + intro=("You'll need to describe your system in terms of collective " + "variables (CVs). We'll use these variables to define things " + "like stable states."), + ask="What kind of CV do you want to define?", + helper=("CVs are functions that map a snapshot to a number. If you " + "have MDTraj installed, then I can automatically create " + "several common CVs, such as distances and dihedrals. But " + "you can also create your own and load it from a file.") +) + def cvs(wizard): wizard.say("You'll need to describe your system in terms of " "collective variables (CVs). We'll use these to define " @@ -196,7 +310,13 @@ def cvs(wizard): cv = SUPPORTED_CVS[cv_type](wizard) return cv +# TEMPORARY +for plugin in [MDTRAJ_DISTANCE, MDTRAJ_ANGLE, MDTRAJ_DIHEDRAL, + COORDINATE_CV, CV_FROM_FILE]: + CV_PLUGIN.register_plugin(plugin) + if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard wiz = Wizard({}) - cvs(wiz) + cv = CV_PLUGIN(wiz) + print(cv) diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 05e3b0ed..5fab9a8e 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -1,5 +1,6 @@ import paths_cli.wizard.openmm as openmm from paths_cli.wizard.load_from_ops import ( + LoadFromOPS, load_from_ops, LABEL as load_label ) from functools import partial @@ -7,8 +8,6 @@ from paths_cli.wizard.wrap_compilers import WrapCategory SUPPORTED_ENGINES = {} -for module in [openmm]: - SUPPORTED_ENGINES.update(module.SUPPORTED) SUPPORTED_ENGINES[load_label] = partial(load_from_ops, store_name='engines', @@ -36,17 +35,19 @@ def engines(wizard): helper=_ENGINE_HELP ) +ENGINE_FROM_FILE = LoadFromOPS('engines', 'engine') + +# TEMPORARY +from .openmm import OPENMM_PLUGIN +plugins = [OPENMM_PLUGIN, ENGINE_FROM_FILE] +for plugin in plugins: + ENGINE_PLUGIN.register_plugin(plugin) + if __name__ == "__main__": from paths_cli.wizard import wizard wiz = wizard.Wizard([]) - choices = { - "OpenMM": openmm.OPENMM_PLUGIN, - load_label: partial(load_from_ops, - store_name='engines', - obj_name='engine') - } - ENGINE_PLUGIN.choices = choices + # TODO: normally will need to register plugins from this file first engine = ENGINE_PLUGIN(wiz) print(engine) diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 6308aeaf..ede4fc65 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -47,3 +47,14 @@ def load_from_ops(wizard, store_name, obj_name): return obj +class LoadFromOPS: + def __init__(self, category, obj_name): + self.category = category + self.name = "Load existing from OPS file" + self.obj_name = obj_name + + def __call__(self, wizard): + wizard.say("Okay, we'll load it from an OPS file.") + storage = _get_ops_storage(wizard) + obj = _get_ops_storage(wizard, storage, self.category, + self.obj_name) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index ce7d4d2a..d6dd0761 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -10,11 +10,7 @@ ) from paths_cli.wizard.parameters import ( - SimpleParameter, InstanceBuilder, load_custom_eval, CUSTOM_EVAL_ERROR -) - -from paths_cli.wizard.wrap_compilers import ( - WizardProxyParameter, WrapCompilerWizardPlugin + ProxyParameter, WizardParameterObjectPlugin ) from paths_cli.compiling.engines import OPENMM_PLUGIN as OPENMM_COMPILING @@ -31,207 +27,49 @@ "as XML versions of your OpenMM integrator and system " "objects.") -OPENMM_PLUGIN = WrapCompilerWizardPlugin( - name="OpenMM", - category="engines", - description=("OpenMM is an GPU-accelerated library for molecular " - "dynamics. " + _OPENMM_REQS), - intro="Great! OpenMM gives you a lots of flexibility" + _OPENMM_REQS, - parameters=[ - WizardProxyParameter( - name='topology', - ask="Where is a PDB file describing your system?", - helper=("We use a PDB file to set up the OpenMM simulation. " - "Please provide the path to your PDB file."), - error=FILE_LOADING_ERROR_MSG, - ), - WizardProxyParameter( - name='integrator', - ask=_where_is_xml.format(obj_type='integrator'), - helper=_xml_help, - error=FILE_LOADING_ERROR_MSG, - ), - WizardProxyParameter( - name='system', - ask=_where_is_xml.format(obj_type="system"), - helper=_xml_help, - error=FILE_LOADING_ERROR_MSG, - ), - WizardProxyParameter( - name='n_steps_per_frame', - ask="How many MD steps per saved frame?", - helper=("Your integrator has a time step associated with it. " - "We need to know how many time steps between the " - "frames we actually save during the run."), - error=CUSTOM_EVAL_ERROR, - ), - WizardProxyParameter( - name='n_frames_max', - ask="How many frames before aborting a trajectory?", - helper=None, - error=CUSTOM_EVAL_ERROR, - ), - ], - compiler_plugin=OPENMM_COMPILING, -) - - -### TOPOLOGY - -def _topology_loader(filename): - import openpathsampling as paths - return paths.engines.openmm.snapshot_from_pdb(filename).topology - -topology_parameter = SimpleParameter( - name='topology', - ask="Where is a PDB file describing your system?", - loader=_topology_loader, - helper=None, # TODO - error=FILE_LOADING_ERROR_MSG, -) - -### INTEGRATOR/SYSTEM (XML FILES) - -_where_is_xml = "Where is the XML file for your OpenMM {obj_type}?" -_xml_help = ( - "You can write OpenMM objects like systems and integrators to XML " - "files using the XMLSerializer class. Learn more here:\n" - + OPENMM_SERIALIZATION_URL -) -# TODO: switch to using load_openmm_xml from input file setup -def _openmm_xml_loader(xml): - with open(xml, 'r') as xml_f: - data = xml_f.read() - return mm.XmlSerializer.deserialize(data) - -integrator_parameter = SimpleParameter( - name='integrator', - ask=_where_is_xml.format(obj_type='integrator'), - loader=_openmm_xml_loader, - helper=_xml_help, - error=FILE_LOADING_ERROR_MSG -) - -system_parameter = SimpleParameter( - name='system', - ask=_where_is_xml.format(obj_type='system'), - loader=_openmm_xml_loader, - helper=_xml_help, - error=FILE_LOADING_ERROR_MSG -) - -# these two are generic, and should be kept somewhere where they can be -# reused -n_steps_per_frame_parameter = SimpleParameter( - name="n_steps_per_frame", - ask="How many MD steps per saved frame?", - loader=load_custom_eval(int), - error=CUSTOM_EVAL_ERROR, -) - -n_frames_max_parameter = SimpleParameter( - name='n_frames_max', - ask="How many frames before aborting a trajectory?", - loader=load_custom_eval(int), - error=CUSTOM_EVAL_ERROR, -) - -# this is taken directly from the input files setup; should find a universal -# place for this (and probably other loaders) -def openmm_options(dct): - n_steps_per_frame = dct.pop('n_steps_per_frame') - n_frames_max = dct.pop('n_frames_max') - options = {'n_steps_per_frame': n_steps_per_frame, - 'n_frames_max': n_frames_max} - dct['options'] = options - return dct - -openmm_builder = InstanceBuilder( - parameters=[ - topology_parameter, - system_parameter, - integrator_parameter, - n_steps_per_frame_parameter, - n_frames_max_parameter, - ], - category='engine', - cls='openpathsampling.engines.openmm.Engine', - intro="You're doing an OpenMM engine", - help_str=None, - remapper=openmm_options -) - - - -##################################################################### - -def _openmm_serialization_helper(wizard, user_input): # no-cov - wizard.say("You can write OpenMM objects like systems and integrators " - "to XML files using the XMLSerializer class. Learn more " - f"here: \n{OPENMM_SERIALIZATION_URL}") - - -@get_object -def _load_openmm_xml(wizard, obj_type): - xml = wizard.ask( - f"Where is the XML file for your OpenMM {obj_type}?", - helper=_openmm_serialization_helper - ) - try: - with open(xml, 'r') as xml_f: - data = xml_f.read() - obj = mm.XmlSerializer.deserialize(data) - except Exception as e: - wizard.exception(FILE_LOADING_ERROR_MSG, e) - else: - return obj - -@get_object -def _load_topology(wizard): - import openpathsampling as paths - filename = wizard.ask("Where is a PDB file describing your system?") - try: - snap = paths.engines.openmm.snapshot_from_pdb(filename) - except Exception as e: - wizard.exception(FILE_LOADING_ERROR_MSG, e) - return - - return snap.topology - -def openmm(wizard): - import openpathsampling as paths - # quick exit if not installed; but should be impossible to get here - if not HAS_OPENMM: # no-cov - not_installed("OpenMM", "engine") - return - - wizard.say("Great! OpenMM gives you a lot of flexibility. " - "To use OpenMM in OPS, you need to provide XML versions of " - "your system, integrator, and some file containing " - "topology information.") - system = _load_openmm_xml(wizard, 'system') - integrator = _load_openmm_xml(wizard, 'integrator') - topology = _load_topology(wizard) - - n_steps_per_frame = wizard.ask_custom_eval( - "How many MD steps per saved frame?", type_=int - ) - n_frames_max = wizard.ask_custom_eval( - "How many frames before aborting a trajectory?", type_=int - ) - # TODO: assemble the OpenMM simulation - engine = paths.engines.openmm.Engine( - topology=topology, - system=system, - integrator=integrator, - options={ - 'n_steps_per_frame': n_steps_per_frame, - 'n_frames_max': n_frames_max, - } +if HAS_OPENMM: + OPENMM_PLUGIN = WizardParameterObjectPlugin.from_proxies( + name="OpenMM", + category="engines", + description=("OpenMM is an GPU-accelerated library for molecular " + "dynamics. " + _OPENMM_REQS), + intro=("Great! OpenMM gives you a lots of flexibility. " + + _OPENMM_REQS), + parameters=[ + ProxyParameter( + name='topology', + ask="Where is a PDB file describing your system?", + helper=("We use a PDB file to set up the OpenMM simulation. " + "Please provide the path to your PDB file."), + ), + ProxyParameter( + name='integrator', + ask=_where_is_xml.format(obj_type='integrator'), + helper=_xml_help, + ), + ProxyParameter( + name='system', + ask=_where_is_xml.format(obj_type="system"), + helper=_xml_help, + ), + ProxyParameter( + name='n_steps_per_frame', + ask="How many MD steps per saved frame?", + helper=("Your integrator has a time step associated with it. " + "We need to know how many time steps between the " + "frames we actually save during the run."), + ), + ProxyParameter( + name='n_frames_max', + ask="How many frames before aborting a trajectory?", + helper=None, + ), + ], + compiler_plugin=OPENMM_COMPILING, ) - return engine +else: + OPENMM_PLUGIN = None -SUPPORTED = {"OpenMM": openmm_builder} if HAS_OPENMM else {} if __name__ == "__main__": from paths_cli.wizard.wizard import Wizard diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index c804cc26..52384b82 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -1,5 +1,6 @@ from paths_cli.compiling.tools import custom_eval import importlib +from collections import namedtuple from paths_cli.wizard.helper import Helper @@ -7,78 +8,157 @@ NO_PARAMETER_LOADED = object() -CUSTOM_EVAL_ERROR = "Sorry, I couldn't understand the input '{user_str}'" +ProxyParameter = namedtuple( + 'ProxyParameter', + ['name', 'ask', 'helper', 'default', 'autohelp', 'summarize'], + defaults=[None, NO_DEFAULT, False, None] +) -def do_import(fully_qualified_name): - dotted = fully_qualified_name.split('.') - thing = dotted[-1] - module = ".".join(dotted[:-1]) - # stole this from SimStore - mod = importlib.import_module(module) - result = getattr(mod, thing) - return result - - -class Parameter: - def __init__(self, name, load_method): +class WizardParameter: + def __init__(self, name, ask, loader, helper=None, default=NO_DEFAULT, + autohelp=False, summarize=None): self.name = name - self.load_method = load_method - - def __call__(self, wizard): - result = NO_PARAMETER_LOADED - while result is NO_PARAMETER_LOADED: - result = self.load_method(wizard) - return result - - -class SimpleParameter(Parameter): - def __init__(self, name, ask, loader, helper=None, error=None, - default=NO_DEFAULT, autohelp=False): - super().__init__(name, self._load_method) self.ask = ask self.loader = loader - if helper is None: - helper = Helper("Sorry, no help is available for this " - "parameter.") - if not isinstance(helper, Helper): - helper = Helper(helper) self.helper = helper - - if error is None: - error = "Something went wrong processing the input '{user_str}'" - self.error = error self.default = default self.autohelp = autohelp + if summarize is None: + summarize = lambda obj: str(obj) + self.summarize = summarize - def _process_input(self, wizard, user_str): + @classmethod + def from_proxy(cls, proxy, compiler_plugin): + loader_dict = {p.name: p.loader for p in compiler_plugin.parameters} + dct = proxy._asdict() + dct['loader'] = loader_dict[proxy.name] + return cls(**dct) + + def __call__(self, wizard): obj = NO_PARAMETER_LOADED - if user_str[0] in ['?', '!']: - wizard.say(self.helper(user_str)) - return NO_PARAMETER_LOADED + while obj is NO_PARAMETER_LOADED: + obj = wizard.ask_load(self.ask, self.loader, self.helper, + autohelp=self.autohelp) + return obj + + +class WizardObjectPlugin: + def __init__(self, name, category, builder, prerequisite=None, + intro=None, description=None, summary=None): + self.name = name + self.category = category + self.builder = builder + self.prerequisite = prerequisite + self.intro = intro + self.description = description + self._summary = summary # func to summarize - try: - obj = self.loader(user_str) - except Exception as e: - wizard.exception(self.error.format(user_str=user_str), e) - if self.autohelp: - wizard.say(self.helper("?")) + def summary(self, obj): + if self._summary: + return self._summary(obj) + else: + return " " + str(obj) + def __call__(self, wizard): + if self.intro is not None: + wizard.say(self.intro) + + if self.prerequisite is not None: + prereqs = self.prerequisite(wizard) + else: + prereqs = {} + + result = self.builder(wizard, prereqs) + wizard.say("Here's what we'll create:\n" + self.summary(result)) + return result + + +class WizardParameterObjectPlugin(WizardObjectPlugin): + def __init__(self, name, category, parameters, builder, *, + prerequisite=None, intro=None, description=None): + super().__init__(name=name, category=category, builder=self._build, + prerequisite=prerequisite, intro=intro, + description=description) + self.parameters = parameters + self.build_func = builder + self.proxy_parameters = [] # non-empty if created from proxies + + @classmethod + def from_proxies(cls, name, category, parameters, compiler_plugin, + prerequisite=None, intro=None, description=None): + """ + Use the from_proxies method if you already have a compiler plugin. + """ + params = [WizardParameter.from_proxy(proxy, compiler_plugin) + for proxy in parameters] + obj = cls(name=name, + category=category, + parameters=params, + builder=compiler_plugin.builder, + prerequisite=prerequisite, + intro=intro, + description=description) + obj.proxy_parameters = parameters return obj - def _load_method(self, wizard): - user_str = wizard.ask(self.ask) - result = self._process_input(wizard, user_str) + def _build(self, wizard, prereqs): + dct = dict(prereqs) # shallow copy + dct.update({p.name: p(wizard) for p in self.parameters}) + result = self.build_func(**dct) return result -def load_custom_eval(type_=None): - if type_ is None: - type_ = lambda x: x - def parse(input_str): - return type_(custom_eval(input_str)) +class FromWizardPrerequisite: + """Load prerequisites from the wizard. + """ + def __init__(self, name, create_func, category, obj_name, n_required, + say_select=None, say_create=None, say_finish=None, + load_func=None): + self.name = name + self.create_func = create_func + self.category = category + self.obj_name = obj_name + self.n_required = n_required + self.say_select = say_select + self.say_create = say_create + self.say_finish = say_finish + if load_func is None: + load_func = lambda x: x + + self.load_func = load_func + + def create_new(self, wizard): + if self.say_create: + wizard.say(self.say_create) + obj = self.create_func(wizard) + wizard.register(obj, self.obj_name, self.category) + result = self.load_func(obj) + return result + + def get_existing(self, wizard): + all_objs = list(getattr(wizard, self.category).values()) + results = [self.load_func(obj) for obj in all_objs] + return results - return parse + def select_existing(self, wizard): + pass + def __call__(self, wizard): + n_existing = len(getattr(wizard, self.category)) + if n_existing == self.n_required: + # early return in this case (return silently) + return {self.name: self.get_existing(wizard)} + elif n_existing > self.n_required: + dct = {self.name: self.select_existing(wizard)} + else: + objs = [] + while len(getattr(wizard, self.category)) < self.n_required: + objs.append(self.create_new(wizard)) + dct = {self.name: objs} + + if self.say_finish: + wizard.say(self.say_finish) + return dct class InstanceBuilder: """ diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 25774eee..cfbc55f6 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -69,7 +69,9 @@ def _speak(self, content, preface): self.console.print("\n".join(wrapped)) @get_object - def ask(self, question, options=None, default=None, helper=None): + def ask(self, question, options=None, default=None, helper=None, + autohelp=False): + # TODO: if helper is None, create a default helper result = self.console.input("🧙 " + question + " ") self.console.print() if helper and result[0] in ["?", "!"]: @@ -91,7 +93,8 @@ def bad_input(self, content, preface="👺 "): self.say(content, preface) @get_object - def ask_enumerate_dict(self, question, options, helper=None): + def ask_enumerate_dict(self, question, options, helper=None, + autohelp=False): self.say(question) opt_string = "\n".join([f" {(i+1):>3}. {opt}" for i, opt in enumerate(options)]) @@ -140,7 +143,7 @@ def ask_enumerate(self, question, options): return result @get_object - def ask_load(self, question, loader, helper=None): + def ask_load(self, question, loader, helper=None, autohelp=False): as_str = self.ask(question, helper=helper) try: result = loader(as_str) diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py index f275fd82..08058e63 100644 --- a/paths_cli/wizard/wrap_compilers.py +++ b/paths_cli/wizard/wrap_compilers.py @@ -2,34 +2,11 @@ from .helper import Helper -class WizardProxyParameter: - def __init__(self, name, ask, helper, error): - self.name = name - self.ask = ask - self.helper = Helper(helper) - self.error = error - self.loader = None - - def register_loader(self, loader): - if self.loader is not None: - raise RuntimeError("Already have a loader for this parameter") - self.loader = loader - - def _process_input(self, user_str): - if user_str[0] in ['?', '!']: - pass - - def __call__(self, wizard): - obj = NO_PARAMETER_LOADED - while obj is NO_PARAMETER_LOADED: - obj = wizard.ask_load(self.ask, self.loader, self.helper) - - return obj - class WrapCompilerWizardPlugin: def __init__(self, name, category, parameters, compiler_plugin, prerequisite=None, intro=None, description=None): + self.name = name self.parameters = parameters self.compiler_plugin = compiler_plugin self.prerequisite = prerequisite @@ -39,15 +16,22 @@ def __init__(self, name, category, parameters, compiler_plugin, for param in self.parameters: param.register_loader(loaders[param.name]) + def _builder(self, wizard, prereqs): + dct = dict(prereqs) # make a copy + dct.update({param.name: param(wizard) for param in self.parameters}) + result = self.compiler_plugin(**dct) + return result + def __call__(self, wizard): if self.intro is not None: wizard.say(self.intro) if self.prerequisite is not None: - self.prerequisite(wizard) + prereqs = self.prerequisite(wizard) + else: + prereqs = {} - dct = {param.name: param(wizard) for param in self.parameters} - result = self.compiler_plugin.builder(**dct) + result = self._builder(wizard, prereqs) return result From 3c451374e5fa92e4694674783aaf44f695461381 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 2 Oct 2021 14:27:40 -0400 Subject: [PATCH 141/251] clean up CVs --- paths_cli/wizard/cvs.py | 136 ++++++---------------------------------- 1 file changed, 19 insertions(+), 117 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index a156a405..7b2125c6 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -25,22 +25,19 @@ HAS_MDTRAJ = True _ATOM_INDICES_HELP_STR = ( - "You should specify atom indicies enclosed in double brackets, e.g. " + "You should specify atom indices enclosed in double brackets, e.g. " "[{list_range_natoms}]" ) -def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov - wizard.say("You should specify atom indices enclosed in double " - "brackets, e.g, [" + str(list(range(n_atoms))) + "]") - # TODO: implement the following: - # wizard.say("You can specify atoms either as atom indices (which count " - # "from zero) or as atom labels of the format " - # "CHAIN:RESIDUE-ATOM, e.g., '0:ALA1-CA' for the alpha carbon " - # "of alanine 1 (this time counting from one, as in the PDB) " - # "of the 0th chain in the topology. You can also use letters " - # "for chain IDs, but note that A corresponds to the first " - # "chain in your topology, even if its name in the PDB file " - # "is B.") +# TODO: implement so the following can be the help string: +# _ATOM_INDICES_HELP_STR = ( +# "You can specify atoms either as atom indices (which count from zero) " +# "or as atom labels of the format CHAIN:RESIDUE-ATOM, e.g., '0:ALA1-CA' " +# "for the alpha carbon of alanine 1 (this time counting from one, as in " +# "the PDB) of the 0th chain in the topology. You can also use letters " +# "for chain IDs, but note that A corresponds to the first chain in your " +# "topology, even if its name in the PDB file " "is B." +# ) TOPOLOGY_CV_PREREQ = FromWizardPrerequisite( name='topology', @@ -57,32 +54,6 @@ def mdtraj_atom_helper(wizard, user_input, n_atoms): # no-cov ) - -def _get_topology(wizard): - from paths_cli.wizard.engines import engines - topology = None - # TODO: this is very similar to get_missing_object, but has more - # reporting; is there some way to add the reporting to - # get_missing_object? - if len(wizard.engines) == 0: - # SHOULD NEVER GET HERE IF WIZARDS ARE DESIGNED CORRECTLY - wizard.say("Hey, you need to define an MD engine before you " - "create CVs that refer to it. Let's do that now!") - engine = engines(wizard) - wizard.register(engine, 'engine', 'engines') - wizard.say("Now let's get back to defining your CV.") - topology = engine.topology - elif len(wizard.engines) == 1: - topology = list(wizard.engines.values())[0].topology - else: - wizard.say("You have defined multiple engines, and need to pick " - "one to use to get a the topology for your CV.") - engine = wizard.obj_selector('engines', 'engine', engines) - topology = engine.topology - wizard.say("Now let's get back to defining your CV.") - - return topology - @get_object def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): helper = Helper(_ATOM_INDICES_HELP_STR.format( @@ -95,7 +66,7 @@ def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): arr = mdtraj_parse_atomlist(atoms_str, n_atoms, topology) except Exception as e: wizard.exception(f"Sorry, I didn't understand '{atoms_str}'.", e) - mdtraj_atom_helper(wizard, '?', n_atoms) + helper("?") return return arr @@ -180,62 +151,7 @@ def _mdtraj_summary(cv): summary=_mdtraj_summary, ) -def _mdtraj_function_cv(wizard, cv_does_str, cv_user_prompt, func, - kwarg_name, n_atoms, period): - from openpathsampling.experimental.storage.collective_variables import \ - MDTrajFunctionCV - wizard.say(f"We'll make a CV that measures the {cv_does_str}.") - period_min, period_max = period - topology = _get_topology(wizard) - indices = _get_atom_indices(wizard, topology, n_atoms=n_atoms, - cv_user_str=cv_user_prompt) - kwargs = {kwarg_name: indices} - atoms_str = " ".join([str(topology.mdtraj.atom(i)) for i in indices[0]]) - - summary = ("Here's what we'll create:\n" - f" Function: {func.__name__}\n" - f" Atoms: {atoms_str}\n" - f" Topology: {repr(topology.mdtraj)}") - wizard.say(summary) - - return MDTrajFunctionCV(func, topology, period_min=period_min, - period_max=period_max, **kwargs) - -def distance(wizard): - return _mdtraj_function_cv( - wizard=wizard, - cv_does_str="distance between two atoms", - cv_user_prompt="measure the distance between", - func=md.compute_distances, - kwarg_name='atom_pairs', - n_atoms=2, - period=(None, None) - ) - -def angle(wizard): - return _mdtraj_function_cv( - wizard=wizard, - cv_does_str="angle made by three atoms", - cv_user_prompt="use to define the angle", - func=md.compute_angles, - kwarg_name='angle_indices', - n_atoms=3, - period=(-np.pi, np.pi) - ) - -def dihedral(wizard): - return _mdtraj_function_cv( - wizard=wizard, - cv_does_str="dihedral made by four atoms", - cv_user_prompt="use to define the dihedral angle", - func=md.compute_dihedrals, - kwarg_name='indices', - n_atoms=4, - period=(-np.pi, np.pi) - ) - -def rmsd(wizard): - raise NotImplementedError("RMSD has not yet been implemented") + # TODO: add RMSD -- need to figure out how to select a frame def coordinate(wizard, prereqs=None): # TODO: atom_index should be from wizard.ask_custom_eval @@ -272,22 +188,6 @@ def coordinate(wizard, prereqs=None): CV_FROM_FILE = LoadFromOPS('cvs', 'CV') -SUPPORTED_CVS = {} - -if HAS_MDTRAJ: - SUPPORTED_CVS.update({ - 'Distance': MDTRAJ_DISTANCE, - 'Angle': MDTRAJ_ANGLE, - 'Dihedral': MDTRAJ_DIHEDRAL, - # 'RMSD': rmsd, - }) - -SUPPORTED_CVS.update({ - 'Coordinate': COORDINATE_CV, - # 'Python script': ..., - _load_label: CV_FROM_FILE, -}) - CV_PLUGIN = WrapCategory( name='cvs', intro=("You'll need to describe your system in terms of collective " @@ -310,13 +210,15 @@ def cvs(wizard): cv = SUPPORTED_CVS[cv_type](wizard) return cv -# TEMPORARY -for plugin in [MDTRAJ_DISTANCE, MDTRAJ_ANGLE, MDTRAJ_DIHEDRAL, - COORDINATE_CV, CV_FROM_FILE]: - CV_PLUGIN.register_plugin(plugin) - if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard + plugins = [obj for obj in globals().values() + if isinstance(obj, WizardObjectPlugin)] + from_file = [obj for obj in globals().values() + if isinstance(obj, LoadFromOPS)] + for plugin in plugins + from_file: + CV_PLUGIN.register_plugin(plugin) + wiz = Wizard({}) cv = CV_PLUGIN(wiz) print(cv) From 4453c3afa4d0114c8daf3630f23e6014d333b806 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 5 Oct 2021 20:57:19 -0400 Subject: [PATCH 142/251] Wizard works again! --- paths_cli/commands/wizard.py | 12 +- paths_cli/wizard/cvs.py | 31 +++-- paths_cli/wizard/engines.py | 30 +--- paths_cli/wizard/load_from_ops.py | 25 +++- paths_cli/wizard/openmm.py | 2 +- paths_cli/wizard/parameters.py | 109 ++++++++++++--- paths_cli/wizard/shooting.py | 5 +- paths_cli/wizard/steps.py | 15 +- paths_cli/wizard/tps.py | 4 +- paths_cli/wizard/two_state_tps.py | 25 +++- paths_cli/wizard/volumes.py | 213 ++++++++++++++++------------- paths_cli/wizard/wrap_compilers.py | 55 +++++++- 12 files changed, 346 insertions(+), 180 deletions(-) diff --git a/paths_cli/commands/wizard.py b/paths_cli/commands/wizard.py index 68bede3d..249115e6 100644 --- a/paths_cli/commands/wizard.py +++ b/paths_cli/commands/wizard.py @@ -1,12 +1,20 @@ import click +from paths_cli.wizard.plugins import register_installed_plugins from paths_cli.wizard.two_state_tps import TWO_STATE_TPS_WIZARD +from paths_cli import OPSCommandPlugin @click.command( 'wizard', short_help="run wizard for setting up simulations", ) def wizard(): # no-cov + register_installed_plugins() + # breakpoint() TWO_STATE_TPS_WIZARD.run_wizard() -CLI = wizard -SECTION = "Simulation setup" +PLUGIN = OPSCommandPlugin( + command=wizard, + section="Simulation setup", + requires_ops=(1, 0), + requires_cli=(0, 3) +) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 7b2125c6..cf259697 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,9 +1,7 @@ -from paths_cli.wizard.engines import engines -from paths_cli.compiling.tools import custom_eval, mdtraj_parse_atomlist -from paths_cli.wizard.load_from_ops import load_from_ops, LoadFromOPS -from paths_cli.wizard.load_from_ops import LABEL as _load_label +from paths_cli.compiling.tools import mdtraj_parse_atomlist +from paths_cli.wizard.load_from_ops import LoadFromOPS from paths_cli.wizard.core import get_object -import paths_cli.wizard +import paths_cli.wizard.engines from functools import partial from collections import namedtuple @@ -42,7 +40,7 @@ TOPOLOGY_CV_PREREQ = FromWizardPrerequisite( name='topology', create_func=paths_cli.wizard.engines.ENGINE_PLUGIN, - category='engines', + category='engine', obj_name='engine', n_required=1, say_create=("Hey, you need to define an MD engine before you create " @@ -110,7 +108,8 @@ def _mdtraj_cv_builder(wizard, prereqs, func_name): _MDTRAJ_INTRO = "We'll make a CV that measures the {user_str}." -def _mdtraj_summary(cv): +def _mdtraj_summary(wizard, context, result): + cv = result func = cv.func topology = cv.topology indices = list(cv.kwargs.values())[0] @@ -118,12 +117,12 @@ def _mdtraj_summary(cv): summary = (f" Function: {func.__name__}\n" f" Atoms: {atoms_str}\n" f" Topology: {repr(topology.mdtraj)}") - return summary + return [summary] if HAS_MDTRAJ: MDTRAJ_DISTANCE = WizardObjectPlugin( name='Distance', - category='cvs', + category='cv', builder=partial(_mdtraj_cv_builder, func_name='compute_distances'), prerequisite=TOPOLOGY_CV_PREREQ, intro=_MDTRAJ_INTRO.format(user_str="distance between two atoms"), @@ -133,7 +132,7 @@ def _mdtraj_summary(cv): MDTRAJ_ANGLE = WizardObjectPlugin( name="Angle", - category='cvs', + category='cv', builder=partial(_mdtraj_cv_builder, func_name='compute_angles'), prerequisite=TOPOLOGY_CV_PREREQ, intro=_MDTRAJ_INTRO.format(user_str="angle made by three atoms"), @@ -143,10 +142,10 @@ def _mdtraj_summary(cv): MDTRAJ_DIHEDRAL = WizardObjectPlugin( name="Dihedral", - category='cvs', + category='cv', builder=partial(_mdtraj_cv_builder, func_name='compute_dihedrals'), prerequisite=TOPOLOGY_CV_PREREQ, - intro=_MDTRAJ_INTRO, + intro=_MDTRAJ_INTRO.format(user_str="dihedral made by four atoms"), description=..., summary=_mdtraj_summary, ) @@ -180,16 +179,16 @@ def coordinate(wizard, prereqs=None): COORDINATE_CV = WizardObjectPlugin( name="Coordinate", - category="cvs", + category="cv", builder=coordinate, description=("Create a CV based on a specific coordinate (for a " "specific atom)."), ) -CV_FROM_FILE = LoadFromOPS('cvs', 'CV') +CV_FROM_FILE = LoadFromOPS('cv') CV_PLUGIN = WrapCategory( - name='cvs', + name='cv', intro=("You'll need to describe your system in terms of collective " "variables (CVs). We'll use these variables to define things " "like stable states."), @@ -212,6 +211,8 @@ def cvs(wizard): if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard + from paths_cli.wizard.plugins import register_installed_plugins + register_installed_plugins() plugins = [obj for obj in globals().values() if isinstance(obj, WizardObjectPlugin)] from_file = [obj for obj in globals().values() diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 5fab9a8e..3b6ce268 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -7,27 +7,9 @@ from paths_cli.wizard.wrap_compilers import WrapCategory -SUPPORTED_ENGINES = {} - -SUPPORTED_ENGINES[load_label] = partial(load_from_ops, - store_name='engines', - obj_name='engine') - -def engines(wizard): - wizard.say("Let's make an engine. An engine describes how you'll do " - "the actual dynamics. Most of the details are given " - "in files that depend on the specific type of engine.") - engine_names = list(SUPPORTED_ENGINES.keys()) - eng_name = wizard.ask_enumerate( - "What will you use for the underlying engine?", - options=engine_names - ) - engine = SUPPORTED_ENGINES[eng_name](wizard) - return engine - _ENGINE_HELP = "An engine describes how you'll do the actual dynamics." ENGINE_PLUGIN = WrapCategory( - name='engines', + name='engine', ask="What will you use for the underlying engine?", intro=("Let's make an engine. " + _ENGINE_HELP + " Most of the " "details are given in files that depend on the specific " @@ -35,13 +17,13 @@ def engines(wizard): helper=_ENGINE_HELP ) -ENGINE_FROM_FILE = LoadFromOPS('engines', 'engine') +ENGINE_FROM_FILE = LoadFromOPS('engine') # TEMPORARY -from .openmm import OPENMM_PLUGIN -plugins = [OPENMM_PLUGIN, ENGINE_FROM_FILE] -for plugin in plugins: - ENGINE_PLUGIN.register_plugin(plugin) +# from .openmm import OPENMM_PLUGIN +# plugins = [OPENMM_PLUGIN, ENGINE_FROM_FILE] +# for plugin in plugins: +# ENGINE_PLUGIN.register_plugin(plugin) if __name__ == "__main__": diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index ede4fc65..a2f07200 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -1,5 +1,6 @@ from paths_cli.parameters import INPUT_FILE from paths_cli.wizard.core import get_object +from paths_cli.wizard.standard_categories import CATEGORIES from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG LABEL = "Load existing from OPS file" @@ -47,14 +48,32 @@ def load_from_ops(wizard, store_name, obj_name): return obj -class LoadFromOPS: - def __init__(self, category, obj_name): +from paths_cli.plugin_management import OPSPlugin +class LoadFromOPS(OPSPlugin): + def __init__(self, category, obj_name=None, store_name=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) self.category = category self.name = "Load existing from OPS file" + if obj_name is None: + obj_name = self._get_category_info(category).singular + + if store_name is None: + store_name = self._get_category_info(category).storage + self.obj_name = obj_name + self.store_name = store_name + + @staticmethod + def _get_category_info(category): + try: + return CATEGORIES[category] + except KeyError: + raise RuntimeError(f"No category {category}. Extra names must " + "be given explicitly") def __call__(self, wizard): wizard.say("Okay, we'll load it from an OPS file.") storage = _get_ops_storage(wizard) - obj = _get_ops_storage(wizard, storage, self.category, + obj = _get_ops_storage(wizard, storage, self.store_name, self.obj_name) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index d6dd0761..354f3144 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -30,7 +30,7 @@ if HAS_OPENMM: OPENMM_PLUGIN = WizardParameterObjectPlugin.from_proxies( name="OpenMM", - category="engines", + category="engine", description=("OpenMM is an GPU-accelerated library for molecular " "dynamics. " + _OPENMM_REQS), intro=("Great! OpenMM gives you a lots of flexibility. " + diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 52384b82..54765867 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -3,6 +3,9 @@ from collections import namedtuple from paths_cli.wizard.helper import Helper +from paths_cli.compiling.root_compiler import _CategoryCompilerProxy +from paths_cli.wizard.standard_categories import get_category_info +from paths_cli.plugin_management import OPSPlugin NO_DEFAULT = object() @@ -14,6 +17,7 @@ defaults=[None, NO_DEFAULT, False, None] ) + class WizardParameter: def __init__(self, name, ask, loader, helper=None, default=NO_DEFAULT, autohelp=False, summarize=None): @@ -31,20 +35,50 @@ def __init__(self, name, ask, loader, helper=None, default=NO_DEFAULT, def from_proxy(cls, proxy, compiler_plugin): loader_dict = {p.name: p.loader for p in compiler_plugin.parameters} dct = proxy._asdict() - dct['loader'] = loader_dict[proxy.name] + loader = loader_dict[proxy.name] + if isinstance(loader, _CategoryCompilerProxy): + from paths_cli.wizard.plugins import get_category_wizard + category = loader.category + dct['loader'] = get_category_wizard(category) + dct['ask'] = get_category_info(category).singular + dct['store_name'] = get_category_info(category).storage + cls = ExistingObjectParameter + else: + dct['loader'] = loader return cls(**dct) - def __call__(self, wizard): + def __call__(self, wizard, context): obj = NO_PARAMETER_LOADED + ask = self.ask.format(**context) while obj is NO_PARAMETER_LOADED: - obj = wizard.ask_load(self.ask, self.loader, self.helper, + obj = wizard.ask_load(ask, self.loader, self.helper, autohelp=self.autohelp) return obj -class WizardObjectPlugin: +class ExistingObjectParameter(WizardParameter): + def __init__(self, name, ask, loader, store_name, helper=None, + default=NO_DEFAULT, autohelp=False, summarize=None): + super().__init__(name=name, ask=ask, loader=loader, helper=helper, + default=default, autohelp=autohelp, + summarize=summarize) + self.store_name = store_name + + @classmethod + def from_proxy(cls, proxy, compiler_plugin): + raise NotImplementedError() + + def __call__(self, wizard, context): + ask = self.ask.format(**context) + obj = wizard.obj_selector(self.store_name, ask, self.loader) + return obj + + +class WizardObjectPlugin(OPSPlugin): def __init__(self, name, category, builder, prerequisite=None, - intro=None, description=None, summary=None): + intro=None, description=None, summary=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) self.name = name self.category = category self.builder = builder @@ -53,13 +87,31 @@ def __init__(self, name, category, builder, prerequisite=None, self.description = description self._summary = summary # func to summarize - def summary(self, obj): - if self._summary: - return self._summary(obj) - else: - return " " + str(obj) + def default_summarize(self, wizard, context, result): + return [f"Here's what we'll make:\n {str(result)}"] - def __call__(self, wizard): + def get_summary(self, wizard, context, result): + # TODO: this patter has been repeated -- make it a function (see + # also get_intro) + summarize = context.get('summarize', self._summary) + if summarize is None: + summarize = self.default_summarize + + try: + summary = summarize(wizard, context, result) + except TypeError: + summary = summarize + + if summary is None: + summary = [] + + if isinstance(summary, str): + summary = [summary] + + return summary + + + def __call__(self, wizard, context): if self.intro is not None: wizard.say(self.intro) @@ -69,9 +121,15 @@ def __call__(self, wizard): prereqs = {} result = self.builder(wizard, prereqs) - wizard.say("Here's what we'll create:\n" + self.summary(result)) + summary = self.get_summary(wizard, context, result) + for line in summary: + wizard.say(line) return result + def __repr__(self): + return (f"{self.__class__.__name__}(name={self.name}, " + f"category={self.category})") + class WizardParameterObjectPlugin(WizardObjectPlugin): def __init__(self, name, category, parameters, builder, *, @@ -102,8 +160,11 @@ def from_proxies(cls, name, category, parameters, compiler_plugin, return obj def _build(self, wizard, prereqs): - dct = dict(prereqs) # shallow copy - dct.update({p.name: p(wizard) for p in self.parameters}) + dct = dict(prereqs) + context = {'obj_dict': dct} + for param in self.parameters: + dct[param.name] = param(wizard, context) + # dct.update({p.name: p(wizard) for p in self.parameters}) result = self.build_func(**dct) return result @@ -111,13 +172,19 @@ def _build(self, wizard, prereqs): class FromWizardPrerequisite: """Load prerequisites from the wizard. """ - def __init__(self, name, create_func, category, obj_name, n_required, - say_select=None, say_create=None, say_finish=None, - load_func=None): + def __init__(self, name, create_func, category, n_required, + obj_name=None, store_name=None, say_select=None, + say_create=None, say_finish=None, load_func=None): self.name = name self.create_func = create_func self.category = category + if obj_name is None: + obj_name = get_category_info(category).singular + if store_name is None: + store_name = get_category_info(category).storage + self.obj_name = obj_name + self.store_name = store_name self.n_required = n_required self.say_select = say_select self.say_create = say_create @@ -131,12 +198,12 @@ def create_new(self, wizard): if self.say_create: wizard.say(self.say_create) obj = self.create_func(wizard) - wizard.register(obj, self.obj_name, self.category) + wizard.register(obj, self.obj_name, self.store_name) result = self.load_func(obj) return result def get_existing(self, wizard): - all_objs = list(getattr(wizard, self.category).values()) + all_objs = list(getattr(wizard, self.store_name).values()) results = [self.load_func(obj) for obj in all_objs] return results @@ -144,7 +211,7 @@ def select_existing(self, wizard): pass def __call__(self, wizard): - n_existing = len(getattr(wizard, self.category)) + n_existing = len(getattr(wizard, self.store_name)) if n_existing == self.n_required: # early return in this case (return silently) return {self.name: self.get_existing(wizard)} @@ -152,7 +219,7 @@ def __call__(self, wizard): dct = {self.name: self.select_existing(wizard)} else: objs = [] - while len(getattr(wizard, self.category)) < self.n_required: + while len(getattr(wizard, self.store_name)) < self.n_required: objs.append(self.create_new(wizard)) dct = {self.name: objs} diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 099c5230..8a7fb7a7 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -1,10 +1,13 @@ from functools import partial from paths_cli.wizard.core import get_missing_object -from paths_cli.wizard.engines import engines +# from paths_cli.wizard.engines import engines +from paths_cli.wizard.plugins import get_category_wizard from paths_cli.wizard.cvs import cvs from paths_cli.compiling.tools import custom_eval +engines = get_category_wizard('engine') + import numpy as np diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py index e77e72b2..312ee8b7 100644 --- a/paths_cli/wizard/steps.py +++ b/paths_cli/wizard/steps.py @@ -1,27 +1,30 @@ from collections import namedtuple from functools import partial -from paths_cli.wizard.cvs import cvs -from paths_cli.wizard.engines import engines -from paths_cli.wizard.volumes import volumes +# from paths_cli.wizard.cvs import cvs +# from paths_cli.wizard.engines import engines +from paths_cli.wizard.plugins import get_category_wizard from paths_cli.wizard.tps import tps_scheme +from paths_cli.wizard.plugins import get_category_wizard +volumes = get_category_wizard('volume') + WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', 'minimum', 'maximum']) -SINGLE_ENGINE_STEP = WizardStep(func=engines, +SINGLE_ENGINE_STEP = WizardStep(func=get_category_wizard('engine'), display_name="engine", store_name="engines", minimum=1, maximum=1) -CVS_STEP = WizardStep(func=cvs, +CVS_STEP = WizardStep(func=get_category_wizard('cv'), display_name="CV", store_name='cvs', minimum=1, maximum=float('inf')) -MULTIPLE_STATES_STEP = WizardStep(func=partial(volumes, as_state=True), +MULTIPLE_STATES_STEP = WizardStep(func=get_category_wizard('volume'), display_name="state", store_name="states", minimum=2, diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 03121eb0..8c91ee08 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -1,9 +1,11 @@ from paths_cli.wizard.tools import a_an from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.shooting import shooting -from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.volumes import VOLUMES_PLUGIN as volumes +from paths_cli.wizard.plugins import get_category_wizard from functools import partial +volumes = get_category_wizard('volume') def tps_network(wizard): raise NotImplementedError("Still need to add other network choices") diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index a62aa9bf..dfb57c9e 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -1,18 +1,31 @@ -from paths_cli.wizard.volumes import volumes +from paths_cli.wizard.plugins import get_category_wizard from paths_cli.wizard.tps import tps_scheme from paths_cli.wizard.steps import ( SINGLE_ENGINE_STEP, CVS_STEP, WizardStep ) from paths_cli.wizard.wizard import Wizard +volumes = get_category_wizard('volume') +from paths_cli.wizard.volumes import _FIRST_STATE, _VOL_DESC + def two_state_tps(wizard, fixed_length=False): import openpathsampling as paths - wizard.say("Now let's define the stable states for your system. " - "Let's start with your initial state.") - initial_state = volumes(wizard, as_state=True, intro="") + wizard.requirements['state'] = ('volumes', 2, 2) + intro = [ + _FIRST_STATE.format(n_states_string=2), + "Let's start with your initial state.", + _VOL_DESC, + ] + # wizard.say("Now let's define the stable states for your system. " + # "Let's start with your initial state.") + initial_state = volumes(wizard, context={'intro': intro}) wizard.register(initial_state, 'initial state', 'states') - wizard.say("Next let's define your final state.") - final_state = volumes(wizard, as_state=True, intro="") + intro = [ + "Next let's define your final state.", + _VOL_DESC + ] + # wizard.say("Next let's define your final state.") + final_state = volumes(wizard, context={'intro': intro}) wizard.register(final_state, 'final state', 'states') if fixed_length: ... # no-cov (will add this later) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 539df4bb..16efb81d 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -1,111 +1,138 @@ import operator +from paths_cli.wizard.parameters import ( + ProxyParameter, WizardParameterObjectPlugin, WizardObjectPlugin +) +from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req from paths_cli.wizard.cvs import cvs +import paths_cli.compiling.volumes +from functools import partial -def _vol_intro(wizard, as_state): - if as_state: - req = wizard.requirements['state'] - if len(wizard.states) == 0: - intro = ("Now let's define stable states for your system. " - f"You'll need to define {interpret_req(req)} of them.") - else: - intro = "Okay, let's define another stable state." - else: - intro = None - return intro +def _binary_func_volume(wizard, context, op): + def summarize(volname): + def inner(wizard, context, result): + return f"The {volname} volume is:\n {str(result)}" -def _binary_func_volume(wizard, op): + as_state = context.get('depth', 0) == 0 wizard.say("Let's make the first constituent volume:") - vol1 = volumes(wizard) - wizard.say(f"The first volume is:\n{vol1}") + new_context = volume_set_context(wizard, context, selected=None) + new_context['part'] = 1 + new_context['summarize'] = summarize("first") + vol1 = VOLUMES_PLUGIN(wizard, new_context) wizard.say("Let's make the second constituent volume:") - vol2 = volumes(wizard) - wizard.say(f"The second volume is:\n{vol2}") + new_context['part'] = 2 + new_context['summarize'] = summarize("second") + vol2 = VOLUMES_PLUGIN(wizard, new_context) vol = op(vol1, vol2) - wizard.say(f"Created a volume:\n{vol}") + # wizard.say(f"Created a volume:\n{vol}") return vol -def intersection_volume(wizard): - wizard.say("This volume will be the intersection of two other volumes. " - "This means that it only allows phase space points that are " - "in both of the constituent volumes.") - return _binary_func_volume(wizard, operator.__and__) - -def union_volume(wizard): - wizard.say("This volume will be the union of two other volumes. " - "This means that it allows phase space points that are in " - "either of the constituent volumes.") - return _binary_func_volume(wizard, operator.__or__) - -def negated_volume(wizard): - wizard.say("This volume will be everything not in the subvolume.") - wizard.say("Let's make the subvolume.") - subvol = volumes(wizard) - vol = ~subvol - wizard.say(f"Created a volume:\n{vol}") - return vol - -def cv_defined_volume(wizard): - import openpathsampling as paths - wizard.say("A CV-defined volume allows an interval in a CV.") - cv = wizard.obj_selector('cvs', "CV", cvs) - lambda_min = lambda_max = None - is_periodic = cv.is_periodic - volume_bound_str = ("What is the {bound} allowed value for " - f"'{cv.name}' in this volume?") - - while lambda_min is None: - lambda_min = wizard.ask_custom_eval( - volume_bound_str.format(bound="minimum") - ) - - while lambda_max is None: - lambda_max = wizard.ask_custom_eval( - volume_bound_str.format(bound="maximum") - ) - - if is_periodic: - vol = paths.PeriodicCVDefinedVolume( - cv, lambda_min=lambda_min, lambda_max=lambda_max, - period_min=cv.period_min, period_max=cv.period_max - ) - else: - vol = paths.CVDefinedVolume( - cv, lambda_min=lambda_min, lambda_max=lambda_max, - ) - return vol - - -SUPPORTED_VOLUMES = { - 'CV-defined volume (allowed values of CV)': cv_defined_volume, - 'Intersection of two volumes (must be in both)': intersection_volume, - 'Union of two volumes (must be in at least one)': union_volume, - 'Complement of a volume (not in given volume)': negated_volume, -} +_LAMBDA_STR = ("What is the {minmax} allowed value for " + "'{{obj_dict[cv].name}}' in this volume?") +CV_DEFINED_VOLUME_PLUGIN = WizardParameterObjectPlugin.from_proxies( + name="CV-defined volume (allowed values of CV)", + category="volume", + description="Create a volume based on an interval along a chosen CV", + intro="A CV-defined volume defines an interval along a chosen CV", + parameters=[ + ProxyParameter( + name='cv', + ask=None, # use internal tricks + helper="Select the CV to use to define the volume.", + ), + ProxyParameter( + name="lambda_min", + ask=_LAMBDA_STR.format(minmax="minimum"), + helper="foo", + ), + ProxyParameter( + name='lambda_max', + ask=_LAMBDA_STR.format(minmax="maximum"), + helper="foo", + ), + ], + compiler_plugin=paths_cli.compiling.volumes.CV_VOLUME_PLUGIN, +) + +INTERSECTION_VOLUME_PLUGIN = WizardObjectPlugin( + name='Intersection of two volumes (must be in both)', + category="volume", + intro=("This volume will be the intersection of two other volumes. " + "This means that it only allows phase space points that are " + "in both of the constituent volumes."), + builder=partial(_binary_func_volume, op=operator.__and__), +) + +UNION_VOLUME_PLUGIN = WizardObjectPlugin( + name='Union of two volumes (must be in at least one)', + category="volume", + intro=("This volume will be the union of two other volumes. " + "This means that it allows phase space points that are in " + "either of the constituent volumes."), + builder=partial(_binary_func_volume, op=operator.__or__), +) + +NEGATED_VOLUME_PLUGIN = WizardObjectPlugin( + name='Complement of a volume (not in given volume)', + category='volume', + intro="This volume will be everything not in the subvolume.", + builder=lambda wizard, context: ~VOLUMES_PLUGIN(wizard, context), +) + +_FIRST_STATE = ("Now let's define state states for your system. " + "You'll need to define {n_states_string} of them.") +_ADDITIONAL_STATES = "Okay, let's define another stable state" +_VOL_DESC = ("You can describe this as either a range of values for some " + "CV, or as some combination of other such volumes " + "(i.e., intersection or union).") + +def volume_intro(wizard, context): + as_state = context.get('depth', 0) == 0 + n_states = len(wizard.states) + n_states_string = interpret_req(wizard.requirements['state']) + intro = [] + if as_state: + if n_states == 0: + intro += [_FIRST_STATE.format(n_states_string=n_states_string)] + else: + intro += [_ADDITIONAL_STATES] -def volumes(wizard, as_state=False, intro=None): - if intro is None: - intro = _vol_intro(wizard, as_state) + intro += [_VOL_DESC] + return intro - if intro: # disallow None and "" - wizard.say(intro) +def volume_set_context(wizard, context, selected): + depth = context.get('depth', 0) + 1 + new_context = { + 'depth': depth, + } + return new_context - wizard.say("You can describe this as either a range of values for some " - "CV, or as some combination of other such volumes " - "(i.e., intersection or union).") - obj = "state" if as_state else "volume" - vol = None - while vol is None: - vol_type = wizard.ask_enumerate( - f"What describes this {obj}?", - options=list(SUPPORTED_VOLUMES.keys()) - ) - vol = SUPPORTED_VOLUMES[vol_type](wizard) +def volume_ask(wizard, context): + as_state = context.get('depth', 0) == 0 + obj = {True: 'state', False: 'volume'}[as_state] + return f"What describes this {obj}?" - return vol +VOLUMES_PLUGIN = WrapCategory( + name='volume', + intro=volume_intro, + ask=volume_ask, + set_context=volume_set_context, + helper="No volume help yet" +) if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard - wiz = Wizard({'states': ('states', 1, '+')}) - volumes(wiz, as_state=True) + from paths_cli.wizard.plugins import register_installed_plugins + register_installed_plugins() + plugins = [obj for obj in globals().values() + if isinstance(obj, WizardObjectPlugin)] + from_file = [obj for obj in globals().values() + if isinstance(obj, LoadFromOPS)] + for plugin in plugins + from_file: + VOLUMES_PLUGIN.register_plugin(plugin) + wiz = Wizard([]) + wiz.requirements['state'] = ('volumes', 1, 1) + VOLUMES_PLUGIN(wiz) diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py index 08058e63..2f0fe93e 100644 --- a/paths_cli/wizard/wrap_compilers.py +++ b/paths_cli/wizard/wrap_compilers.py @@ -1,7 +1,7 @@ NO_PARAMETER_LOADED = object() from .helper import Helper - +from paths_cli.plugin_management import OPSPlugin class WrapCompilerWizardPlugin: def __init__(self, name, category, parameters, compiler_plugin, @@ -61,13 +61,16 @@ def __call__(self, help_args, context): return result -class WrapCategory: - def __init__(self, name, ask, helper=None, intro=None): +class WrapCategory(OPSPlugin): + def __init__(self, name, ask, helper=None, intro=None, set_context=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) self.name = name if isinstance(intro, str): intro = [intro] self.intro = intro self.ask = ask + self._set_context = set_context if helper is None: helper = Helper(CategoryHelpFunc(self)) if isinstance(helper, str): @@ -76,15 +79,53 @@ def __init__(self, name, ask, helper=None, intro=None): self.helper = helper self.choices = {} + def __repr__(self): + return f"{self.__class__.__name__}({self.name})" + + def set_context(self, wizard, context, selected): + if self._set_context: + return self._set_context(wizard, context, selected) + else: + return context + def register_plugin(self, plugin): self.choices[plugin.name] = plugin - def __call__(self, wizard): - for line in self.intro: + def get_intro(self, wizard, context): + intro = context.get('intro', self.intro) + + try: + intro = intro(wizard, context) + except TypeError: + pass + + if intro is None: + intro = [] + + return intro + + def get_ask(self, wizard, context): + try: + ask = self.ask(wizard, context) + except TypeError: + ask = self.ask.format(**context) + return ask + + def __call__(self, wizard, context=None): + if context is None: + context = {} + + intro = self.get_intro(wizard, context) + + for line in intro: wizard.say(line) - selected = wizard.ask_enumerate_dict(self.ask, self.choices, + ask = self.get_ask(wizard, context) + + selected = wizard.ask_enumerate_dict(ask, self.choices, self.helper) - obj = selected(wizard) + + new_context = self.set_context(wizard, context, selected) + obj = selected(wizard, new_context) return obj From 71fbc694a5753cb8fa8e42abcb25f750f457f473 Mon Sep 17 00:00:00 2001 From: sroet Date: Wed, 6 Oct 2021 20:35:46 +0200 Subject: [PATCH 143/251] switch codecov bash uploader for their github action --- .github/workflows/test-suite.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index d8d2c157..72c93df1 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -71,5 +71,4 @@ jobs: run: | python -c "import paths_cli" py.test -vv --cov --cov-report xml:cov.xml - - name: "Report coverage" - run: bash <(curl -s https://codecov.io/bash) + - uses: codecov/codecov-action@v2 From c357976a8762fdf57e71db6a5c1d05e8466f62b1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 18:47:45 -0400 Subject: [PATCH 144/251] passing tests for most of CVs and half volumes --- paths_cli/tests/wizard/test_cvs.py | 29 +++++++--------- paths_cli/tests/wizard/test_engines.py | 14 -------- paths_cli/tests/wizard/test_openmm.py | 47 ++------------------------ paths_cli/tests/wizard/test_volumes.py | 35 ++++++++++++------- paths_cli/wizard/engines.py | 14 +++----- paths_cli/wizard/parameters.py | 5 ++- 6 files changed, 46 insertions(+), 98 deletions(-) delete mode 100644 paths_cli/tests/wizard/test_engines.py diff --git a/paths_cli/tests/wizard/test_cvs.py b/paths_cli/tests/wizard/test_cvs.py index 10bb8252..e7793422 100644 --- a/paths_cli/tests/wizard/test_cvs.py +++ b/paths_cli/tests/wizard/test_cvs.py @@ -7,8 +7,8 @@ from paths_cli.tests.wizard.mock_wizard import mock_wizard from paths_cli.wizard.cvs import ( - _get_topology, _get_atom_indices, distance, angle, dihedral, rmsd, - coordinate, cvs, SUPPORTED_CVS + TOPOLOGY_CV_PREREQ, _get_atom_indices, MDTRAJ_DISTANCE, MDTRAJ_ANGLE, + MDTRAJ_DIHEDRAL, COORDINATE_CV, CV_PLUGIN ) import openpathsampling as paths @@ -17,7 +17,7 @@ from openpathsampling.tests.test_helpers import make_1d_traj @pytest.mark.parametrize('n_engines', [0, 1, 2]) -def test_get_topology(ad_engine, n_engines): +def test_TOPOLOGY_CV_PREREQ(ad_engine, n_engines): inputs = {0: [], 1: [], 2: ['1']}[n_engines] wizard = mock_wizard(inputs) engines = [ad_engine, paths.engines.NoEngine(None).named('foo')] @@ -27,9 +27,10 @@ def mock_register(obj, obj_type, store_name): wizard.register = mock_register mock_engines = mock.Mock(return_value=ad_engine) - patch_loc = 'paths_cli.wizard.engines.engines' + patch_loc = 'paths_cli.wizard.cvs.TOPOLOGY_CV_PREREQ.create_func' with mock.patch(patch_loc, new=mock_engines): - topology = _get_topology(wizard) + topology_result = TOPOLOGY_CV_PREREQ(wizard) + topology = topology_result['topology'][0] assert isinstance(topology, paths.engines.MDTrajTopology) @pytest.mark.parametrize('inputs', [ @@ -58,37 +59,31 @@ def test_distance(ad_openmm, ad_engine): md = pytest.importorskip('mdtraj') wizard = mock_wizard(['0, 1']) md_func = partial(md.compute_distances, atom_pairs=[[0, 1]]) - _mdtraj_function_test(wizard, distance, md_func, ad_openmm, ad_engine) + _mdtraj_function_test(wizard, MDTRAJ_DISTANCE, md_func, ad_openmm, + ad_engine) def test_angle(ad_openmm, ad_engine): md = pytest.importorskip('mdtraj') wizard = mock_wizard(['0, 1, 2']) md_func = partial(md.compute_angles, angle_indices=[[0, 1, 2]]) - _mdtraj_function_test(wizard, angle, md_func, ad_openmm, ad_engine) + _mdtraj_function_test(wizard, MDTRAJ_ANGLE, md_func, ad_openmm, ad_engine) def test_dihedral(ad_openmm, ad_engine): md = pytest.importorskip('mdtraj') wizard = mock_wizard(['0, 1, 2, 3']) md_func = partial(md.compute_dihedrals, indices=[[0, 1, 2, 3]]) - _mdtraj_function_test(wizard, dihedral, md_func, ad_openmm, ad_engine) + _mdtraj_function_test(wizard, MDTRAJ_DIHEDRAL, md_func, ad_openmm, + ad_engine) @pytest.mark.parametrize('inputs', [ (['0', 'x']), ('foo', '0', 'x'), (['0', 'q', 'x']) ]) def test_coordinate(inputs): wizard = mock_wizard(inputs) - cv = coordinate(wizard) + cv = COORDINATE_CV(wizard) traj = make_1d_traj([5.0]) assert cv(traj[0]) == 5.0 if 'foo' in inputs: assert "I can't make an atom index" in wizard.console.log_text if 'q' in inputs: assert "Please select one of" in wizard.console.log_text - -@pytest.mark.parametrize('inputs', [(['foo', 'Distance']), (['Distance'])]) -def test_cvs(inputs): - wizard = mock_wizard(inputs) - say_hello = mock.Mock(return_value="hello") - with mock.patch.dict(SUPPORTED_CVS, {'Distance': say_hello}): - assert cvs(wizard) == "hello" - assert wizard.console.input_call_count == len(inputs) diff --git a/paths_cli/tests/wizard/test_engines.py b/paths_cli/tests/wizard/test_engines.py deleted file mode 100644 index a11cec2d..00000000 --- a/paths_cli/tests/wizard/test_engines.py +++ /dev/null @@ -1,14 +0,0 @@ -import pytest -from unittest import mock - -from paths_cli.tests.wizard.mock_wizard import mock_wizard - -from paths_cli.wizard.engines import engines, SUPPORTED_ENGINES - -def test_engines(): - wizard = mock_wizard(['foo']) - with mock.patch.dict(SUPPORTED_ENGINES, - {'foo': mock.Mock(return_value='ran foo')}): - foo = engines(wizard) - assert foo == 'ran foo' - diff --git a/paths_cli/tests/wizard/test_openmm.py b/paths_cli/tests/wizard/test_openmm.py index 3dd98af9..88df8525 100644 --- a/paths_cli/tests/wizard/test_openmm.py +++ b/paths_cli/tests/wizard/test_openmm.py @@ -5,7 +5,7 @@ from paths_cli.tests.utils import assert_url from paths_cli.wizard.openmm import ( - _load_openmm_xml, _load_topology, openmm, OPENMM_SERIALIZATION_URL + OPENMM_PLUGIN, OPENMM_SERIALIZATION_URL ) from paths_cli.compat.openmm import mm, HAS_OPENMM @@ -13,51 +13,10 @@ def test_helper_url(): assert_url(OPENMM_SERIALIZATION_URL) -@pytest.mark.parametrize('obj_type', ['system', 'integrator', 'foo']) -def test_load_openmm_xml(ad_openmm, obj_type): - # switch back to importorskip when we drop OpenMM < 7.6 - if not HAS_OPENMM: - pytest.skip("could not import openmm") - # mm = pytest.importorskip("simtk.openmm") - filename = f"{obj_type}.xml" - inputs = [filename] - expected_count = 1 - if obj_type == 'foo': - inputs.append('integrator.xml') - expected_count = 2 - - wizard = mock_wizard(inputs) - superclass = {'integrator': mm.CustomIntegrator, - 'system': mm.System, - 'foo': mm.CustomIntegrator}[obj_type] - with ad_openmm.as_cwd(): - obj = _load_openmm_xml(wizard, obj_type) - assert isinstance(obj, superclass) - - assert wizard.console.input_call_count == expected_count - -@pytest.mark.parametrize('setup', ['normal', 'bad_filetype', 'no_file']) -def test_load_topology(ad_openmm, setup): - import openpathsampling as paths - inputs = {'normal': [], - 'bad_filetype': ['foo.bar'], - 'no_file': ['foo.pdb']}[setup] - expected_text = {'normal': "PDB file", - 'bad_filetype': 'trr', - 'no_file': 'No such file'}[setup] - inputs += ['ad.pdb'] - wizard = mock_wizard(inputs) - with ad_openmm.as_cwd(): - top = _load_topology(wizard) - - assert isinstance(top, paths.engines.MDTrajTopology) - assert wizard.console.input_call_count == len(inputs) - assert expected_text in wizard.console.log_text - def test_openmm(ad_openmm): - inputs = ['system.xml', 'integrator.xml', 'ad.pdb', '10', '10000'] + inputs = ['ad.pdb', 'integrator.xml', 'system.xml', '10', '10000'] wizard = mock_wizard(inputs) with ad_openmm.as_cwd(): - engine = openmm(wizard) + engine = OPENMM_PLUGIN(wizard) assert engine.n_frames_max == 10000 assert engine.n_steps_per_frame == 10 diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 56fa044b..863aa5cd 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -4,10 +4,11 @@ from paths_cli.tests.wizard.mock_wizard import mock_wizard from paths_cli.wizard.volumes import ( - _vol_intro, intersection_volume, union_volume, negated_volume, - cv_defined_volume, volumes, SUPPORTED_VOLUMES + INTERSECTION_VOLUME_PLUGIN, UNION_VOLUME_PLUGIN, NEGATED_VOLUME_PLUGIN, + CV_DEFINED_VOLUME_PLUGIN, VOLUMES_PLUGIN, volume_intro, _VOL_DESC ) + import openpathsampling as paths from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV @@ -33,20 +34,25 @@ def volume_setup(): @pytest.mark.parametrize('as_state,has_state', [ (True, False), (True, True), (False, False) ]) -def test_vol_intro(as_state, has_state): +def test_volume_intro(as_state, has_state): wizard = mock_wizard([]) wizard.requirements['state'] = ('states', 2, float('inf')) if has_state: wizard.states['foo'] = 'placeholder' - intro = _vol_intro(wizard, as_state) + if as_state: + context = {} + else: + context = {'depth': 1} + + intro = "\n".join(volume_intro(wizard, context)) if as_state and has_state: assert "another stable state" in intro elif as_state and not has_state: assert "You'll need to define" in intro elif not as_state: - assert intro is None + assert intro == _VOL_DESC else: raise RuntimeError("WTF?") @@ -54,23 +60,25 @@ def _binary_volume_test(volume_setup, func): vol1, vol2 = volume_setup wizard = mock_wizard([]) mock_volumes = mock.Mock(side_effect=[vol1, vol2]) - with mock.patch('paths_cli.wizard.volumes.volumes', new=mock_volumes): + patch_loc = 'paths_cli.wizard.volumes.VOLUMES_PLUGIN' + with mock.patch(patch_loc, new=mock_volumes): vol = func(wizard) - assert "first volume" in wizard.console.log_text - assert "second volume" in wizard.console.log_text - assert "Created" in wizard.console.log_text + assert "first constituent volume" in wizard.console.log_text + assert "second constituent volume" in wizard.console.log_text + assert "Here's what we'll make" in wizard.console.log_text return wizard, vol def test_intersection_volume(volume_setup): - wizard, vol = _binary_volume_test(volume_setup, intersection_volume) + wizard, vol = _binary_volume_test(volume_setup, + INTERSECTION_VOLUME_PLUGIN) assert "intersection" in wizard.console.log_text traj = make_1d_traj([0.25, 0.75]) assert not vol(traj[0]) assert vol(traj[1]) def test_union_volume(volume_setup): - wizard, vol = _binary_volume_test(volume_setup, union_volume) + wizard, vol = _binary_volume_test(volume_setup, UNION_VOLUME_PLUGIN) assert "union" in wizard.console.log_text traj = make_1d_traj([0.25, 0.75, 1.75]) assert vol(traj[0]) @@ -84,8 +92,9 @@ def test_negated_volume(volume_setup): assert not init_vol(traj[1]) wizard = mock_wizard([]) mock_vol = mock.Mock(return_value=init_vol) - with mock.patch('paths_cli.wizard.volumes.volumes', new=mock_vol): - vol = negated_volume(wizard) + patch_loc = 'paths_cli.wizard.volumes.VOLUMES_PLUGIN' + with mock.patch(patch_loc, new=mock_vol): + vol = NEGATED_VOLUME_PLUGIN(wizard) assert "not in" in wizard.console.log_text assert not vol(traj[0]) diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 3b6ce268..ab1179ac 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -19,17 +19,13 @@ ENGINE_FROM_FILE = LoadFromOPS('engine') -# TEMPORARY -# from .openmm import OPENMM_PLUGIN -# plugins = [OPENMM_PLUGIN, ENGINE_FROM_FILE] -# for plugin in plugins: -# ENGINE_PLUGIN.register_plugin(plugin) - - if __name__ == "__main__": from paths_cli.wizard import wizard + from paths_cli.wizard.plugins import register_installed_plugins + from paths_cli.wizard.plugins import get_category_wizard + register_installed_plugins() + engines = get_category_wizard('engine') wiz = wizard.Wizard([]) - # TODO: normally will need to register plugins from this file first - engine = ENGINE_PLUGIN(wiz) + engine = engines(wiz) print(engine) diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 54765867..6dcf807d 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -111,7 +111,10 @@ def get_summary(self, wizard, context, result): return summary - def __call__(self, wizard, context): + def __call__(self, wizard, context=None): + if context is None: + context = {} + if self.intro is not None: wizard.say(self.intro) From 1be98677d3842af67e5040e78d150bc705a31ff1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 20:29:03 -0400 Subject: [PATCH 145/251] only 3 failing tests now --- paths_cli/tests/wizard/test_volumes.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 863aa5cd..66927948 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -120,28 +120,9 @@ def test_cv_defined_volume(periodic): out_state = make_1d_traj([-0.1, 1.1]) wizard = mock_wizard(inputs) wizard.cvs[cv.name] = cv - vol = cv_defined_volume(wizard) + vol = CV_DEFINED_VOLUME_PLUGIN(wizard) assert "interval" in wizard.console.log_text for snap in in_state: assert vol(snap) for snap in out_state: assert not vol(snap) - -@pytest.mark.parametrize('intro', [None, "", "foo"]) -def test_volumes(intro): - say_hello = mock.Mock(return_value="hello!") - wizard = mock_wizard(['Hello world']) - with mock.patch.dict(SUPPORTED_VOLUMES, {'Hello world': say_hello}): - assert volumes(wizard, intro=intro) == "hello!" - assert wizard.console.input_call_count == 1 - - - n_statements = 2 * (1 + 3) - # 2: line and blank; (1 + 3): 1 in volumes + 3 in ask_enumerate - - if intro == 'foo': - assert 'foo' in wizard.console.log_text - assert len(wizard.console.log) == n_statements + 2 # from intro - else: - assert 'foo' not in wizard.console.log_text - assert len(wizard.console.log) == n_statements From abdbeadb363258257887289aa58f02ffc8bffbc3 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 21:03:12 -0400 Subject: [PATCH 146/251] fix most tests Still having an issue that periodic volumes aren't being created correctly --- paths_cli/tests/compiling/test_volumes.py | 2 + paths_cli/tests/wizard/test_volumes.py | 1 + paths_cli/tests/wizard/test_wizard.py | 4 +- paths_cli/wizard/parameters.py | 49 ++--------------------- 4 files changed, 8 insertions(+), 48 deletions(-) diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index 9e49f6dc..1a9c9a68 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -50,6 +50,8 @@ def test_build_cv_volume(self, inline, periodic): elif inline == 'internal': vol = build_cv_volume(dct) assert vol.collectivevariable(1) == 0.5 + snap = make_1d_traj([0.5])[0] + assert vol(snap) expected_class = { 'nonperiodic': paths.CVDefinedVolume, 'periodic': paths.PeriodicCVDefinedVolume diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 66927948..d50e3ee7 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -103,6 +103,7 @@ def test_negated_volume(volume_setup): @pytest.mark.parametrize('periodic', [True, False]) def test_cv_defined_volume(periodic): if periodic: + breakpoint() min_ = 0.0 max_ = 1.0 cv = CoordinateFunctionCV( diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index df343563..c5676c7e 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -74,8 +74,8 @@ def test_ask(self): def test_ask_help(self): console = MockConsole(['?helpme', 'foo']) self.wizard.console = console - def helper(wizard, result): - wizard.say(f"You said: {result[1:]}") + def helper(result): + return f"You said: {result[1:]}" result = self.wizard.ask('bar', helper=helper) assert result == 'foo' diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 6dcf807d..6f0744e4 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -211,7 +211,9 @@ def get_existing(self, wizard): return results def select_existing(self, wizard): - pass + obj = wizard.obj_selector(self.store_name, self.obj_name, + self.create_func) + return [self.load_func(obj)] def __call__(self, wizard): n_existing = len(getattr(wizard, self.store_name)) @@ -229,48 +231,3 @@ def __call__(self, wizard): if self.say_finish: wizard.say(self.say_finish) return dct - -class InstanceBuilder: - """ - - Parameters - ---------- - parameters: List[:class:`.Parameter`] - category: str - cls: Union[str, type] - intro: str - help_str: str - remapper: Callable[dict, dict] - make_summary: Callable[dict, str] - Function to create an output string to summarize the object that has - been created. Optional. - """ - def __init__(self, parameters, category, cls, intro=None, help_str=None, - remapper=None, make_summary=None): - self.parameters = parameters - self.param_dict = {p.name: p for p in parameters} - self.category = category - self._cls = cls - self.intro = intro - self.help_str = help_str - if remapper is None: - remapper = lambda x: x - self.remapper = remapper - if make_summary is None: - make_summary = lambda dct: None - self.make_summary = make_summary - - @property - def cls(self): - # trick to delay slow imports - if isinstance(self._cls, str): - self._cls = do_import(self._cls) - return self._cls - - def __call__(self, wizard): - if self.intro is not None: - wizard.say(self.intro) - - param_dict = {p.name: p(wizard) for p in self.parameters} - kwargs = self.remapper(param_dict) - return self.cls(**kwargs) From 59b5770b62eae0b75fe79385b1c51d7104994532 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 21:39:40 -0400 Subject: [PATCH 147/251] tests pass locally --- paths_cli/compiling/volumes.py | 2 ++ paths_cli/tests/compiling/test_volumes.py | 23 ++++++++++++++++++----- paths_cli/tests/wizard/test_volumes.py | 1 - 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index cdc1c958..a1952a14 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -13,6 +13,8 @@ def cv_volume_build_func(**dct): builder = paths.CVDefinedVolume if cv.period_min is not None or cv.period_max is not None: builder = paths.PeriodicCVDefinedVolume + dct['period_min'] = cv.period_min + dct['period_max'] = cv.period_max dct['collectivevariable'] = dct.pop('cv') # TODO: wrap this with some logging diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index 1a9c9a68..54d0b730 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -3,7 +3,10 @@ from paths_cli.tests.compiling.utils import mock_compiler import yaml +import numpy as np import openpathsampling as paths +from openpathsampling.experimental.storage.collective_variables import \ + CoordinateFunctionCV from openpathsampling.tests.test_helpers import make_1d_traj from paths_cli.compiling.volumes import * @@ -13,7 +16,6 @@ def setup(self): self.yml = "\n".join(["type: cv-volume", "cv: {func}", "lambda_min: 0", "lambda_max: 1"]) - self.mock_cv = mock.Mock(return_value=0.5) self.named_objs_dict = { 'foo': {'name': 'foo', 'type': 'bar', @@ -40,18 +42,29 @@ def test_build_cv_volume(self, inline, periodic): self.set_periodic(periodic) yml = self.yml.format(func=self.func[inline]) dct = yaml.load(yml, Loader=yaml.FullLoader) + period_min, period_max = {'periodic': (-np.pi, np.pi), + 'nonperiodic': (None, None)}[periodic] + mock_cv = CoordinateFunctionCV(lambda s: s.xyz[0][0], + period_min=period_min, + period_max=period_max).named('foo') if inline =='external': patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' compilers = { - 'cv': mock_compiler('cv', named_objs={'foo': self.mock_cv}) + 'cv': mock_compiler('cv', named_objs={'foo': mock_cv}) } with mock.patch.dict(patch_loc, compilers): vol = build_cv_volume(dct) elif inline == 'internal': vol = build_cv_volume(dct) - assert vol.collectivevariable(1) == 0.5 - snap = make_1d_traj([0.5])[0] - assert vol(snap) + + in_state = make_1d_traj([0.5])[0] + assert vol.collectivevariable(in_state) == 0.5 + assert vol(in_state) + out_state = make_1d_traj([2.0])[0] + assert vol.collectivevariable(out_state) == 2.0 + assert not vol(out_state) + if_periodic = make_1d_traj([7.0])[0] + assert (vol(if_periodic) == (periodic == 'periodic')) expected_class = { 'nonperiodic': paths.CVDefinedVolume, 'periodic': paths.PeriodicCVDefinedVolume diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index d50e3ee7..66927948 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -103,7 +103,6 @@ def test_negated_volume(volume_setup): @pytest.mark.parametrize('periodic', [True, False]) def test_cv_defined_volume(periodic): if periodic: - breakpoint() min_ = 0.0 max_ = 1.0 cv = CoordinateFunctionCV( From 1bf6a892151cbe1f930f9d90334e7fda3654b03b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 21:52:00 -0400 Subject: [PATCH 148/251] Add missing files; make more args kwarg-only --- paths_cli/wizard/parameters.py | 8 +-- paths_cli/wizard/plugins.py | 69 +++++++++++++++++++++++++ paths_cli/wizard/standard_categories.py | 31 +++++++++++ 3 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 paths_cli/wizard/plugins.py create mode 100644 paths_cli/wizard/standard_categories.py diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 6f0744e4..fdad9300 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -19,7 +19,7 @@ class WizardParameter: - def __init__(self, name, ask, loader, helper=None, default=NO_DEFAULT, + def __init__(self, name, ask, loader, *, helper=None, default=NO_DEFAULT, autohelp=False, summarize=None): self.name = name self.ask = ask @@ -57,7 +57,7 @@ def __call__(self, wizard, context): class ExistingObjectParameter(WizardParameter): - def __init__(self, name, ask, loader, store_name, helper=None, + def __init__(self, name, ask, loader, store_name, *, helper=None, default=NO_DEFAULT, autohelp=False, summarize=None): super().__init__(name=name, ask=ask, loader=loader, helper=helper, default=default, autohelp=autohelp, @@ -75,7 +75,7 @@ def __call__(self, wizard, context): class WizardObjectPlugin(OPSPlugin): - def __init__(self, name, category, builder, prerequisite=None, + def __init__(self, name, category, builder, *, prerequisite=None, intro=None, description=None, summary=None, requires_ops=(1,0), requires_cli=(0,3)): super().__init__(requires_ops, requires_cli) @@ -175,7 +175,7 @@ def _build(self, wizard, prereqs): class FromWizardPrerequisite: """Load prerequisites from the wizard. """ - def __init__(self, name, create_func, category, n_required, + def __init__(self, name, create_func, category, n_required, *, obj_name=None, store_name=None, say_select=None, say_create=None, say_finish=None, load_func=None): self.name = name diff --git a/paths_cli/wizard/plugins.py b/paths_cli/wizard/plugins.py new file mode 100644 index 00000000..5b2e0039 --- /dev/null +++ b/paths_cli/wizard/plugins.py @@ -0,0 +1,69 @@ +from collections import defaultdict + +from paths_cli.wizard.parameters import WizardObjectPlugin +from paths_cli.wizard.wrap_compilers import WrapCategory +from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.utils import get_installed_plugins +from paths_cli.plugin_management import NamespacePluginLoader + +class CategoryWizardPluginRegistrationError(Exception): + pass + +_CATEGORY_PLUGINS = {} + +def get_category_wizard(category): + def inner(wizard, context=None): + try: + plugin = _CATEGORY_PLUGINS[category] + except KeyError: + raise CategoryWizardPluginRegistrationError( + f"No wizard plugin for '{category}'" + ) + return plugin(wizard, context) + return inner + +def _register_category_plugin(plugin): + if plugin.name in _CATEGORY_PLUGINS: + raise CategoryWizardPluginRegistrationError( + f"The category '{plugin.name}' has already been reserved " + "by another wizard plugin." + ) + _CATEGORY_PLUGINS[plugin.name] = plugin + + +def register_plugins(plugins): + categories = [] + object_plugins = [] + for plugin in plugins: + if isinstance(plugin, WrapCategory): + categories.append(plugin) + if isinstance(plugin, (WizardObjectPlugin, LoadFromOPS)): + object_plugins.append(plugin) + + for plugin in categories: + # print("Registering " + str(plugin)) + _register_category_plugin(plugin) + + for plugin in object_plugins: + # print("Registering " + str(plugin)) + category = _CATEGORY_PLUGINS[plugin.category] + # print(category) + category.register_plugin(plugin) + +def register_installed_plugins(): + plugin_types = (WrapCategory, WizardObjectPlugin) + plugins = get_installed_plugins( + default_loader=NamespacePluginLoader('paths_cli.wizard', + plugin_types), + plugin_types=plugin_types + ) + register_plugins(plugins) + + file_loader_plugins = get_installed_plugins( + default_loader=NamespacePluginLoader('paths_cli.wizard', + LoadFromOPS), + plugin_types=LoadFromOPS + ) + register_plugins(file_loader_plugins) + + diff --git a/paths_cli/wizard/standard_categories.py b/paths_cli/wizard/standard_categories.py new file mode 100644 index 00000000..1f0ad0f6 --- /dev/null +++ b/paths_cli/wizard/standard_categories.py @@ -0,0 +1,31 @@ +from collections import namedtuple + +Category = namedtuple('Category', ['name', 'singular', 'plural', 'storage']) + +_CATEGORY_LIST = [ + Category(name='engine', + singular='engine', + plural='engines', + storage='engines'), + Category(name='cv', + singular='CV', + plural='CVs', + storage='cvs'), + Category(name='state', + singular='state', + plural='states', + storage='volumes'), + Category(name='volume', + singular='volume', + plural='volumes', + storage='volumes'), +] + +CATEGORIES = {cat.name: cat for cat in _CATEGORY_LIST} + +def get_category_info(category): + try: + return CATEGORIES[category] + except KeyError: + raise RuntimeError(f"No category {category}. Names and store name " + "must be explicitly provided") From f793f89937168b7d94275f6f6630db71d5b3c5ee Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 6 Oct 2021 21:58:27 -0400 Subject: [PATCH 149/251] Drop Python 3.6 support --- .github/workflows/test-suite.yml | 1 - setup.cfg | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 72c93df1..79050ccd 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -27,7 +27,6 @@ jobs: - 3.9 - 3.8 - 3.7 - - 3.6 INTEGRATIONS: [""] include: - CONDA_PY: 3.9 diff --git a/setup.cfg b/setup.cfg index f3abdcd1..0b67e88d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >= 3.6 +python_requires = >= 3.7 install_requires = click tqdm From da866bfc2936e64a1984c3e093a9c4ff55da02cd Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 8 Oct 2021 02:32:48 -0400 Subject: [PATCH 150/251] Add file location to CodeCov action --- .github/workflows/test-suite.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 79050ccd..82e413a5 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -71,3 +71,5 @@ jobs: python -c "import paths_cli" py.test -vv --cov --cov-report xml:cov.xml - uses: codecov/codecov-action@v2 + with: + files: ./cov.xml From 425f925d02725d2477cf4735028dc764430297dd Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 8 Oct 2021 20:57:47 -0400 Subject: [PATCH 151/251] tests for wizard/helper.py --- paths_cli/tests/wizard/test_helper.py | 73 +++++++++++++++++++++++++++ paths_cli/wizard/helper.py | 2 + 2 files changed, 75 insertions(+) create mode 100644 paths_cli/tests/wizard/test_helper.py diff --git a/paths_cli/tests/wizard/test_helper.py b/paths_cli/tests/wizard/test_helper.py new file mode 100644 index 00000000..e4e64dbe --- /dev/null +++ b/paths_cli/tests/wizard/test_helper.py @@ -0,0 +1,73 @@ +import pytest + +from paths_cli.wizard.helper import * +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +def test_raise_quit(): + with pytest.raises(QuitWizard): + raise_quit("foo", None) + +def test_restart(): + with pytest.raises(RestartObjectException): + raise_restart("foo", None) + +def test_force_exit(): + with pytest.raises(SystemExit): + force_exit("foo", None) + +class TestHelper: + def setup(self): + self.helper = Helper(help_func=lambda s, ctx: s) + + def test_help_string(self): + # if the helper "function" is a string, retuwn that string whether + # or not additional arguments to help are given + helper = Helper(help_func="a string") + assert helper("?") == "a string" + assert helper("?foo") == "a string" + + def test_help_with_args(self): + # if the helper can process arguments, do so (our example return the + # input) + assert self.helper("?foo") == "foo" + + def test_command_help_empty(self): + # when calling help with "?!", get the list of commands + assert "The following commands can be used" in self.helper("?!") + + @pytest.mark.parametrize('inp', ["?!q", "?!quit"]) + def test_command_help_for_command(self, inp): + # when calling help on a specific command, get the help for that + # command + assert "The !quit command" in self.helper(inp) + + def test_command_help_no_such_command(self): + # if asking for help on an unknown command, report that there is no + # help available for it + assert "No help for" in self.helper("?!foo") + + @pytest.mark.parametrize('inp', ["!q", "!quit"]) + def test_run_command_quit(self, inp): + # run the quit command when asked to + with pytest.raises(QuitWizard): + self.helper(inp) + + @pytest.mark.parametrize('inp', ["!!q", "!!quit"]) + def test_run_command_force_quit(self, inp): + # run the force quit command when asked to + with pytest.raises(SystemExit): + self.helper(inp) + + def test_run_command_restart(self): + # run the restart command when asked to + with pytest.raises(RestartObjectException): + self.helper("!restart") + + def test_run_command_unknown(self): + # unknown command names should just report an unknown command + assert "Unknown command" in self.helper("!foo") + + def test_empty_helper(self): + empty = Helper(help_func=None) + assert "no help available" in empty("?") + assert "no help available" in empty("?foo") diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index 0f4a9912..fbf003d5 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -1,3 +1,5 @@ +from .errors import RestartObjectException + class QuitWizard(BaseException): pass From 3660805a6ac9e885ccca2d448f89024cdaa6c256 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 21 Oct 2021 03:07:11 -0400 Subject: [PATCH 152/251] some tests & docstrings --- paths_cli/tests/wizard/test_helper.py | 3 + paths_cli/wizard/cvs.py | 10 -- paths_cli/wizard/helper.py | 7 +- paths_cli/wizard/parameters.py | 126 +++++++++++++++++++++++--- paths_cli/wizard/shooting.py | 1 - paths_cli/wizard/volumes.py | 1 - paths_cli/wizard/wizard.py | 8 +- 7 files changed, 128 insertions(+), 28 deletions(-) diff --git a/paths_cli/tests/wizard/test_helper.py b/paths_cli/tests/wizard/test_helper.py index e4e64dbe..01424fc2 100644 --- a/paths_cli/tests/wizard/test_helper.py +++ b/paths_cli/tests/wizard/test_helper.py @@ -67,6 +67,9 @@ def test_run_command_unknown(self): # unknown command names should just report an unknown command assert "Unknown command" in self.helper("!foo") + def test_run_command_no_argument(self): + assert "Please provide a command. The following" in self.helper("!") + def test_empty_helper(self): empty = Helper(help_func=None) assert "no help available" in empty("?") diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index cf259697..c9b454ab 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -199,16 +199,6 @@ def coordinate(wizard, prereqs=None): "you can also create your own and load it from a file.") ) -def cvs(wizard): - wizard.say("You'll need to describe your system in terms of " - "collective variables (CVs). We'll use these to define " - "things like stable states.") - cv_names = list(SUPPORTED_CVS.keys()) - cv_type = wizard.ask_enumerate("What kind of CV do you want to " - "define?", options=cv_names) - cv = SUPPORTED_CVS[cv_type](wizard) - return cv - if __name__ == "__main__": # no-cov from paths_cli.wizard.wizard import Wizard from paths_cli.wizard.plugins import register_installed_plugins diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index fbf003d5..8b99806c 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -68,7 +68,12 @@ def command_help(self, help_args, context): def run_command(self, command, context): cmd_split = command.split() - key = cmd_split[0] + try: + key = cmd_split[0] + except IndexError: + return ("Please provide a command. " + + self.command_help("", context)) + args = " ".join(cmd_split[1:]) try: cmd = self.commands[key] diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index fdad9300..4ccd43ce 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -1,5 +1,6 @@ from paths_cli.compiling.tools import custom_eval import importlib +import textwrap from collections import namedtuple from paths_cli.wizard.helper import Helper @@ -17,8 +18,28 @@ defaults=[None, NO_DEFAULT, False, None] ) - class WizardParameter: + """Load a single parameter from the wizard. + + Parameters + ---------- + name : str + name of this parameter + ask : List[str] + list of strings that the wizard should use to ask the user for + input. These can be formatted with a provided ``context`` dict. + loader : Callable + method to create parameter from user input string + helper : Union[`:class:.Helper`, str] + method to provide help for this parameter + default : Any + default value of this parameter; ``NO_DEFAULT`` if no default exists + autohelp : bool + if True, bad input automatically causes the help information to be + shown. Default False. + summarize : Callable + method to provide summary of created output. + """ def __init__(self, name, ask, loader, *, helper=None, default=NO_DEFAULT, autohelp=False, summarize=None): self.name = name @@ -33,6 +54,15 @@ def __init__(self, name, ask, loader, *, helper=None, default=NO_DEFAULT, @classmethod def from_proxy(cls, proxy, compiler_plugin): + """Create a parameter from a proxy parameter a compiler plugin. + + Parameters + ---------- + proxy : :class:`.ProxyParameter` + proxy with wizard-specific information for user interaction + compiler_plugin : :class:`.InstanceCompilerPlugin` + the compiler plugin to make objects of this type + """ loader_dict = {p.name: p.loader for p in compiler_plugin.parameters} dct = proxy._asdict() loader = loader_dict[proxy.name] @@ -57,6 +87,12 @@ def __call__(self, wizard, context): class ExistingObjectParameter(WizardParameter): + """Special parameter type for parameters created by wizards. + + This should only be created as part of the + :method:`.WizardParameter.from_proxy` method; client code should not use + this directly. + """ def __init__(self, name, ask, loader, store_name, *, helper=None, default=NO_DEFAULT, autohelp=False, summarize=None): super().__init__(name=name, ask=ask, loader=loader, helper=helper, @@ -73,8 +109,39 @@ def __call__(self, wizard, context): obj = wizard.obj_selector(self.store_name, ask, self.loader) return obj +_WIZARD_KWONLY = """ + prerequisite : Callable + method to use to create any objects required for the target object + intro : Union[Callable, str] + method to produce the intro text for the wizard to say + description : str + description to be used in help functions when this plugin is a + choice + summary : Callable + method to create the summary string describing the object that is + created + requires_ops : Tuple[int, int] + version of OpenPathSampling required for this plugin + requires_cli : Tuple[int, int] + version of the OpenPathSampling CLI required for this plugin +""" class WizardObjectPlugin(OPSPlugin): + """Base class for wizard plugins to create OPS objects. + + This allows full overrides of the entire object creation process. For + simple objects, see :class:`.WizardParameterObjectPlugin`, which makes + the easiest cases much easier. + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + builder : Callable + method used to build object based on loaded data + """ + _WIZARD_KWONLY def __init__(self, name, category, builder, *, prerequisite=None, intro=None, description=None, summary=None, requires_ops=(1,0), requires_cli=(0,3)): @@ -91,7 +158,7 @@ def default_summarize(self, wizard, context, result): return [f"Here's what we'll make:\n {str(result)}"] def get_summary(self, wizard, context, result): - # TODO: this patter has been repeated -- make it a function (see + # TODO: this pattern has been repeated -- make it a function (see # also get_intro) summarize = context.get('summarize', self._summary) if summarize is None: @@ -110,7 +177,6 @@ def get_summary(self, wizard, context, result): return summary - def __call__(self, wizard, context=None): if context is None: context = {} @@ -135,30 +201,62 @@ def __repr__(self): class WizardParameterObjectPlugin(WizardObjectPlugin): + """Object plugin that uses :class:`.WizardParameter` + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + parameters : List[:class:`.WizardParameter`] + parameters used in this object + builder : Callable + method used to build object based on loaded parameters -- note, this + must take the names of the parameters as keywords. + """ + _WIZARD_KWONLY def __init__(self, name, category, parameters, builder, *, - prerequisite=None, intro=None, description=None): + prerequisite=None, intro=None, description=None, + summary=None, requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(name=name, category=category, builder=self._build, prerequisite=prerequisite, intro=intro, - description=description) + description=description, summary=summary, + requires_ops=requires_ops, + requires_cli=requires_cli) self.parameters = parameters self.build_func = builder self.proxy_parameters = [] # non-empty if created from proxies @classmethod def from_proxies(cls, name, category, parameters, compiler_plugin, - prerequisite=None, intro=None, description=None): - """ - Use the from_proxies method if you already have a compiler plugin. + prerequisite=None, intro=None, description=None, + summary=None, requires_ops=(1,0), requires_cli=(0,3)): """ + Create plugin from proxy parameters and existing compiler plugin. + + This method facilitates reuse of plugins used in the compiler, + avoiding repeating code to create the instance from user input. + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + parameters : List[ProxyParameter] + proxy parameters containing wizard-specific user interaction + infomration. These must have names that correspond to the names + of the ``compiler_plugin``. + compiler_plugin : :class:`.InstanceCompilerPlugin` + the compiler plugin to use to create the object + """ + textwrap.indent(_WIZARD_KWONLY, ' ' * 4) params = [WizardParameter.from_proxy(proxy, compiler_plugin) for proxy in parameters] - obj = cls(name=name, - category=category, - parameters=params, + obj = cls(name=name, category=category, parameters=params, builder=compiler_plugin.builder, - prerequisite=prerequisite, - intro=intro, - description=description) + prerequisite=prerequisite, intro=intro, + description=description, summary=summary, + requires_ops=requires_ops, requires_cli=requires_cli) obj.proxy_parameters = parameters return obj diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 8a7fb7a7..9aaeef97 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -3,7 +3,6 @@ from paths_cli.wizard.core import get_missing_object # from paths_cli.wizard.engines import engines from paths_cli.wizard.plugins import get_category_wizard -from paths_cli.wizard.cvs import cvs from paths_cli.compiling.tools import custom_eval engines = get_category_wizard('engine') diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 16efb81d..4b85d3c2 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -6,7 +6,6 @@ from paths_cli.wizard.plugins import get_category_wizard from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req -from paths_cli.wizard.cvs import cvs import paths_cli.compiling.volumes from functools import partial diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index cfbc55f6..3f6f5dd7 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -9,6 +9,7 @@ FILE_LOADING_ERROR_MSG, RestartObjectException ) from paths_cli.wizard.joke import name_joke +from paths_cli.wizard.helper import Helper from paths_cli.compiling.tools import custom_eval import shutil @@ -72,6 +73,8 @@ def _speak(self, content, preface): def ask(self, question, options=None, default=None, helper=None, autohelp=False): # TODO: if helper is None, create a default helper + if isinstance(helper, str): + helper = Helper(helper) result = self.console.input("🧙 " + question + " ") self.console.print() if helper and result[0] in ["?", "!"]: @@ -188,7 +191,10 @@ def name(self, obj, obj_type, store_name, default=None): self.say(f"Now let's name your {obj_type}.") name = None while name is None: - name = self.ask("What do you want to call it?") + name_help = ("Objects in OpenPathSampling can be named. You'll " + "use these names to refer back to these objects " + "or to load them from a storage file.") + name = self.ask("What do you want to call it?", helper=name_help) if name in getattr(self, store_name): self.bad_input(f"Sorry, you already have {a_an(obj_type)} " f"named {name}. Please try another name.") From bba408f7a256ebfa0f1ff1e1a3814335043e22de Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 21 Oct 2021 07:27:49 -0400 Subject: [PATCH 153/251] Rename plugins => plugin_registration Also simplify running modules with `python -m` --- paths_cli/commands/wizard.py | 2 +- paths_cli/wizard/cvs.py | 15 +---- paths_cli/wizard/engines.py | 12 +--- paths_cli/wizard/parameters.py | 3 +- .../{plugins.py => plugin_registration.py} | 1 + paths_cli/wizard/run_module.py | 23 +++++++ paths_cli/wizard/shooting.py | 2 +- paths_cli/wizard/steps.py | 3 +- paths_cli/wizard/tps.py | 2 +- paths_cli/wizard/two_state_tps.py | 2 +- paths_cli/wizard/volumes.py | 17 +---- paths_cli/wizard/wizard.py | 3 - paths_cli/wizard/wrap_compilers.py | 62 +++++++++---------- 13 files changed, 70 insertions(+), 77 deletions(-) rename paths_cli/wizard/{plugins.py => plugin_registration.py} (99%) create mode 100644 paths_cli/wizard/run_module.py diff --git a/paths_cli/commands/wizard.py b/paths_cli/commands/wizard.py index 249115e6..50c176d2 100644 --- a/paths_cli/commands/wizard.py +++ b/paths_cli/commands/wizard.py @@ -1,5 +1,5 @@ import click -from paths_cli.wizard.plugins import register_installed_plugins +from paths_cli.wizard.plugin_registration import register_installed_plugins from paths_cli.wizard.two_state_tps import TWO_STATE_TPS_WIZARD from paths_cli import OPSCommandPlugin diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index c9b454ab..569837f9 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -200,16 +200,5 @@ def coordinate(wizard, prereqs=None): ) if __name__ == "__main__": # no-cov - from paths_cli.wizard.wizard import Wizard - from paths_cli.wizard.plugins import register_installed_plugins - register_installed_plugins() - plugins = [obj for obj in globals().values() - if isinstance(obj, WizardObjectPlugin)] - from_file = [obj for obj in globals().values() - if isinstance(obj, LoadFromOPS)] - for plugin in plugins + from_file: - CV_PLUGIN.register_plugin(plugin) - - wiz = Wizard({}) - cv = CV_PLUGIN(wiz) - print(cv) + from paths_cli.wizard.run_module import run_category + run_category('cv') diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index ab1179ac..56a99fc8 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -19,13 +19,7 @@ ENGINE_FROM_FILE = LoadFromOPS('engine') -if __name__ == "__main__": - from paths_cli.wizard import wizard - from paths_cli.wizard.plugins import register_installed_plugins - from paths_cli.wizard.plugins import get_category_wizard - register_installed_plugins() - engines = get_category_wizard('engine') - wiz = wizard.Wizard([]) - engine = engines(wiz) - print(engine) +if __name__ == "__main__": # no-cov + from paths_cli.wizard.run_module import run_category + run_category('engine') diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index 4ccd43ce..cb3f80dd 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -67,7 +67,8 @@ def from_proxy(cls, proxy, compiler_plugin): dct = proxy._asdict() loader = loader_dict[proxy.name] if isinstance(loader, _CategoryCompilerProxy): - from paths_cli.wizard.plugins import get_category_wizard + # TODO: can this import now be moved to top of file? + from paths_cli.wizard.plugin_registration import get_category_wizard category = loader.category dct['loader'] = get_category_wizard(category) dct['ask'] = get_category_info(category).singular diff --git a/paths_cli/wizard/plugins.py b/paths_cli/wizard/plugin_registration.py similarity index 99% rename from paths_cli/wizard/plugins.py rename to paths_cli/wizard/plugin_registration.py index 5b2e0039..7c51fe10 100644 --- a/paths_cli/wizard/plugins.py +++ b/paths_cli/wizard/plugin_registration.py @@ -6,6 +6,7 @@ from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader + class CategoryWizardPluginRegistrationError(Exception): pass diff --git a/paths_cli/wizard/run_module.py b/paths_cli/wizard/run_module.py new file mode 100644 index 00000000..304f8417 --- /dev/null +++ b/paths_cli/wizard/run_module.py @@ -0,0 +1,23 @@ +from paths_cli.wizard.plugin_registration import register_installed_plugins +from paths_cli.wizard.wizard import Wizard +from paths_cli.wizard.plugin_registration import get_category_wizard + +from paths_cli.wizard.parameters import WizardObjectPlugin +from paths_cli.wizard.load_from_ops import LoadFromOPS + +# TODO: for now we ignore this for coverage -- it's mainly used for UX +# testing by allowing each module to be run with, e.g.: +# python -m paths_cli.wizard.engines +# If that functionality is retained, it might be good to find a way to test +# this. +def run_category(category, requirements=None): # -no-cov- + # TODO: if we keep this, fix it up so that it also saves the resulting + # objects + register_installed_plugins() + if requirements is None: + requirements = {} + wiz = Wizard({}) + wiz.requirements = requirements + category_plugin = get_category_wizard(category) + obj = category_plugin(wiz) + print(obj) diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 9aaeef97..3c8774a0 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -2,7 +2,7 @@ from paths_cli.wizard.core import get_missing_object # from paths_cli.wizard.engines import engines -from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.compiling.tools import custom_eval engines = get_category_wizard('engine') diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py index 312ee8b7..f353f966 100644 --- a/paths_cli/wizard/steps.py +++ b/paths_cli/wizard/steps.py @@ -3,10 +3,9 @@ # from paths_cli.wizard.cvs import cvs # from paths_cli.wizard.engines import engines -from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.tps import tps_scheme -from paths_cli.wizard.plugins import get_category_wizard volumes = get_category_wizard('volume') WizardStep = namedtuple('WizardStep', ['func', 'display_name', 'store_name', diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 8c91ee08..eaf035a8 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -2,7 +2,7 @@ from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.shooting import shooting from paths_cli.wizard.volumes import VOLUMES_PLUGIN as volumes -from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.plugin_registration import get_category_wizard from functools import partial volumes = get_category_wizard('volume') diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index dfb57c9e..53e40a4a 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -1,4 +1,4 @@ -from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.tps import tps_scheme from paths_cli.wizard.steps import ( SINGLE_ENGINE_STEP, CVS_STEP, WizardStep diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 4b85d3c2..41f53d42 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -3,7 +3,7 @@ ProxyParameter, WizardParameterObjectPlugin, WizardObjectPlugin ) from paths_cli.wizard.load_from_ops import LoadFromOPS -from paths_cli.wizard.plugins import get_category_wizard +from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes @@ -121,17 +121,6 @@ def volume_ask(wizard, context): helper="No volume help yet" ) - if __name__ == "__main__": # no-cov - from paths_cli.wizard.wizard import Wizard - from paths_cli.wizard.plugins import register_installed_plugins - register_installed_plugins() - plugins = [obj for obj in globals().values() - if isinstance(obj, WizardObjectPlugin)] - from_file = [obj for obj in globals().values() - if isinstance(obj, LoadFromOPS)] - for plugin in plugins + from_file: - VOLUMES_PLUGIN.register_plugin(plugin) - wiz = Wizard([]) - wiz.requirements['state'] = ('volumes', 1, 1) - VOLUMES_PLUGIN(wiz) + from paths_cli.wizard.run_module import run_category + run_category('volume', {'state': ('volumes', 1, 1)}) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 3f6f5dd7..3c92e2cb 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -323,6 +323,3 @@ def run_wizard(self): # TWO_STATE_TIS_WIZARD # MULTIPLE_STATE_TIS_WIZARD # MULTIPLE_INTERFACE_SET_TIS_WIZARD - -if __name__ == "__main__": - FLEX_LENGTH_TPS_WIZARD.run_wizard() diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py index 2f0fe93e..27abc3b5 100644 --- a/paths_cli/wizard/wrap_compilers.py +++ b/paths_cli/wizard/wrap_compilers.py @@ -3,37 +3,37 @@ from .helper import Helper from paths_cli.plugin_management import OPSPlugin -class WrapCompilerWizardPlugin: - def __init__(self, name, category, parameters, compiler_plugin, - prerequisite=None, intro=None, description=None): - self.name = name - self.parameters = parameters - self.compiler_plugin = compiler_plugin - self.prerequisite = prerequisite - self.intro = intro - self.description = description - loaders = {p.name: p.loader for p in self.compiler_plugin.parameters} - for param in self.parameters: - param.register_loader(loaders[param.name]) - - def _builder(self, wizard, prereqs): - dct = dict(prereqs) # make a copy - dct.update({param.name: param(wizard) for param in self.parameters}) - result = self.compiler_plugin(**dct) - return result - - def __call__(self, wizard): - if self.intro is not None: - wizard.say(self.intro) - - if self.prerequisite is not None: - prereqs = self.prerequisite(wizard) - else: - prereqs = {} - - result = self._builder(wizard, prereqs) - - return result +# class WrapCompilerWizardPlugin: +# def __init__(self, name, category, parameters, compiler_plugin, +# prerequisite=None, intro=None, description=None): +# self.name = name +# self.parameters = parameters +# self.compiler_plugin = compiler_plugin +# self.prerequisite = prerequisite +# self.intro = intro +# self.description = description +# loaders = {p.name: p.loader for p in self.compiler_plugin.parameters} +# for param in self.parameters: +# param.register_loader(loaders[param.name]) + +# def _builder(self, wizard, prereqs): +# dct = dict(prereqs) # make a copy +# dct.update({param.name: param(wizard) for param in self.parameters}) +# result = self.compiler_plugin(**dct) +# return result + +# def __call__(self, wizard): +# if self.intro is not None: +# wizard.say(self.intro) + +# if self.prerequisite is not None: +# prereqs = self.prerequisite(wizard) +# else: +# prereqs = {} + +# result = self._builder(wizard, prereqs) + +# return result class CategoryHelpFunc: def __init__(self, category, basic_help=None): From 6cfc5368217221e97eae3c6ff46671b9894a3b15 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 21 Oct 2021 09:03:26 -0400 Subject: [PATCH 154/251] Start plugin_classes with LoadFromOPS --- paths_cli/wizard/cvs.py | 2 +- paths_cli/wizard/engines.py | 5 +--- paths_cli/wizard/joke.py | 2 +- paths_cli/wizard/load_from_ops.py | 31 ------------------------- paths_cli/wizard/plugin_classes.py | 21 +++++++++++++++++ paths_cli/wizard/plugin_registration.py | 3 ++- paths_cli/wizard/run_module.py | 2 +- paths_cli/wizard/volumes.py | 2 +- 8 files changed, 28 insertions(+), 40 deletions(-) create mode 100644 paths_cli/wizard/plugin_classes.py diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 569837f9..29511c6c 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,5 +1,5 @@ from paths_cli.compiling.tools import mdtraj_parse_atomlist -from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.wizard.plugin_classes import LoadFromOPS from paths_cli.wizard.core import get_object import paths_cli.wizard.engines diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 56a99fc8..a40a41e6 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -1,8 +1,5 @@ import paths_cli.wizard.openmm as openmm -from paths_cli.wizard.load_from_ops import ( - LoadFromOPS, - load_from_ops, LABEL as load_label -) +from paths_cli.wizard.plugin_classes import LoadFromOPS from functools import partial from paths_cli.wizard.wrap_compilers import WrapCategory diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index 52546ef2..10229b1d 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -41,7 +41,7 @@ def name_joke(name, obj_type): # no-cov joke = random.choices(jokes, weights=weights)[0] return joke(name, obj_type) -if __name__ == "__main__": +if __name__ == "__main__": # no-cov for _ in range(5): print() print(name_joke('AD_300K', 'engine')) diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index a2f07200..70e09d03 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -46,34 +46,3 @@ def load_from_ops(wizard, store_name, obj_name): storage = _get_ops_storage(wizard) obj = _get_ops_object(wizard, storage, store_name, obj_name) return obj - - -from paths_cli.plugin_management import OPSPlugin -class LoadFromOPS(OPSPlugin): - def __init__(self, category, obj_name=None, store_name=None, - requires_ops=(1,0), requires_cli=(0,3)): - super().__init__(requires_ops, requires_cli) - self.category = category - self.name = "Load existing from OPS file" - if obj_name is None: - obj_name = self._get_category_info(category).singular - - if store_name is None: - store_name = self._get_category_info(category).storage - - self.obj_name = obj_name - self.store_name = store_name - - @staticmethod - def _get_category_info(category): - try: - return CATEGORIES[category] - except KeyError: - raise RuntimeError(f"No category {category}. Extra names must " - "be given explicitly") - - def __call__(self, wizard): - wizard.say("Okay, we'll load it from an OPS file.") - storage = _get_ops_storage(wizard) - obj = _get_ops_storage(wizard, storage, self.store_name, - self.obj_name) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py new file mode 100644 index 00000000..7fdfd769 --- /dev/null +++ b/paths_cli/wizard/plugin_classes.py @@ -0,0 +1,21 @@ +from paths_cli.plugin_management import OPSPlugin +from paths_cli.wizard.standard_categories import get_category_info +from paths_cli.wizard.load_from_ops import load_from_ops + +class LoadFromOPS(OPSPlugin): + def __init__(self, category, obj_name=None, store_name=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) + self.category = category + self.name = "Load existing from OPS file" + if obj_name is None: + obj_name = get_category_info(category).singular + + if store_name is None: + store_name = get_category_info(category).storage + + self.obj_name = obj_name + self.store_name = store_name + + def __call__(self, wizard): + return load_from_ops(wizard, self.store_name, self.obj_name) diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index 7c51fe10..22f66959 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -2,7 +2,7 @@ from paths_cli.wizard.parameters import WizardObjectPlugin from paths_cli.wizard.wrap_compilers import WrapCategory -from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.wizard.plugin_classes import LoadFromOPS from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader @@ -23,6 +23,7 @@ def inner(wizard, context=None): return plugin(wizard, context) return inner + def _register_category_plugin(plugin): if plugin.name in _CATEGORY_PLUGINS: raise CategoryWizardPluginRegistrationError( diff --git a/paths_cli/wizard/run_module.py b/paths_cli/wizard/run_module.py index 304f8417..fef467dd 100644 --- a/paths_cli/wizard/run_module.py +++ b/paths_cli/wizard/run_module.py @@ -3,7 +3,7 @@ from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.parameters import WizardObjectPlugin -from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.wizard.plugin_classes import LoadFromOPS # TODO: for now we ignore this for coverage -- it's mainly used for UX # testing by allowing each module to be run with, e.g.: diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 41f53d42..35b45f31 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -2,7 +2,7 @@ from paths_cli.wizard.parameters import ( ProxyParameter, WizardParameterObjectPlugin, WizardObjectPlugin ) -from paths_cli.wizard.load_from_ops import LoadFromOPS +from paths_cli.wizard.plugin_classes import LoadFromOPS from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req From 47c744a72d93a63a3a0eb11fed144758c8d7702b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 21 Oct 2021 09:39:46 -0400 Subject: [PATCH 155/251] move wizard object plugins into plugin_classes --- paths_cli/wizard/cvs.py | 6 +- paths_cli/wizard/openmm.py | 5 +- paths_cli/wizard/parameters.py | 162 ----------------------- paths_cli/wizard/plugin_classes.py | 167 ++++++++++++++++++++++++ paths_cli/wizard/plugin_registration.py | 5 +- paths_cli/wizard/run_module.py | 3 - paths_cli/wizard/volumes.py | 6 +- 7 files changed, 179 insertions(+), 175 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index 29511c6c..e0a20392 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,5 +1,7 @@ from paths_cli.compiling.tools import mdtraj_parse_atomlist -from paths_cli.wizard.plugin_classes import LoadFromOPS +from paths_cli.wizard.plugin_classes import ( + LoadFromOPS, WizardObjectPlugin +) from paths_cli.wizard.core import get_object import paths_cli.wizard.engines @@ -8,7 +10,7 @@ import numpy as np from paths_cli.wizard.parameters import ( - WizardObjectPlugin, FromWizardPrerequisite + FromWizardPrerequisite ) from paths_cli.wizard.helper import Helper diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 354f3144..659d332d 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -9,9 +9,8 @@ "openmm.openmm.XmlSerializer.html" ) -from paths_cli.wizard.parameters import ( - ProxyParameter, WizardParameterObjectPlugin -) +from paths_cli.wizard.parameters import ProxyParameter +from paths_cli.wizard.plugin_classes import WizardParameterObjectPlugin from paths_cli.compiling.engines import OPENMM_PLUGIN as OPENMM_COMPILING diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index cb3f80dd..d8456f39 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -1,12 +1,10 @@ from paths_cli.compiling.tools import custom_eval import importlib -import textwrap from collections import namedtuple from paths_cli.wizard.helper import Helper from paths_cli.compiling.root_compiler import _CategoryCompilerProxy from paths_cli.wizard.standard_categories import get_category_info -from paths_cli.plugin_management import OPSPlugin NO_DEFAULT = object() @@ -110,166 +108,6 @@ def __call__(self, wizard, context): obj = wizard.obj_selector(self.store_name, ask, self.loader) return obj -_WIZARD_KWONLY = """ - prerequisite : Callable - method to use to create any objects required for the target object - intro : Union[Callable, str] - method to produce the intro text for the wizard to say - description : str - description to be used in help functions when this plugin is a - choice - summary : Callable - method to create the summary string describing the object that is - created - requires_ops : Tuple[int, int] - version of OpenPathSampling required for this plugin - requires_cli : Tuple[int, int] - version of the OpenPathSampling CLI required for this plugin -""" - -class WizardObjectPlugin(OPSPlugin): - """Base class for wizard plugins to create OPS objects. - - This allows full overrides of the entire object creation process. For - simple objects, see :class:`.WizardParameterObjectPlugin`, which makes - the easiest cases much easier. - - Parameters - ---------- - name : str - name of this object type - category : str - name of the category to which this object belongs - builder : Callable - method used to build object based on loaded data - """ + _WIZARD_KWONLY - def __init__(self, name, category, builder, *, prerequisite=None, - intro=None, description=None, summary=None, - requires_ops=(1,0), requires_cli=(0,3)): - super().__init__(requires_ops, requires_cli) - self.name = name - self.category = category - self.builder = builder - self.prerequisite = prerequisite - self.intro = intro - self.description = description - self._summary = summary # func to summarize - - def default_summarize(self, wizard, context, result): - return [f"Here's what we'll make:\n {str(result)}"] - - def get_summary(self, wizard, context, result): - # TODO: this pattern has been repeated -- make it a function (see - # also get_intro) - summarize = context.get('summarize', self._summary) - if summarize is None: - summarize = self.default_summarize - - try: - summary = summarize(wizard, context, result) - except TypeError: - summary = summarize - - if summary is None: - summary = [] - - if isinstance(summary, str): - summary = [summary] - - return summary - - def __call__(self, wizard, context=None): - if context is None: - context = {} - - if self.intro is not None: - wizard.say(self.intro) - - if self.prerequisite is not None: - prereqs = self.prerequisite(wizard) - else: - prereqs = {} - - result = self.builder(wizard, prereqs) - summary = self.get_summary(wizard, context, result) - for line in summary: - wizard.say(line) - return result - - def __repr__(self): - return (f"{self.__class__.__name__}(name={self.name}, " - f"category={self.category})") - - -class WizardParameterObjectPlugin(WizardObjectPlugin): - """Object plugin that uses :class:`.WizardParameter` - - Parameters - ---------- - name : str - name of this object type - category : str - name of the category to which this object belongs - parameters : List[:class:`.WizardParameter`] - parameters used in this object - builder : Callable - method used to build object based on loaded parameters -- note, this - must take the names of the parameters as keywords. - """ + _WIZARD_KWONLY - def __init__(self, name, category, parameters, builder, *, - prerequisite=None, intro=None, description=None, - summary=None, requires_ops=(1, 0), requires_cli=(0, 3)): - super().__init__(name=name, category=category, builder=self._build, - prerequisite=prerequisite, intro=intro, - description=description, summary=summary, - requires_ops=requires_ops, - requires_cli=requires_cli) - self.parameters = parameters - self.build_func = builder - self.proxy_parameters = [] # non-empty if created from proxies - - @classmethod - def from_proxies(cls, name, category, parameters, compiler_plugin, - prerequisite=None, intro=None, description=None, - summary=None, requires_ops=(1,0), requires_cli=(0,3)): - """ - Create plugin from proxy parameters and existing compiler plugin. - - This method facilitates reuse of plugins used in the compiler, - avoiding repeating code to create the instance from user input. - - Parameters - ---------- - name : str - name of this object type - category : str - name of the category to which this object belongs - parameters : List[ProxyParameter] - proxy parameters containing wizard-specific user interaction - infomration. These must have names that correspond to the names - of the ``compiler_plugin``. - compiler_plugin : :class:`.InstanceCompilerPlugin` - the compiler plugin to use to create the object - """ + textwrap.indent(_WIZARD_KWONLY, ' ' * 4) - params = [WizardParameter.from_proxy(proxy, compiler_plugin) - for proxy in parameters] - obj = cls(name=name, category=category, parameters=params, - builder=compiler_plugin.builder, - prerequisite=prerequisite, intro=intro, - description=description, summary=summary, - requires_ops=requires_ops, requires_cli=requires_cli) - obj.proxy_parameters = parameters - return obj - - def _build(self, wizard, prereqs): - dct = dict(prereqs) - context = {'obj_dict': dct} - for param in self.parameters: - dct[param.name] = param(wizard, context) - # dct.update({p.name: p(wizard) for p in self.parameters}) - result = self.build_func(**dct) - return result - class FromWizardPrerequisite: """Load prerequisites from the wizard. diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 7fdfd769..27109c7c 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -1,6 +1,27 @@ from paths_cli.plugin_management import OPSPlugin from paths_cli.wizard.standard_categories import get_category_info from paths_cli.wizard.load_from_ops import load_from_ops +from paths_cli.wizard.parameters import WizardParameter +import textwrap + +_WIZARD_KWONLY = """ + prerequisite : Callable + method to use to create any objects required for the target object + intro : Union[Callable, str] + method to produce the intro text for the wizard to say + description : str + description to be used in help functions when this plugin is a + choice + summary : Callable + method to create the summary string describing the object that is + created + requires_ops : Tuple[int, int] + version of OpenPathSampling required for this plugin + requires_cli : Tuple[int, int] + version of the OpenPathSampling CLI required for this plugin +""" + + class LoadFromOPS(OPSPlugin): def __init__(self, category, obj_name=None, store_name=None, @@ -19,3 +40,149 @@ def __init__(self, category, obj_name=None, store_name=None, def __call__(self, wizard): return load_from_ops(wizard, self.store_name, self.obj_name) + + +class WizardObjectPlugin(OPSPlugin): + """Base class for wizard plugins to create OPS objects. + + This allows full overrides of the entire object creation process. For + simple objects, see :class:`.WizardParameterObjectPlugin`, which makes + the easiest cases much easier. + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + builder : Callable + method used to build object based on loaded data + """ + _WIZARD_KWONLY + def __init__(self, name, category, builder, *, prerequisite=None, + intro=None, description=None, summary=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) + self.name = name + self.category = category + self.builder = builder + self.prerequisite = prerequisite + self.intro = intro + self.description = description + self._summary = summary # func to summarize + + def default_summarize(self, wizard, context, result): + return [f"Here's what we'll make:\n {str(result)}"] + + def get_summary(self, wizard, context, result): + # TODO: this pattern has been repeated -- make it a function (see + # also get_intro) + summarize = context.get('summarize', self._summary) + if summarize is None: + summarize = self.default_summarize + + try: + summary = summarize(wizard, context, result) + except TypeError: + summary = summarize + + if summary is None: + summary = [] + + if isinstance(summary, str): + summary = [summary] + + return summary + + def __call__(self, wizard, context=None): + if context is None: + context = {} + + if self.intro is not None: + wizard.say(self.intro) + + if self.prerequisite is not None: + prereqs = self.prerequisite(wizard) + else: + prereqs = {} + + result = self.builder(wizard, prereqs) + summary = self.get_summary(wizard, context, result) + for line in summary: + wizard.say(line) + return result + + def __repr__(self): + return (f"{self.__class__.__name__}(name={self.name}, " + f"category={self.category})") + + +class WizardParameterObjectPlugin(WizardObjectPlugin): + """Object plugin that uses :class:`.WizardParameter` + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + parameters : List[:class:`.WizardParameter`] + parameters used in this object + builder : Callable + method used to build object based on loaded parameters -- note, this + must take the names of the parameters as keywords. + """ + _WIZARD_KWONLY + def __init__(self, name, category, parameters, builder, *, + prerequisite=None, intro=None, description=None, + summary=None, requires_ops=(1, 0), requires_cli=(0, 3)): + super().__init__(name=name, category=category, builder=self._build, + prerequisite=prerequisite, intro=intro, + description=description, summary=summary, + requires_ops=requires_ops, + requires_cli=requires_cli) + self.parameters = parameters + self.build_func = builder + self.proxy_parameters = [] # non-empty if created from proxies + + @classmethod + def from_proxies(cls, name, category, parameters, compiler_plugin, + prerequisite=None, intro=None, description=None, + summary=None, requires_ops=(1,0), requires_cli=(0,3)): + """ + Create plugin from proxy parameters and existing compiler plugin. + + This method facilitates reuse of plugins used in the compiler, + avoiding repeating code to create the instance from user input. + + Parameters + ---------- + name : str + name of this object type + category : str + name of the category to which this object belongs + parameters : List[ProxyParameter] + proxy parameters containing wizard-specific user interaction + infomration. These must have names that correspond to the names + of the ``compiler_plugin``. + compiler_plugin : :class:`.InstanceCompilerPlugin` + the compiler plugin to use to create the object + """ + textwrap.indent(_WIZARD_KWONLY, ' ' * 4) + params = [WizardParameter.from_proxy(proxy, compiler_plugin) + for proxy in parameters] + obj = cls(name=name, category=category, parameters=params, + builder=compiler_plugin.builder, + prerequisite=prerequisite, intro=intro, + description=description, summary=summary, + requires_ops=requires_ops, requires_cli=requires_cli) + obj.proxy_parameters = parameters + return obj + + def _build(self, wizard, prereqs): + dct = dict(prereqs) + context = {'obj_dict': dct} + for param in self.parameters: + dct[param.name] = param(wizard, context) + # dct.update({p.name: p(wizard) for p in self.parameters}) + result = self.build_func(**dct) + return result + + diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index 22f66959..f116c557 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -1,8 +1,9 @@ from collections import defaultdict -from paths_cli.wizard.parameters import WizardObjectPlugin from paths_cli.wizard.wrap_compilers import WrapCategory -from paths_cli.wizard.plugin_classes import LoadFromOPS +from paths_cli.wizard.plugin_classes import ( + LoadFromOPS, WizardObjectPlugin +) from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader diff --git a/paths_cli/wizard/run_module.py b/paths_cli/wizard/run_module.py index fef467dd..f1eec448 100644 --- a/paths_cli/wizard/run_module.py +++ b/paths_cli/wizard/run_module.py @@ -2,9 +2,6 @@ from paths_cli.wizard.wizard import Wizard from paths_cli.wizard.plugin_registration import get_category_wizard -from paths_cli.wizard.parameters import WizardObjectPlugin -from paths_cli.wizard.plugin_classes import LoadFromOPS - # TODO: for now we ignore this for coverage -- it's mainly used for UX # testing by allowing each module to be run with, e.g.: # python -m paths_cli.wizard.engines diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 35b45f31..9bc9e364 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -1,8 +1,8 @@ import operator -from paths_cli.wizard.parameters import ( - ProxyParameter, WizardParameterObjectPlugin, WizardObjectPlugin +from paths_cli.wizard.parameters import ProxyParameter +from paths_cli.wizard.plugin_classes import ( + LoadFromOPS, WizardParameterObjectPlugin, WizardObjectPlugin ) -from paths_cli.wizard.plugin_classes import LoadFromOPS from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req From 36c887d521e852afb03d108ee313d13bc9ececa6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 21 Oct 2021 23:38:45 -0400 Subject: [PATCH 156/251] move WrapCategory to plugin_classes --- paths_cli/wizard/cvs.py | 3 +- paths_cli/wizard/engines.py | 4 +- paths_cli/wizard/plugin_classes.py | 95 +++++++++++++++++++++++++ paths_cli/wizard/plugin_registration.py | 3 +- paths_cli/wizard/volumes.py | 4 +- paths_cli/wizard/wrap_compilers.py | 94 ------------------------ 6 files changed, 100 insertions(+), 103 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index e0a20392..b2daf142 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,6 +1,6 @@ from paths_cli.compiling.tools import mdtraj_parse_atomlist from paths_cli.wizard.plugin_classes import ( - LoadFromOPS, WizardObjectPlugin + LoadFromOPS, WizardObjectPlugin, WrapCategory ) from paths_cli.wizard.core import get_object import paths_cli.wizard.engines @@ -15,7 +15,6 @@ from paths_cli.wizard.helper import Helper -from paths_cli.wizard.wrap_compilers import WrapCategory try: import mdtraj as md diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index a40a41e6..2ae60b9c 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -1,9 +1,7 @@ import paths_cli.wizard.openmm as openmm -from paths_cli.wizard.plugin_classes import LoadFromOPS +from paths_cli.wizard.plugin_classes import LoadFromOPS, WrapCategory from functools import partial -from paths_cli.wizard.wrap_compilers import WrapCategory - _ENGINE_HELP = "An engine describes how you'll do the actual dynamics." ENGINE_PLUGIN = WrapCategory( name='engine', diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 27109c7c..edd1a48f 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -2,6 +2,7 @@ from paths_cli.wizard.standard_categories import get_category_info from paths_cli.wizard.load_from_ops import load_from_ops from paths_cli.wizard.parameters import WizardParameter +from paths_cli.wizard.helper import Helper import textwrap _WIZARD_KWONLY = """ @@ -186,3 +187,97 @@ def _build(self, wizard, prereqs): return result +class CategoryHelpFunc: + def __init__(self, category, basic_help=None): + self.category = category + if basic_help is None: + basic_help = f"Sorry, no help available for {category.name}." + self.basic_help = basic_help + + def __call__(self, help_args, context): + if not help_args: + return self.basic_help + help_dict = {} + for num, (name, obj) in enumerate(self.category.choices.items()): + try: + help_str = obj.description + except Exception: + help_str = f"Sorry, no help available for '{name}'." + help_dict[str(num+1)] = help_str + help_dict[name] = help_str + + try: + result = help_dict[help_args] + except KeyError: + result = None + return result + + +class WrapCategory(OPSPlugin): + def __init__(self, name, ask, helper=None, intro=None, set_context=None, + requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) + self.name = name + if isinstance(intro, str): + intro = [intro] + self.intro = intro + self.ask = ask + self._set_context = set_context + if helper is None: + helper = Helper(CategoryHelpFunc(self)) + if isinstance(helper, str): + helper = Helper(CategoryHelpFunc(self, helper)) + + self.helper = helper + self.choices = {} + + def __repr__(self): + return f"{self.__class__.__name__}({self.name})" + + def set_context(self, wizard, context, selected): + if self._set_context: + return self._set_context(wizard, context, selected) + else: + return context + + def register_plugin(self, plugin): + self.choices[plugin.name] = plugin + + def get_intro(self, wizard, context): + intro = context.get('intro', self.intro) + + try: + intro = intro(wizard, context) + except TypeError: + pass + + if intro is None: + intro = [] + + return intro + + def get_ask(self, wizard, context): + try: + ask = self.ask(wizard, context) + except TypeError: + ask = self.ask.format(**context) + return ask + + def __call__(self, wizard, context=None): + if context is None: + context = {} + + intro = self.get_intro(wizard, context) + + for line in intro: + wizard.say(line) + + ask = self.get_ask(wizard, context) + + selected = wizard.ask_enumerate_dict(ask, self.choices, + self.helper) + + new_context = self.set_context(wizard, context, selected) + obj = selected(wizard, new_context) + return obj + diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index f116c557..0e35229e 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -1,8 +1,7 @@ from collections import defaultdict -from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.plugin_classes import ( - LoadFromOPS, WizardObjectPlugin + LoadFromOPS, WizardObjectPlugin, WrapCategory ) from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 9bc9e364..c86c0b69 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -1,10 +1,10 @@ import operator from paths_cli.wizard.parameters import ProxyParameter from paths_cli.wizard.plugin_classes import ( - LoadFromOPS, WizardParameterObjectPlugin, WizardObjectPlugin + LoadFromOPS, WizardParameterObjectPlugin, WizardObjectPlugin, + WrapCategory ) from paths_cli.wizard.plugin_registration import get_category_wizard -from paths_cli.wizard.wrap_compilers import WrapCategory from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes from functools import partial diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py index 27abc3b5..44af5404 100644 --- a/paths_cli/wizard/wrap_compilers.py +++ b/paths_cli/wizard/wrap_compilers.py @@ -35,97 +35,3 @@ # return result -class CategoryHelpFunc: - def __init__(self, category, basic_help=None): - self.category = category - if basic_help is None: - basic_help = f"Sorry, no help available for {category.name}." - self.basic_help = basic_help - - def __call__(self, help_args, context): - if not help_args: - return self.basic_help - help_dict = {} - for num, (name, obj) in enumerate(self.category.choices.items()): - try: - help_str = obj.description - except Exception: - help_str = f"Sorry, no help available for '{name}'." - help_dict[str(num+1)] = help_str - help_dict[name] = help_str - - try: - result = help_dict[help_args] - except KeyError: - result = None - return result - - -class WrapCategory(OPSPlugin): - def __init__(self, name, ask, helper=None, intro=None, set_context=None, - requires_ops=(1,0), requires_cli=(0,3)): - super().__init__(requires_ops, requires_cli) - self.name = name - if isinstance(intro, str): - intro = [intro] - self.intro = intro - self.ask = ask - self._set_context = set_context - if helper is None: - helper = Helper(CategoryHelpFunc(self)) - if isinstance(helper, str): - helper = Helper(CategoryHelpFunc(self, helper)) - - self.helper = helper - self.choices = {} - - def __repr__(self): - return f"{self.__class__.__name__}({self.name})" - - def set_context(self, wizard, context, selected): - if self._set_context: - return self._set_context(wizard, context, selected) - else: - return context - - def register_plugin(self, plugin): - self.choices[plugin.name] = plugin - - def get_intro(self, wizard, context): - intro = context.get('intro', self.intro) - - try: - intro = intro(wizard, context) - except TypeError: - pass - - if intro is None: - intro = [] - - return intro - - def get_ask(self, wizard, context): - try: - ask = self.ask(wizard, context) - except TypeError: - ask = self.ask.format(**context) - return ask - - def __call__(self, wizard, context=None): - if context is None: - context = {} - - intro = self.get_intro(wizard, context) - - for line in intro: - wizard.say(line) - - ask = self.get_ask(wizard, context) - - selected = wizard.ask_enumerate_dict(ask, self.choices, - self.helper) - - new_context = self.set_context(wizard, context, selected) - obj = selected(wizard, new_context) - return obj - From f02a3676f3cf851b243debefb8a88935642087e9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 22 Oct 2021 12:54:14 -0400 Subject: [PATCH 157/251] tests for wizard.parameters,standard_categories --- .../tests/wizard/test_standard_categories.py | 13 +++++++ paths_cli/wizard/cvs.py | 5 ++- paths_cli/wizard/parameters.py | 15 ++++---- paths_cli/wizard/plugin_classes.py | 5 ++- paths_cli/wizard/wrap_compilers.py | 37 ------------------- 5 files changed, 28 insertions(+), 47 deletions(-) create mode 100644 paths_cli/tests/wizard/test_standard_categories.py delete mode 100644 paths_cli/wizard/wrap_compilers.py diff --git a/paths_cli/tests/wizard/test_standard_categories.py b/paths_cli/tests/wizard/test_standard_categories.py new file mode 100644 index 00000000..77e4f9e6 --- /dev/null +++ b/paths_cli/tests/wizard/test_standard_categories.py @@ -0,0 +1,13 @@ +import pytest +from paths_cli.wizard.standard_categories import * + +def test_get_category_info(): + cat = get_category_info('cv') + assert cat.name == 'cv' + assert cat.singular == 'CV' + assert cat.plural == "CVs" + assert cat.storage == "cvs" + +def test_get_category_info_error(): + with pytest.raises(RuntimeError, match="No category"): + get_category_info("foo") diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index b2daf142..d5aab50b 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -46,8 +46,9 @@ n_required=1, say_create=("Hey, you need to define an MD engine before you create " "CVs that refer to it. Let's do that now!"), - say_select=("You have defined multiple engines, and need to pick one " - "to use to get a topology for your CV."), + # TODO: consider for future -- is it worth adding say_select support? + # say_select=("You have defined multiple engines, and need to pick one " + # "to use to get a topology for your CV."), say_finish="Now let's get back to defining your CV.", load_func=lambda engine: engine.topology ) diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index d8456f39..d6dd70c4 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -113,8 +113,8 @@ class FromWizardPrerequisite: """Load prerequisites from the wizard. """ def __init__(self, name, create_func, category, n_required, *, - obj_name=None, store_name=None, say_select=None, - say_create=None, say_finish=None, load_func=None): + obj_name=None, store_name=None, say_create=None, + say_finish=None, load_func=None): self.name = name self.create_func = create_func self.category = category @@ -126,7 +126,6 @@ def __init__(self, name, create_func, category, n_required, *, self.obj_name = obj_name self.store_name = store_name self.n_required = n_required - self.say_select = say_select self.say_create = say_create self.say_finish = say_finish if load_func is None: @@ -147,10 +146,10 @@ def get_existing(self, wizard): results = [self.load_func(obj) for obj in all_objs] return results - def select_existing(self, wizard): + def select_single_existing(self, wizard): obj = wizard.obj_selector(self.store_name, self.obj_name, self.create_func) - return [self.load_func(obj)] + return self.load_func(obj) def __call__(self, wizard): n_existing = len(getattr(wizard, self.store_name)) @@ -158,9 +157,11 @@ def __call__(self, wizard): # early return in this case (return silently) return {self.name: self.get_existing(wizard)} elif n_existing > self.n_required: - dct = {self.name: self.select_existing(wizard)} + sel = [self.select_single_existing(wizard) + for _ in range(self.n_required)] + dct = {self.name: sel} else: - objs = [] + objs = self.get_existing(wizard) while len(getattr(wizard, self.store_name)) < self.n_required: objs.append(self.create_new(wizard)) dct = {self.name: objs} diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index edd1a48f..8f6e28d5 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -167,6 +167,9 @@ def from_proxies(cls, name, category, parameters, compiler_plugin, compiler_plugin : :class:`.InstanceCompilerPlugin` the compiler plugin to use to create the object """ + textwrap.indent(_WIZARD_KWONLY, ' ' * 4) + # TODO: it perhaps we can make this so that we have a method + # proxy.to_parameter(compiler_plugin) -- this might break some + # circular dependencies params = [WizardParameter.from_proxy(proxy, compiler_plugin) for proxy in parameters] obj = cls(name=name, category=category, parameters=params, @@ -174,7 +177,7 @@ def from_proxies(cls, name, category, parameters, compiler_plugin, prerequisite=prerequisite, intro=intro, description=description, summary=summary, requires_ops=requires_ops, requires_cli=requires_cli) - obj.proxy_parameters = parameters + obj.proxy_parameters = parameters # stored for future debugging return obj def _build(self, wizard, prereqs): diff --git a/paths_cli/wizard/wrap_compilers.py b/paths_cli/wizard/wrap_compilers.py deleted file mode 100644 index 44af5404..00000000 --- a/paths_cli/wizard/wrap_compilers.py +++ /dev/null @@ -1,37 +0,0 @@ -NO_PARAMETER_LOADED = object() - -from .helper import Helper -from paths_cli.plugin_management import OPSPlugin - -# class WrapCompilerWizardPlugin: -# def __init__(self, name, category, parameters, compiler_plugin, -# prerequisite=None, intro=None, description=None): -# self.name = name -# self.parameters = parameters -# self.compiler_plugin = compiler_plugin -# self.prerequisite = prerequisite -# self.intro = intro -# self.description = description -# loaders = {p.name: p.loader for p in self.compiler_plugin.parameters} -# for param in self.parameters: -# param.register_loader(loaders[param.name]) - -# def _builder(self, wizard, prereqs): -# dct = dict(prereqs) # make a copy -# dct.update({param.name: param(wizard) for param in self.parameters}) -# result = self.compiler_plugin(**dct) -# return result - -# def __call__(self, wizard): -# if self.intro is not None: -# wizard.say(self.intro) - -# if self.prerequisite is not None: -# prereqs = self.prerequisite(wizard) -# else: -# prereqs = {} - -# result = self._builder(wizard, prereqs) - -# return result - From 7e6b55e72d50e1ff953c9518e1cab806cf198dd3 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 22 Oct 2021 13:02:29 -0400 Subject: [PATCH 158/251] add test_parameters.py --- paths_cli/tests/wizard/test_parameters.py | 192 ++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 paths_cli/tests/wizard/test_parameters.py diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py new file mode 100644 index 00000000..056499ee --- /dev/null +++ b/paths_cli/tests/wizard/test_parameters.py @@ -0,0 +1,192 @@ +import pytest +import mock + +from paths_cli.tests.wizard.mock_wizard import mock_wizard +import paths_cli.compiling.core as compiling +from paths_cli.compiling.root_compiler import _CategoryCompilerProxy +from paths_cli.wizard import standard_categories + +import openpathsampling as paths + +from paths_cli.wizard.parameters import * + +class TestWizardParameter: + @staticmethod + def _reverse(string): + return "".join(reversed(string)) + + @staticmethod + def _summarize(string): + return f"Here's a summary: we made {string}" + + def setup(self): + self.parameter = WizardParameter( + name='foo', + ask="How should I {do_what}?", + loader=self._reverse, + summarize=self._summarize, + ) + self.wizard = mock_wizard(["bar"]) + self.compiler_plugin = compiling.InstanceCompilerPlugin( + builder=lambda foo: {'foo': foo, 'bar': bar}, + parameters=[ + compiling.Parameter(name='foo', + loader=lambda x: x), + compiling.Parameter(name='bar', + loader=_CategoryCompilerProxy('bar')) + ] + ) + + def test_parameter_call(self): + # using a normal parameter should call the loader and give expected + # results + result = self.parameter(self.wizard, context={'do_what': 'baz'}) + assert result == "rab" + log = self.wizard.console.log_text + assert "How should I baz?" in log + + def test_from_proxy_call_standard(self): + # using a proxy for a normal parameter (not an existing object) + # should work and give the expected result + proxy = ProxyParameter(name='foo', ask="ask foo?") + wizard = mock_wizard(['baz']) + real_param = WizardParameter.from_proxy(proxy, self.compiler_plugin) + result = real_param(wizard, context={}) + assert result == "baz" + assert "ask foo?" in wizard.console.log_text + + def test_from_proxy_call_existing(self): + # using a proxy parameter that seeks an existing object should work + # and give the expected result + proxy = ProxyParameter(name='bar', + ask="ask bar?") + cat = standard_categories.Category(name='bar', + singular='bar', + plural='bars', + storage='bars') + cat_loc = 'paths_cli.wizard.standard_categories.CATEGORIES' + get_cat_wiz_loc = ('paths_cli.wizard.plugin_registration' + '.get_category_wizard') + get_cat_wiz_mock = mock.Mock() + with mock.patch(cat_loc, {'bar': cat}) as p1, \ + mock.patch(get_cat_wiz_loc, get_cat_wiz_mock) as p2: + parameter = WizardParameter.from_proxy(proxy, + self.compiler_plugin) + wizard = mock_wizard(['bar', 'baz']) + wizard.bars = {'baz': 'qux'} + result = parameter(wizard, context={}) + + assert result == 'qux' + assert "1. baz" in wizard.console.log_text + assert "2. Create a new bar" in wizard.console.log_text + assert "'bar' is not a valid option" in wizard.console.log_text + + +class TestFromWizardPrerequisite: + def setup(self): + # For this model, user input should be a string that represents an + # integer. The self._create method repeats the input string, e.g., + # "1" => "11", and wraps the result in self.Wrapper. This is the + # thing that is actually stored in wizard.store. The self._load_func + # method converts that to an integer. This is returned from various + # functions. + self.prereq = FromWizardPrerequisite( + name='foo', + create_func=self._create, + category='foo_cat', + n_required=2, + obj_name='obj_name', + store_name='store', + say_create="create one", + say_finish="now we're done", + load_func=self._load_func + ) + + class Wrapper(paths.netcdfplus.StorableNamedObject): + def __init__(self, val): + super().__init__() + self.val = val + + def _create(self, wizard): + as_str = wizard.ask("create_func") + return self.Wrapper(as_str * 2) + + def _load_func(self, obj): + return int(obj.val) + + def test_setup_from_category_info(self): + # when obj_name and store_name are not specified, and the category + # is known in standard_categories, the obj_name and store_name + # should be set according to the category name + cat = standard_categories.Category(name='foo', + singular='singular', + plural='plural', + storage='storage') + cat_loc = 'paths_cli.wizard.standard_categories.CATEGORIES' + with mock.patch(cat_loc, {'foo': cat}): + prereq = FromWizardPrerequisite('foo_obj', ..., 'foo', 1) + + assert prereq.obj_name == "singular" + assert prereq.store_name == "storage" + + def test_no_load_func(self): + # if load_func is not provided, then the output of prereq.load_func + # should be is-identical to its input + prereq = FromWizardPrerequisite('foo', ..., 'foo_cat', 1, + obj_name='foo', store_name='foos') + assert prereq.load_func(prereq) is prereq + + def test_create_new(self): + # the create_new method should return the (integer) value from the + # load_func, and the Wrapper object should be registered with the + # wizard as a side-effect. The say_create should appear in logs. + wiz = mock_wizard(["1", "name1"]) + wiz.store = {} + obj = self.prereq.create_new(wiz) + assert obj == 11 + assert len(wiz.store) == 1 + assert wiz.store['name1'].val == "11" + assert "create one" in wiz.console.log_text + + def test_get_existing(self): + # the get_existing method should load objects currently in the + # storage, after passing them through the load_func. This should be + # done without any statements to the user. + wiz = mock_wizard([]) + wiz.store = {'bar': self.Wrapper("11"), + 'baz': self.Wrapper("22")} + obj = self.prereq.get_existing(wiz) + assert obj == [11, 22] + assert wiz.console.log_text == "" + + def test_select_existing(self): + # select_existing should return the selected result based on + # existing objects in storage and logs should include question from + # wizard.obj_selector + wiz = mock_wizard(['1']) + wiz.store = {'bar': self.Wrapper("11"), + 'baz': self.Wrapper("22"), + 'qux': self.Wrapper("33")} + obj = self.prereq.select_single_existing(wiz) + assert obj == 11 + assert "Which obj_name would you" in wiz.console.log_text + + @pytest.mark.parametrize('objs', ['fewer', 'exact', 'more']) + def test_call(self, objs): + n_objs = {'fewer': 1, 'exact': 2, 'more': 3}[objs] + inputs = {'fewer': ['2', 'name2'], + 'exact': [], + 'more': ['1', '2']}[objs] + + wrappers = {"name" + i: self.Wrapper(i*2) for i in ['1', '2', '3']} + items = [(key, val) for key, val in list(wrappers.items())[:n_objs]] + + wiz = mock_wizard(inputs) + wiz.store = dict(items) + + results = self.prereq(wiz) + assert len(results) == 1 + assert len(results['foo']) == 2 + assert results['foo'] == [11, 22] + if objs != 'exact': + assert "now we're done" in wiz.console.log_text From 0bc827035c66225e56b3199ddf481e4d4d8b78b9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 22 Oct 2021 13:06:55 -0400 Subject: [PATCH 159/251] unittest.mock --- paths_cli/tests/wizard/test_parameters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py index 056499ee..ee1b5a5a 100644 --- a/paths_cli/tests/wizard/test_parameters.py +++ b/paths_cli/tests/wizard/test_parameters.py @@ -1,5 +1,5 @@ import pytest -import mock +from unittest import mock from paths_cli.tests.wizard.mock_wizard import mock_wizard import paths_cli.compiling.core as compiling From 829015401048f3f6029a2fdd27a80a9caf2c2cb4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 22 Oct 2021 16:11:27 -0400 Subject: [PATCH 160/251] tests for plugin_registration --- .../tests/wizard/test_plugin_registration.py | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 paths_cli/tests/wizard/test_plugin_registration.py diff --git a/paths_cli/tests/wizard/test_plugin_registration.py b/paths_cli/tests/wizard/test_plugin_registration.py new file mode 100644 index 00000000..83b56b01 --- /dev/null +++ b/paths_cli/tests/wizard/test_plugin_registration.py @@ -0,0 +1,89 @@ +import pytest +from unittest import mock +from paths_cli.wizard.plugin_registration import * +from paths_cli.wizard.plugin_registration import _register_category_plugin +from paths_cli.tests.wizard.test_helper import mock_wizard +from paths_cli.plugin_management import OPSPlugin +from paths_cli.wizard.plugin_classes import WrapCategory, WizardObjectPlugin + +CAT_PLUGINS_LOC = "paths_cli.wizard.plugin_registration._CATEGORY_PLUGINS" + +def _simple_func(wizard, context=None): + wizard.say("bar") + return 10 + +class MockPlugin(OPSPlugin): + def __init__(self, name='foo', requires_ops=(1,0), requires_cli=(0,3)): + super().__init__(requires_ops, requires_cli) + + def __call__(self, wizard, context): + return _simple_func(wizard, context) + + +def test_get_category_wizard(): + # get_category_wizard returns a function that will run a given wizard + # (even if the specific wizard is only registered at a later point in + # time) + func = get_category_wizard('foo') + wiz = mock_wizard([]) + with mock.patch.dict(CAT_PLUGINS_LOC, {'foo': MockPlugin('foo')}): + result = func(wiz, {}) + + assert result == 10 + assert "bar" in wiz.console.log_text + +def test_get_category_wizard_error(): + # if a category plugin of the given name does not exist, an error is + # raised + func = get_category_wizard("foo") + wiz = mock_wizard([]) + with pytest.raises(CategoryWizardPluginRegistrationError, + match="No wizard"): + func(wiz, {}) + +def test_register_category_plugin(): + # a category plugin can be registered using _register_category_plugin + plugin = WrapCategory("foo", "ask_foo") + PLUGINS = {} + with mock.patch.dict(CAT_PLUGINS_LOC, PLUGINS): + from paths_cli.wizard.plugin_registration import _CATEGORY_PLUGINS + assert len(_CATEGORY_PLUGINS) == 0 + _register_category_plugin(plugin) + assert len(_CATEGORY_PLUGINS) == 1 + + +def test_register_category_plugin_duplicate(): + # if two category plugins try to use the same name, an error is raised + plugin = WrapCategory("foo", "ask_foo") + PLUGINS = {"foo": WrapCategory("foo", "foo 2")} + with mock.patch.dict(CAT_PLUGINS_LOC, PLUGINS): + with pytest.raises(CategoryWizardPluginRegistrationError, + match="already been reserved"): + _register_category_plugin(plugin) + + +def test_register_plugins(): + # when we register plugins, category plugins should register as + # correctly and object plugins should register with the correct category + # plugins + cat = WrapCategory("foo", "ask_foo") + obj = WizardObjectPlugin("bar", "foo", _simple_func) + PLUGINS = {} + with mock.patch.dict(CAT_PLUGINS_LOC, PLUGINS): + from paths_cli.wizard.plugin_registration import _CATEGORY_PLUGINS + register_plugins([cat, obj]) + assert len(_CATEGORY_PLUGINS) == 1 + assert _CATEGORY_PLUGINS["foo"] == cat + + assert len(cat.choices) == 1 + assert cat.choices['bar'] == obj + +def test_register_installed_plugins(): + # mostly a smoke test, but also check that the LoadFromOPS plugin is the + # last choice in situations where it is a choice + register_installed_plugins() + from paths_cli.wizard.plugin_registration import _CATEGORY_PLUGINS + for cat in ['engine', 'cv']: + cat_plug = _CATEGORY_PLUGINS[cat] + choices = list(cat_plug.choices.values()) + assert isinstance(choices[-1], LoadFromOPS) From 90ed29046ad4965a3dbc11f7436ca07824b66fb1 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 22 Oct 2021 22:36:44 -0400 Subject: [PATCH 161/251] separate compile's register_installed_plugins (for re-use by other parts of the code) --- paths_cli/commands/compile.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 7cfdf2f5..a1e0253b 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -56,6 +56,16 @@ def select_loader(filename): except KeyError: raise RuntimeError(f"Unknown file extension: {ext}") +def register_installed_plugins(): + plugin_types = (InstanceCompilerPlugin, CategoryPlugin) + plugins = get_installed_plugins( + default_loader=NamespacePluginLoader('paths_cli.compiling', + plugin_types), + plugin_types=plugin_types + ) + register_plugins(plugins) + + @click.command( 'compile', ) @@ -66,13 +76,7 @@ def compile_(input_file, output_file): with open(input_file, mode='r') as f: dct = loader(f) - plugin_types = (InstanceCompilerPlugin, CategoryPlugin) - plugins = get_installed_plugins( - default_loader=NamespacePluginLoader('paths_cli.compiling', - plugin_types), - plugin_types=plugin_types - ) - register_plugins(plugins) + register_installed_plugins() objs = do_compile(dct) print(f"Saving {len(objs)} user-specified objects to {output_file}....") From bf387036aa6e0ce6ab9c200f791d7a1c80970244 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 23 Oct 2021 02:01:28 -0400 Subject: [PATCH 162/251] refactor get_text_from_context --- paths_cli/wizard/plugin_classes.py | 79 ++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 8f6e28d5..a0e5e012 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -42,6 +42,43 @@ def __init__(self, category, obj_name=None, store_name=None, def __call__(self, wizard): return load_from_ops(wizard, self.store_name, self.obj_name) +def get_text_from_context(name, instance, default, wizard, context, *args, + **kwargs): + """Generic method for getting text from context of other sources. + + A lot of this motif seemed to be repeating in the plugins, so it has + been refactored into its own function. + + Parameters + ---------- + name : str + the name in the context dict + instance : + the object as kept as a user-given value + default : + default value to use if neither context nor user-given values exist + wizard : :class:`.Wizard` + the wizard + context : dict + the context dict + """ + text = context.get(name, instance) + if text is None: + text = default + + try: + text = text(wizard, context, *args, **kwargs) + except TypeError: + pass + + if text is None: + text = [] + + if isinstance(text, str): + text = [text] + + return text + class WizardObjectPlugin(OPSPlugin): """Base class for wizard plugins to create OPS objects. @@ -77,22 +114,14 @@ def default_summarize(self, wizard, context, result): def get_summary(self, wizard, context, result): # TODO: this pattern has been repeated -- make it a function (see # also get_intro) - summarize = context.get('summarize', self._summary) - if summarize is None: - summarize = self.default_summarize - - try: - summary = summarize(wizard, context, result) - except TypeError: - summary = summarize - - if summary is None: - summary = [] - - if isinstance(summary, str): - summary = [summary] - - return summary + return get_text_from_context( + name='summarize', + instance=self._summary, + default=self.default_summarize, + wizard=wizard, + context=context, + result=result + ) def __call__(self, wizard, context=None): if context is None: @@ -247,17 +276,13 @@ def register_plugin(self, plugin): self.choices[plugin.name] = plugin def get_intro(self, wizard, context): - intro = context.get('intro', self.intro) - - try: - intro = intro(wizard, context) - except TypeError: - pass - - if intro is None: - intro = [] - - return intro + return get_text_from_context( + name='intro', + instance=self.intro, + default=None, + wizard=wizard, + context=context + ) def get_ask(self, wizard, context): try: From 0d706e67835183f6bf8f76648e71fc92e66620a2 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 23 Oct 2021 14:51:39 -0400 Subject: [PATCH 163/251] start to tests for plugin_classes --- paths_cli/tests/wizard/test_plugin_classes.py | 85 +++++++++++++++++++ paths_cli/wizard/plugin_classes.py | 8 +- 2 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 paths_cli/tests/wizard/test_plugin_classes.py diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py new file mode 100644 index 00000000..ce4adc06 --- /dev/null +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -0,0 +1,85 @@ +import pytest +from unittest import mock +from paths_cli.wizard.plugin_classes import * +from paths_cli.tests.wizard.test_helper import mock_wizard +from paths_cli.wizard.standard_categories import Category + + +class TestLoadOPS: + def test_init_by_category(self): + # when LoadFromOPS does not include explicit obj_name or store_name, + # the relevant info should be loaded from the category + # (note: testing for errors here is part of tests for + # get_category_info) + cat = Category(name='foo', + singular='singular', + plural='plural', + storage='store') + CAT_LOC = "paths_cli.wizard.standard_categories.CATEGORIES" + with mock.patch.dict(CAT_LOC, {'foo': cat}): + loader = LoadFromOPS('foo') + + assert loader.obj_name == 'singular' + assert loader.store_name == 'store' + + def test_call(self): + # the created object should call the load_from_ops method with + # appropriate parameters (note that the test of load_from_ops is + # done separately) + loader = LoadFromOPS("cat_name", obj_name='foo', store_name='foos') + wiz = mock_wizard([]) + mock_load = mock.Mock(return_value="some_object") + patch_loc = "paths_cli.wizard.plugin_classes.load_from_ops" + with mock.patch(patch_loc, mock_load): + result = loader(wiz) + + assert mock_load.called_once_with(wiz, "foos", "foo") + assert result == "some_object" + + +@pytest.mark.parametrize('context,instance,default,expected', [ + ('empty', 'method', 'None', ['instance_method']), + ('empty', 'string', 'None', ['instance_string']), + ('string', 'method', 'None', ['context_string']), + ('method', 'method', 'None', ['context_method']), + ('empty', 'empty', 'method', ['default_method']), + ('empty', 'empty', 'string', ['default_string']), + ('empty', 'empty', 'None', []), +]) +def test_get_text_from_context(context, instance, default, expected): + def make_method(carrier): + def method(wizard, context, *args, **kwargs): + return f"{carrier}_method" + return method + + context = {'empty': {}, + 'string': {'foo': 'context_string'}, + 'method': {'foo': make_method('context')}}[context] + instance = {'string': 'instance_string', + 'method': make_method('instance'), + 'empty': None}[instance] + default = {'method': make_method('default'), + 'string': "default_string", + 'None': None}[default] + + wiz = mock_wizard([]) + + result = get_text_from_context("foo", instance, default, wiz, context) + assert result == expected + + +class TestWizardObjectPlugin: + def setup(self): + pass + + def test_default_summarize(self): + pytest.skip() + + def test_get_summary(self): + pytest.skip() + + def test_call(self): + pytest.skip() + + + diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index a0e5e012..b1f48c16 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -1,9 +1,9 @@ +import textwrap from paths_cli.plugin_management import OPSPlugin from paths_cli.wizard.standard_categories import get_category_info from paths_cli.wizard.load_from_ops import load_from_ops from paths_cli.wizard.parameters import WizardParameter from paths_cli.wizard.helper import Helper -import textwrap _WIZARD_KWONLY = """ prerequisite : Callable @@ -22,8 +22,6 @@ version of the OpenPathSampling CLI required for this plugin """ - - class LoadFromOPS(OPSPlugin): def __init__(self, category, obj_name=None, store_name=None, requires_ops=(1,0), requires_cli=(0,3)): @@ -72,6 +70,7 @@ def get_text_from_context(name, instance, default, wizard, context, *args, pass if text is None: + # note that this only happens if the default is None text = [] if isinstance(text, str): @@ -112,8 +111,6 @@ def default_summarize(self, wizard, context, result): return [f"Here's what we'll make:\n {str(result)}"] def get_summary(self, wizard, context, result): - # TODO: this pattern has been repeated -- make it a function (see - # also get_intro) return get_text_from_context( name='summarize', instance=self._summary, @@ -308,4 +305,3 @@ def __call__(self, wizard, context=None): new_context = self.set_context(wizard, context, selected) obj = selected(wizard, new_context) return obj - From 0f0e0e031e8a9c13e35712e7a8528c2d8dbc9288 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 10:46:13 -0400 Subject: [PATCH 164/251] Tests for object plugin classes --- paths_cli/tests/wizard/test_plugin_classes.py | 113 ++++++++++++++++-- paths_cli/wizard/plugin_classes.py | 17 +++ 2 files changed, 123 insertions(+), 7 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index ce4adc06..12c4223b 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -3,6 +3,11 @@ from paths_cli.wizard.plugin_classes import * from paths_cli.tests.wizard.test_helper import mock_wizard from paths_cli.wizard.standard_categories import Category +from paths_cli.wizard.parameters import WizardParameter, ProxyParameter + +from paths_cli import compiling + +import openpathsampling as paths class TestLoadOPS: @@ -70,16 +75,110 @@ def method(wizard, context, *args, **kwargs): class TestWizardObjectPlugin: def setup(self): - pass + self.plugin = WizardObjectPlugin( + name="foo", + category="foo_category", + builder=lambda wizard, context: "foo_obj", + intro="foo intro", + summary="foo summary", + ) def test_default_summarize(self): - pytest.skip() - - def test_get_summary(self): - pytest.skip() + wizard = mock_wizard([]) + context = {} + result = "foo" + summ = self.plugin.default_summarize(wizard, context, result) + assert len(summ) == 1 + assert "Here's what we'll make" in summ[0] + assert "foo" in summ[0] def test_call(self): - pytest.skip() - + wizard = mock_wizard([]) + res = self.plugin(wizard) + assert "foo intro" in wizard.console.log_text + assert "foo summary" in wizard.console.log_text + assert res == "foo_obj" + + def test_call_with_prereq(self): + def prereq(wizard): + wizard.say("Running prereq") + return {'prereq': ['results']} + + plugin = WizardObjectPlugin( + name="foo", + category="foo_category", + builder=lambda wizard, context: "foo_obj", + prerequisite=prereq, + ) + + wizard = mock_wizard([]) + result = plugin(wizard, context={}) + assert result == "foo_obj" + assert "Running prereq" in wizard.console.log_text + + +class TestWizardParameterObjectPlugin: + class MyClass(paths.netcdfplus.StorableNamedObject): + def __init__(self, foo, bar): + self.foo = foo + self.bar = bar + def setup(self): + self.parameters = [ + WizardParameter(name="foo", + ask="Gimme a foo!", + loader=int), + WizardParameter(name="bar", + ask="Tell me bar!", + loader=str) + ] + self.plugin = WizardParameterObjectPlugin( + name="baz", + category="baz_cat", + parameters=self.parameters, + builder=self.MyClass, + ) + self.wizard = mock_wizard(['11', '22']) + + def _check_call(self, result, wizard): + assert isinstance(result, self.MyClass) + assert result.foo == 11 + assert result.bar == "22" + assert "Gimme a foo" in wizard.console.log_text + assert "Tell me bar" in wizard.console.log_text + def test_call(self): + result = self.plugin(self.wizard) + self._check_call(result, self.wizard) + + def test_from_proxies(self): + object_compiler = compiling.InstanceCompilerPlugin( + builder=self.MyClass, + parameters=[ + compiling.Parameter(name="foo", loader=int), + compiling.Parameter(name="bar", loader=str), + ] + ) + proxies = [ + ProxyParameter(name="foo", + ask="Gimme a foo!"), + ProxyParameter(name="bar", + ask="Tell me bar!"), + + ] + plugin = WizardParameterObjectPlugin.from_proxies( + name="baz", + category="baz_cat", + parameters=proxies, + compiler_plugin=object_compiler + ) + result = plugin(self.wizard) + self._check_call(result, self.wizard) + + +class TestCategoryHelpFunc: + pass + + +class TestWrapCategory: + pass diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index b1f48c16..648111d4 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -111,6 +111,23 @@ def default_summarize(self, wizard, context, result): return [f"Here's what we'll make:\n {str(result)}"] def get_summary(self, wizard, context, result): + """Generate the summary statement describing the created object + + Parameters + ---------- + wizard : :class:`.Wizard` + wizard to use + context : dict + context dict + result : Any + object that has been created, and should be described. + + Returns + ------- + List[str] + statements for the wizard to say (one speech line per list + element) + """ return get_text_from_context( name='summarize', instance=self._summary, From 0a7b981e9ded610e1c68f9f50c10fa3a37c09cad Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 12:42:35 -0400 Subject: [PATCH 165/251] tests for WrapCategory --- paths_cli/plugin_management.py | 1 - paths_cli/tests/wizard/test_plugin_classes.py | 110 +++++++++++++++++- paths_cli/wizard/plugin_classes.py | 3 +- 3 files changed, 110 insertions(+), 4 deletions(-) diff --git a/paths_cli/plugin_management.py b/paths_cli/plugin_management.py index 01f03b32..190aabf9 100644 --- a/paths_cli/plugin_management.py +++ b/paths_cli/plugin_management.py @@ -7,7 +7,6 @@ class PluginRegistrationError(RuntimeError): pass -# TODO: make more generic than OPS (requires_ops => requires_lib) class Plugin(object): """Generic plugin object diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 12c4223b..5a99c0c1 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -52,6 +52,12 @@ def test_call(self): ('empty', 'empty', 'None', []), ]) def test_get_text_from_context(context, instance, default, expected): + # get_text_from_context should obtain the appropriate resulting wizard + # text regardless for various combinations of its inputs, which can be + # None (empty) or a method to be called or a string to be used directly, + # and can be selected from context (highest precedence) or a value + # typically associated with the instance (next precedence) or the class + # default (lowest precedence). def make_method(carrier): def method(wizard, context, *args, **kwargs): return f"{carrier}_method" @@ -177,8 +183,108 @@ def test_from_proxies(self): class TestCategoryHelpFunc: - pass + def setup(self): + pass + + @pytest.mark.parametrize('input_type', ['int', 'str']) + def test_call(self, input_type): + pytest.skip() + + def test_call_empty(self): + pytest.skip() + + def test_call_empty_no_description(self): + pytest.skip() + + @pytest.mark.parametrize('input_type', ['int', 'str']) + def test_bad_arg(self, input_type): + pytest.skip() class TestWrapCategory: - pass + def setup(self): + # TODO: finishing this now + self.wrapper = WrapCategory("foo", "ask foo", intro="intro_foo") + self.plugin_no_format = WizardObjectPlugin( + name="bar", + category="foo", + builder=lambda wizard, context: "bar_obj", + ) + self.plugin_format = WizardObjectPlugin( + name="bar", + category="foo", + builder=(lambda wizard, context: + "bar_obj baz={baz}".format(**context)), + ) + + @pytest.mark.parametrize('input_type', ['method', 'None']) + def test_set_context(self, input_type): + # set_context should create a new context dict if given a method to + # create a new context, or return the is-identical input if give no + # method + old_context = {'baz': 'qux'} + new_context = {'foo': 'bar'} + set_context = { + 'method': lambda wizard, context, selected: new_context, + 'None': None, + }[input_type] + expected = { + 'method': new_context, + 'None': old_context, + }[input_type] + wrapper = WrapCategory("foo", "ask foo", set_context=set_context) + wizard = mock_wizard([]) + context = wrapper.set_context(wizard, old_context, selected=None) + assert context == expected + if input_type == 'None': + assert context is expected + + def test_register_plugin(self): + # register_plugin should add the plugin to the choices + assert len(self.wrapper.choices) == 0 + self.wrapper.register_plugin(self.plugin_no_format) + assert len(self.wrapper.choices) == 1 + assert self.wrapper.choices['bar'] == self.plugin_no_format + # TODO: what is the desired behavior if more that one plugin tries + # to register with the same name? override or error? currently + # overrides, but that should not be considered API + + @pytest.mark.parametrize('input_type', ['method', 'format', 'string']) + def test_get_ask(self, input_type): + # wrapper.get_ask should create the appropriate input string whether + # it is a plain string, or a string that is formatted by the context + # dict, or a method that takes wizard and context to return a string + ask = { + 'method': lambda wizard, context: f"Bar is {context['bar']}", + 'format': "Bar is {bar}", + 'string': "Bar is 10" + }[input_type] + context = {'bar': 10} + wrapper = WrapCategory("foo", ask) + wizard = mock_wizard([]) + ask_string = wrapper.get_ask(wizard, context) + assert ask_string == "Bar is 10" + + @pytest.mark.parametrize('context_type', ['None', 'dict']) + def test_call(self, context_type): + # test that the call works both when the context is unimportant (and + # not given) and when the context is used in the builder + context = { + 'None': None, + 'dict': {'baz': 11}, + }[context_type] + expected = { + 'None': "bar_obj", + 'dict': "bar_obj baz=11", + }[context_type] + self.wrapper.choices['bar'] = { + 'None': self.plugin_no_format, + 'dict': self.plugin_format, + }[context_type] + + wizard = mock_wizard(['1']) + result = self.wrapper(wizard, context) + assert "intro_foo" in wizard.console.log_text + assert "ask foo" in wizard.console.log_text + assert "1. bar" in wizard.console.log_text + assert result == expected diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 648111d4..29c84140 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -149,7 +149,8 @@ def __call__(self, wizard, context=None): else: prereqs = {} - result = self.builder(wizard, prereqs) + context.update(prereqs) + result = self.builder(wizard, context) summary = self.get_summary(wizard, context, result) for line in summary: wizard.say(line) From 64572a7ebce8b8d127327f9c9b6e4139adaccb7c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 14:02:56 -0400 Subject: [PATCH 166/251] finish plugin classes tests --- paths_cli/tests/wizard/test_plugin_classes.py | 55 +++++++++++++++++-- paths_cli/wizard/plugin_classes.py | 9 ++- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 5a99c0c1..9ee4c316 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -90,6 +90,7 @@ def setup(self): ) def test_default_summarize(self): + # ensure that we get the correct output from default_summarize wizard = mock_wizard([]) context = {} result = "foo" @@ -99,6 +100,8 @@ def test_default_summarize(self): assert "foo" in summ[0] def test_call(self): + # check that calling the plugin creates the correct object and + # outputs intro/summary to the wizard wizard = mock_wizard([]) res = self.plugin(wizard) assert "foo intro" in wizard.console.log_text @@ -106,6 +109,7 @@ def test_call(self): assert res == "foo_obj" def test_call_with_prereq(self): + # ensure that prerequisites get run if they are provided def prereq(wizard): wizard.say("Running prereq") return {'prereq': ['results']} @@ -154,10 +158,14 @@ def _check_call(self, result, wizard): assert "Tell me bar" in wizard.console.log_text def test_call(self): + # when provided parameters and a builder function, we should get the + # expected result and log from the wizard result = self.plugin(self.wizard) self._check_call(result, self.wizard) def test_from_proxies(self): + # when provided proxy parameters and an InstanceCompilerPlugin, we + # should get the expected result and log from the wizard object_compiler = compiling.InstanceCompilerPlugin( builder=self.MyClass, parameters=[ @@ -184,21 +192,58 @@ def test_from_proxies(self): class TestCategoryHelpFunc: def setup(self): - pass + self.category = WrapCategory("foo", "ask foo") + self.plugin = WizardObjectPlugin( + name="bar", + category="foo", + builder=lambda wizard, context: "bar_obj", + description="bar_help", + ) + self.category.choices['bar'] = self.plugin + self.helper = CategoryHelpFunc( + category=self.category, + basic_help="help for foo category" + ) @pytest.mark.parametrize('input_type', ['int', 'str']) def test_call(self, input_type): - pytest.skip() + # we should get the help for a category item whether it is requested + # by number or by name + inp = {'int': "1", 'str': "bar"}[input_type] + assert self.helper(inp, {}) == "bar_help" + + @pytest.mark.parametrize('input_type', ['int', 'str']) + def test_call_no_description(self, input_type): + # when no description for a category item is provided, we should get + # the "no help available" message + plugin = WizardObjectPlugin( + name="bar", + category="foo", + builder=lambda wizard, context: "bar_obj" + ) + # override with local plugin, no description + self.category.choices['bar'] = plugin + + inp = {'int': "1", 'str': "bar"}[input_type] + assert self.helper(inp, {}) == "Sorry, no help available for 'bar'." def test_call_empty(self): - pytest.skip() + # when called with the empty string, we should get the help for the + # category + assert self.helper("", {}) == "help for foo category" def test_call_empty_no_description(self): - pytest.skip() + # when called with empty string and no category help defined, we + # should get the "no help available" message + helper = CategoryHelpFunc(category=self.category) + assert helper("", {}) == "Sorry, no help available for foo." @pytest.mark.parametrize('input_type', ['int', 'str']) def test_bad_arg(self, input_type): - pytest.skip() + # if a bad argument is passed to help, the help should return None + # (causing the error message to be issued higher in the stack) + inp = {'int': "2", 'str': "baz"}[input_type] + assert self.helper(inp, {}) is None class TestWrapCategory: diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 29c84140..8d31e589 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -246,10 +246,13 @@ def __call__(self, help_args, context): return self.basic_help help_dict = {} for num, (name, obj) in enumerate(self.category.choices.items()): - try: - help_str = obj.description - except Exception: + # note: previously had a try/except around obj.description -- + # keep an eye out in case that was actually needed + if obj.description is None: help_str = f"Sorry, no help available for '{name}'." + else: + help_str = obj.description + help_dict[str(num+1)] = help_str help_dict[name] = help_str From 93e5f9e018190e66dd280f5cb57cdc493cb46c91 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 14:52:25 -0400 Subject: [PATCH 167/251] missing wizard tests --- paths_cli/tests/wizard/test_wizard.py | 39 +++++++++++++++++++++++++++ paths_cli/wizard/helper.py | 4 +++ paths_cli/wizard/wizard.py | 6 ++--- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index c5676c7e..12953253 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -97,6 +97,29 @@ def test_start(self): def test_bad_input(self): self._generic_speak_test('bad_input') + @pytest.mark.parametrize('user_inp', ['int', 'str', 'help', 'bad']) + def test_ask_enumerate_dict(self, user_inp): + inputs = {'int': ["1"], + 'str': ["bar"], + 'help': ["?", "1"], + "bad": ["99", "1"]}[user_inp] + console = MockConsole(inputs) + self.wizard.console = console + selected = self.wizard.ask_enumerate_dict( + question="foo", + options={"bar": "bar_func", "baz": "baz_func"} + ) + assert selected == "bar_func" + assert 'foo' in console.log_text + assert '1. bar' in console.log_text + assert '2. baz' in console.log_text + assert console.input_call_count == len(inputs) + if user_inp == 'help': + assert "no help available" in console.log_text + elif user_inp == "bad": + assert "'99'" in console.log_text + assert "not a valid option" in console.log_text + @pytest.mark.parametrize('bad_choice', [False, True]) def test_ask_enumerate(self, bad_choice): inputs = {False: '1', True: ['10', '1']}[bad_choice] @@ -115,6 +138,22 @@ def test_ask_enumerate(self, bad_choice): assert "'10'" not in console.log_text assert 'not a valid option' not in console.log_text + def test_ask_load(self): + console = MockConsole(['100']) + self.wizard.console = console + loaded = self.wizard.ask_load("foo", int) + assert loaded == 100 + assert "foo" in console.log_text + + def test_ask_load_error(self): + console = MockConsole(["abc", "100"]) + self.wizard.console = console + loaded = self.wizard.ask_load("foo", int) + assert loaded == 100 + assert "foo" in console.log_text + assert "ValueError" in console.log_text + assert "base 10" in console.log_text + @pytest.mark.parametrize('inputs, type_, expected', [ ("2+2", int, 4), ("0.1 * 0.1", float, 0.1*0.1), ("2.4", int, 2) ]) diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index 8b99806c..5dbc813f 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -44,9 +44,13 @@ def force_exit(cmd, ctx): class Helper: def __init__(self, help_func): # TODO: generalize to get help on specific aspects? + if help_func is None: + help_func = "Sorry, no help available." + if isinstance(help_func, str): text = str(help_func) help_func = lambda args, ctx: text + self.helper = help_func self.commands = HELPER_COMMANDS.copy() # allows per-instance custom self.command_help_str = COMMAND_HELP_STR.copy() diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 3c92e2cb..3223dc6d 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -73,6 +73,8 @@ def _speak(self, content, preface): def ask(self, question, options=None, default=None, helper=None, autohelp=False): # TODO: if helper is None, create a default helper + if helper is None: + helper = Helper(None) if isinstance(helper, str): helper = Helper(helper) result = self.console.input("🧙 " + question + " ") @@ -104,10 +106,6 @@ def ask_enumerate_dict(self, question, options, helper=None, self.say(opt_string, preface=" "*3) choice = self.ask("Please select an option:", helper=helper) - # indicates input was handled by helper -- so ask again - if choice is None: - return None - # select by string if choice in options: return options[choice] From 934040a9c44d1c18fb3841c0532705f6bd865400 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 15:46:03 -0400 Subject: [PATCH 168/251] looks like all tests pass with full coverage --- paths_cli/tests/wizard/test_plugin_classes.py | 7 ++++--- paths_cli/tests/wizard/test_volumes.py | 16 +++++++++++++++- paths_cli/wizard/parameters.py | 2 +- paths_cli/wizard/plugin_classes.py | 5 ++--- paths_cli/wizard/volumes.py | 8 +------- 5 files changed, 23 insertions(+), 15 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 9ee4c316..4010b0e3 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -110,7 +110,7 @@ def test_call(self): def test_call_with_prereq(self): # ensure that prerequisites get run if they are provided - def prereq(wizard): + def prereq(wizard, context): wizard.say("Running prereq") return {'prereq': ['results']} @@ -258,8 +258,9 @@ def setup(self): self.plugin_format = WizardObjectPlugin( name="bar", category="foo", - builder=(lambda wizard, context: - "bar_obj baz={baz}".format(**context)), + prerequisite=lambda wizard, context: {'baz': context['baz']}, + builder=(lambda wizard, prereq: + "bar_obj baz={baz}".format(**prereq)), ) @pytest.mark.parametrize('input_type', ['method', 'None']) diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 66927948..ebce094d 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -5,7 +5,8 @@ from paths_cli.wizard.volumes import ( INTERSECTION_VOLUME_PLUGIN, UNION_VOLUME_PLUGIN, NEGATED_VOLUME_PLUGIN, - CV_DEFINED_VOLUME_PLUGIN, VOLUMES_PLUGIN, volume_intro, _VOL_DESC + CV_DEFINED_VOLUME_PLUGIN, VOLUMES_PLUGIN, volume_intro, _VOL_DESC, + volume_ask ) @@ -100,6 +101,19 @@ def test_negated_volume(volume_setup): assert not vol(traj[0]) assert vol(traj[1]) +@pytest.mark.parametrize('depth', [0, 1, None]) +def test_volume_ask(depth): + context = {0: {'depth': 0}, + 1: {'depth': 1}, + None: {}}[depth] + wiz = mock_wizard([]) + result = volume_ask(wiz, context) + if depth == 1: + assert result == "What describes this volume?" + else: + assert result == "What describes this state?" + + @pytest.mark.parametrize('periodic', [True, False]) def test_cv_defined_volume(periodic): if periodic: diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index d6dd70c4..f0a5d6f6 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -151,7 +151,7 @@ def select_single_existing(self, wizard): self.create_func) return self.load_func(obj) - def __call__(self, wizard): + def __call__(self, wizard, context=None): n_existing = len(getattr(wizard, self.store_name)) if n_existing == self.n_required: # early return in this case (return silently) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 8d31e589..ce66cff1 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -145,12 +145,11 @@ def __call__(self, wizard, context=None): wizard.say(self.intro) if self.prerequisite is not None: - prereqs = self.prerequisite(wizard) + prereqs = self.prerequisite(wizard, context) else: prereqs = {} - context.update(prereqs) - result = self.builder(wizard, context) + result = self.builder(wizard, prereqs) summary = self.get_summary(wizard, context, result) for line in summary: wizard.say(line) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index c86c0b69..b96ac5ef 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -10,22 +10,16 @@ from functools import partial def _binary_func_volume(wizard, context, op): - def summarize(volname): - def inner(wizard, context, result): - return f"The {volname} volume is:\n {str(result)}" - as_state = context.get('depth', 0) == 0 wizard.say("Let's make the first constituent volume:") new_context = volume_set_context(wizard, context, selected=None) new_context['part'] = 1 - new_context['summarize'] = summarize("first") vol1 = VOLUMES_PLUGIN(wizard, new_context) wizard.say("Let's make the second constituent volume:") new_context['part'] = 2 - new_context['summarize'] = summarize("second") vol2 = VOLUMES_PLUGIN(wizard, new_context) + wizard.say("Now we'll combine those two constituent volumes...") vol = op(vol1, vol2) - # wizard.say(f"Created a volume:\n{vol}") return vol _LAMBDA_STR = ("What is the {minmax} allowed value for " From a88a677ce772dc6f51b7d6214d4aeb624568f4ca Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 25 Oct 2021 22:40:48 -0400 Subject: [PATCH 169/251] some docstrings --- paths_cli/tests/wizard/test_parameters.py | 6 +- paths_cli/wizard/helper.py | 50 +++++++-- paths_cli/wizard/parameters.py | 130 ++++++++++++++++++++-- 3 files changed, 163 insertions(+), 23 deletions(-) diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py index ee1b5a5a..79566a67 100644 --- a/paths_cli/tests/wizard/test_parameters.py +++ b/paths_cli/tests/wizard/test_parameters.py @@ -142,7 +142,7 @@ def test_create_new(self): # wizard as a side-effect. The say_create should appear in logs. wiz = mock_wizard(["1", "name1"]) wiz.store = {} - obj = self.prereq.create_new(wiz) + obj = self.prereq._create_new(wiz) assert obj == 11 assert len(wiz.store) == 1 assert wiz.store['name1'].val == "11" @@ -155,7 +155,7 @@ def test_get_existing(self): wiz = mock_wizard([]) wiz.store = {'bar': self.Wrapper("11"), 'baz': self.Wrapper("22")} - obj = self.prereq.get_existing(wiz) + obj = self.prereq._get_existing(wiz) assert obj == [11, 22] assert wiz.console.log_text == "" @@ -167,7 +167,7 @@ def test_select_existing(self): wiz.store = {'bar': self.Wrapper("11"), 'baz': self.Wrapper("22"), 'qux': self.Wrapper("33")} - obj = self.prereq.select_single_existing(wiz) + obj = self.prereq._select_single_existing(wiz) assert obj == 11 assert "Which obj_name would you" in wiz.console.log_text diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index 5dbc813f..fc93173a 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -3,6 +3,10 @@ class QuitWizard(BaseException): pass + +# the following command functions take cmd and ctx -- future commands might +# use the full command text or the context internally. + def raise_quit(cmd, ctx): raise QuitWizard() @@ -42,11 +46,24 @@ def force_exit(cmd, ctx): } class Helper: + """Manage help and command passing on command line. + + Any user input beginning with "?" or "!" is passed to the helper. Input + beginning with "!" is used to call commands, which allow the user to + interact with the system or to force control flow that isn't built into + the wizard. Input beginning with "?" is interpreted as a request for + help, and the text after "?" is passed from the Helper to its help_func + (or to the tools for help about commands.) + + Parameters + ---------- + help_func : str or Callable[str, dict] -> str + If a Callable, it must take the user-provided string and the context + dict. If a string, the help will always return that string for any + user-provided arguments. + """ def __init__(self, help_func): # TODO: generalize to get help on specific aspects? - if help_func is None: - help_func = "Sorry, no help available." - if isinstance(help_func, str): text = str(help_func) help_func = lambda args, ctx: text @@ -56,7 +73,11 @@ def __init__(self, help_func): self.command_help_str = COMMAND_HELP_STR.copy() self.listed_commands = ['quit', '!quit', 'restart'] - def command_help(self, help_args, context): + def _command_help(self, help_args, context): + """Handle help for commands. + + Invoked if user input begins with "?!" + """ if help_args == "": result = "The following commands can be used:\n" result += "\n".join([f"* !{cmd}" @@ -70,13 +91,17 @@ def command_help(self, help_args, context): return result - def run_command(self, command, context): + def _run_command(self, command, context): + """Runs a the given command. + + Invoked if user input begins with "!" + """ cmd_split = command.split() try: key = cmd_split[0] except IndexError: return ("Please provide a command. " - + self.command_help("", context)) + + self._command_help("", context)) args = " ".join(cmd_split[1:]) try: @@ -86,10 +111,13 @@ def run_command(self, command, context): return cmd(args, context) - def get_help(self, help_args, context): - # TODO: add default help (for ?!, etc) + def _get_help(self, help_args, context): + """Get help from either command help or user-provided help. + + Invoked if user input begins with "?" + """ if help_args != "" and help_args[0] == '!': - return self.command_help(help_args[1:], context) + return self._command_help(help_args[1:], context) if self.helper is None: return "Sorry, no help available here." @@ -99,6 +127,6 @@ def get_help(self, help_args, context): def __call__(self, user_input, context=None): starter = user_input[0] args = user_input[1:] - func = {'?': self.get_help, - '!': self.run_command}[starter] + func = {'?': self._get_help, + '!': self._run_command}[starter] return func(args, context) diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index f0a5d6f6..c157fa87 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -71,12 +71,26 @@ def from_proxy(cls, proxy, compiler_plugin): dct['loader'] = get_category_wizard(category) dct['ask'] = get_category_info(category).singular dct['store_name'] = get_category_info(category).storage - cls = ExistingObjectParameter + cls = _ExistingObjectParameter else: dct['loader'] = loader return cls(**dct) def __call__(self, wizard, context): + """Load the parameter. + + Parameters + ---------- + wizard : :class:`.Wizard` + wizard for interacting with the user + context : dict + context dic + + Returns + ------- + Any : + the result of converting user string input to a parsed parameter + """ obj = NO_PARAMETER_LOADED ask = self.ask.format(**context) while obj is NO_PARAMETER_LOADED: @@ -85,7 +99,7 @@ def __call__(self, wizard, context): return obj -class ExistingObjectParameter(WizardParameter): +class _ExistingObjectParameter(WizardParameter): """Special parameter type for parameters created by wizards. This should only be created as part of the @@ -111,6 +125,37 @@ def __call__(self, wizard, context): class FromWizardPrerequisite: """Load prerequisites from the wizard. + + WARNING: This should be considered very experimental and may be removed + or substantially changed in future versions. + + Parameters + ---------- + name : str + the name of this prerequisite + create_func : Callable[:class:`.Wizard] -> Any + method to create a relevant object, using the given wizard for user + interaction. Note that the specific return object can be extracted + from the results of this method by using load_func + category : str + category of object this creates + n_required : int + number of instances that must be created + obj_name : str + singular version of object name in order to refer to it in user + interactions + store_name : str + name of the store in which this object would be found if it has + already been created for this Wizard + say_create : List[str] + user interaction statement prior to creating this object. Each + element in the list makes a separate statement from the wizard. + say_finish : List[str] + user interaction statment upon completing this object. Each element + in the list makes a separate statment from the wizard. + load_func : Callable + method to extract specific return object from the create_func. + Default is to return the result of create_func. """ def __init__(self, name, create_func, category, n_required, *, obj_name=None, store_name=None, say_create=None, @@ -133,7 +178,22 @@ def __init__(self, name, create_func, category, n_required, *, self.load_func = load_func - def create_new(self, wizard): + def _create_new(self, wizard): + """Create a new instance of this prereq. + + Invoked if more objects of this type are needed than have already + been created for the wizard. + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard to use for user interaction + + Returns + ------- + Any : + single instance of the required class + """ if self.say_create: wizard.say(self.say_create) obj = self.create_func(wizard) @@ -141,29 +201,81 @@ def create_new(self, wizard): result = self.load_func(obj) return result - def get_existing(self, wizard): + def _get_existing(self, wizard): + """Get existing instances of the desired object. + + This is invoked either when there are the exact right number of + objects already created, or to get the initial objects when there + aren't enough objects created yet. + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard to use for user interaction + + Returns + ------- + List[Any] : + list of existing instances + """ all_objs = list(getattr(wizard, self.store_name).values()) results = [self.load_func(obj) for obj in all_objs] return results - def select_single_existing(self, wizard): + def _select_single_existing(self, wizard): + """Ask the user to select an instance from the saved instances. + + This is invoked if there are more instances already created than are + required. This is called once for each instance required. + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard to use for user interaction + + Returns + ------- + Any : + single instance of the required class + """ obj = wizard.obj_selector(self.store_name, self.obj_name, self.create_func) return self.load_func(obj) def __call__(self, wizard, context=None): + """Obtain the correct number of instances of the desired type. + + This will either take the existing object(s) in the wizard (if the + exact correct number have been created in the wizard), create new + objects (if there are not enough) or ask the user to select the + existing instances (if there are more than enough). + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard to use for user interaction + context : dict + context dictionary + + Returns + ------- + Dict[str, List[Any]] : + mapping ``self.name`` to a list of objects of the correct type. + Note that this always maps to a list; receiving code may need to + handle that fact. + """ n_existing = len(getattr(wizard, self.store_name)) if n_existing == self.n_required: # early return in this case (return silently) - return {self.name: self.get_existing(wizard)} + return {self.name: self._get_existing(wizard)} elif n_existing > self.n_required: - sel = [self.select_single_existing(wizard) + sel = [self._select_single_existing(wizard) for _ in range(self.n_required)] dct = {self.name: sel} else: - objs = self.get_existing(wizard) + objs = self._get_existing(wizard) while len(getattr(wizard, self.store_name)) < self.n_required: - objs.append(self.create_new(wizard)) + objs.append(self._create_new(wizard)) dct = {self.name: objs} if self.say_finish: From b89847838666286231534631c80c3e33fc942295 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 08:51:27 -0400 Subject: [PATCH 170/251] docstrings --- paths_cli/tests/wizard/test_plugin_classes.py | 4 +- paths_cli/wizard/plugin_classes.py | 127 +++++++++++++++--- paths_cli/wizard/plugin_registration.py | 38 +++++- paths_cli/wizard/run_module.py | 10 ++ paths_cli/wizard/standard_categories.py | 18 +++ 5 files changed, 169 insertions(+), 28 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 4010b0e3..7d9b1497 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -280,7 +280,7 @@ def test_set_context(self, input_type): }[input_type] wrapper = WrapCategory("foo", "ask foo", set_context=set_context) wizard = mock_wizard([]) - context = wrapper.set_context(wizard, old_context, selected=None) + context = wrapper._set_context(wizard, old_context, selected=None) assert context == expected if input_type == 'None': assert context is expected @@ -308,7 +308,7 @@ def test_get_ask(self, input_type): context = {'bar': 10} wrapper = WrapCategory("foo", ask) wizard = mock_wizard([]) - ask_string = wrapper.get_ask(wizard, context) + ask_string = wrapper._get_ask(wizard, context) assert ask_string == "Bar is 10" @pytest.mark.parametrize('context_type', ['None', 'dict']) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index ce66cff1..d2739efb 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -5,6 +5,13 @@ from paths_cli.wizard.parameters import WizardParameter from paths_cli.wizard.helper import Helper +_PLUGIN_DOCSTRING = """ + requires_ops : Tuple[int, int] + version of OpenPathSampling required for this plugin + requires_cli : Tuple[int, int] + version of the OpenPathSampling CLI required for this plugin +""" + _WIZARD_KWONLY = """ prerequisite : Callable method to use to create any objects required for the target object @@ -16,14 +23,22 @@ summary : Callable method to create the summary string describing the object that is created - requires_ops : Tuple[int, int] - version of OpenPathSampling required for this plugin - requires_cli : Tuple[int, int] - version of the OpenPathSampling CLI required for this plugin -""" +""" + _PLUGIN_DOCSTRING class LoadFromOPS(OPSPlugin): - def __init__(self, category, obj_name=None, store_name=None, + """Wizard plugin type to load from an existing OPS file. + + Parameters + ---------- + category : str + the plugin category associated with this plugin + obj_name : str + name of the object (singular) for use in user interaction + store_name : str + nanme of the store within the OPS file where objects of this type + can be found + """ + _PLUGIN_DOCSTRING + def __init__(self, category, *, obj_name=None, store_name=None, requires_ops=(1,0), requires_cli=(0,3)): super().__init__(requires_ops, requires_cli) self.category = category @@ -36,8 +51,10 @@ def __init__(self, category, obj_name=None, store_name=None, self.obj_name = obj_name self.store_name = store_name + self.description = (f"Load the {self.obj_name} from an OPS .db " + "file.") - def __call__(self, wizard): + def __call__(self, wizard, context=None): return load_from_ops(wizard, self.store_name, self.obj_name) def get_text_from_context(name, instance, default, wizard, context, *args, @@ -56,7 +73,7 @@ def get_text_from_context(name, instance, default, wizard, context, *args, default : default value to use if neither context nor user-given values exist wizard : :class:`.Wizard` - the wizard + the wizard to use for user interaction context : dict the context dict """ @@ -188,7 +205,7 @@ def __init__(self, name, category, parameters, builder, *, self.proxy_parameters = [] # non-empty if created from proxies @classmethod - def from_proxies(cls, name, category, parameters, compiler_plugin, + def from_proxies(cls, name, category, parameters, compiler_plugin, *, prerequisite=None, intro=None, description=None, summary=None, requires_ops=(1,0), requires_cli=(0,3)): """ @@ -234,6 +251,17 @@ def _build(self, wizard, prereqs): class CategoryHelpFunc: + """Help function for wizard category wrappers. + + An instance of this is used as input to :class:`.Helper`. + + Parameters + ---------- + category : :class:`.WrapCategory` + the category wrapper for which this is the help function. + basic_help : str + help statement for the category as a whole + """ def __init__(self, category, basic_help=None): self.category = category if basic_help is None: @@ -241,6 +269,21 @@ def __init__(self, category, basic_help=None): self.basic_help = basic_help def __call__(self, help_args, context): + """Get help based on user input ``help_args`` and context. + + Parameters + ---------- + help_args : str + use input arguments to help function + context : dict + context dict + + Returns + ------- + str : + help statement based on user input. Returns ``None`` if there + was an error in the user input. + """ if not help_args: return self.basic_help help_dict = {} @@ -263,15 +306,35 @@ def __call__(self, help_args, context): class WrapCategory(OPSPlugin): - def __init__(self, name, ask, helper=None, intro=None, set_context=None, - requires_ops=(1,0), requires_cli=(0,3)): + """Container for plugins to organize them by "category." + + A category here is a specific role within OPS. For example, engines make + one category, volumes make another. Objects within a category are (in + principle) interchangeable from a software standardpoint. + + This is the Wizard equivalent of the CategoryCompiler in the compiling + subpackage. + + Parameters + ---------- + name : str + Name of this category. This should be in the singular. + ask : str or Callable[:class:`.Wizard`, dict] -> str + string or callable to create string that for the question asked of + the user when creating an object of this type + helper : :class:`.Helper` or str + helper tool + + """ + def __init__(self, name, ask, helper=None, *, intro=None, + set_context=None, requires_ops=(1,0), requires_cli=(0,3)): super().__init__(requires_ops, requires_cli) self.name = name if isinstance(intro, str): intro = [intro] self.intro = intro self.ask = ask - self._set_context = set_context + self._user_set_context = set_context if helper is None: helper = Helper(CategoryHelpFunc(self)) if isinstance(helper, str): @@ -283,16 +346,25 @@ def __init__(self, name, ask, helper=None, intro=None, set_context=None, def __repr__(self): return f"{self.__class__.__name__}({self.name})" - def set_context(self, wizard, context, selected): - if self._set_context: - return self._set_context(wizard, context, selected) + def _set_context(self, wizard, context, selected): + """If user has provided a funtion to create context, use it.""" + if self._user_set_context: + return self._user_set_context(wizard, context, selected) else: return context def register_plugin(self, plugin): + """Register a :class:`.WizardObjectPlugin` with this category. + + Parameters + ---------- + plugin : :class:`.WizardObjectPlugin` + the plugin to register + """ self.choices[plugin.name] = plugin - def get_intro(self, wizard, context): + def _get_intro(self, wizard, context): + """get the intro test to be said by the wizard""" return get_text_from_context( name='intro', instance=self.intro, @@ -301,7 +373,8 @@ def get_intro(self, wizard, context): context=context ) - def get_ask(self, wizard, context): + def _get_ask(self, wizard, context): + """get the ask text to be said by the wizard""" try: ask = self.ask(wizard, context) except TypeError: @@ -309,19 +382,33 @@ def get_ask(self, wizard, context): return ask def __call__(self, wizard, context=None): + """Create an instance for this category. + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard to use for user interaction + context : dict + the context dict + + Returns + ------- + Any : + instance of the type created by this category + """ if context is None: context = {} - intro = self.get_intro(wizard, context) + intro = self._get_intro(wizard, context) for line in intro: wizard.say(line) - ask = self.get_ask(wizard, context) + ask = self._get_ask(wizard, context) selected = wizard.ask_enumerate_dict(ask, self.choices, self.helper) - new_context = self.set_context(wizard, context, selected) + new_context = self._set_context(wizard, context, selected) obj = selected(wizard, new_context) return obj diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index 0e35229e..3a92a6d1 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -1,5 +1,3 @@ -from collections import defaultdict - from paths_cli.wizard.plugin_classes import ( LoadFromOPS, WizardObjectPlugin, WrapCategory ) @@ -13,18 +11,34 @@ class CategoryWizardPluginRegistrationError(Exception): _CATEGORY_PLUGINS = {} def get_category_wizard(category): + """Get the wizard category object of the given name. + + This is the user-facing way to load the wizard category plugins after + they have been registered. + + Parameters + ---------- + category : str + name of the category to load + + Returns + ------- + :class:`.WrapCategory` : + wizard category plugin for the specified category + """ def inner(wizard, context=None): try: plugin = _CATEGORY_PLUGINS[category] - except KeyError: + except KeyError as exc: raise CategoryWizardPluginRegistrationError( f"No wizard plugin for '{category}'" - ) + ) from exc return plugin(wizard, context) return inner def _register_category_plugin(plugin): + """convenience to register plugin or error if already registered""" if plugin.name in _CATEGORY_PLUGINS: raise CategoryWizardPluginRegistrationError( f"The category '{plugin.name}' has already been reserved " @@ -34,6 +48,14 @@ def _register_category_plugin(plugin): def register_plugins(plugins): + """Register the given plugins for use with the wizard. + + Parameters + ---------- + plugins : List[:class:`.OPSPlugin`] + wizard plugins to register (including category plugins and object + plugins) + """ categories = [] object_plugins = [] for plugin in plugins: @@ -53,6 +75,12 @@ def register_plugins(plugins): category.register_plugin(plugin) def register_installed_plugins(): + """Register all Wizard plugins found in standard locations. + + This is a convenience to avoid repeating identification of wizard + plugins. If something external needs to load all plugins (e.g., for + testing or for a custom script), this is the method to use. + """ plugin_types = (WrapCategory, WizardObjectPlugin) plugins = get_installed_plugins( default_loader=NamespacePluginLoader('paths_cli.wizard', @@ -67,5 +95,3 @@ def register_installed_plugins(): plugin_types=LoadFromOPS ) register_plugins(file_loader_plugins) - - diff --git a/paths_cli/wizard/run_module.py b/paths_cli/wizard/run_module.py index f1eec448..a6cca01c 100644 --- a/paths_cli/wizard/run_module.py +++ b/paths_cli/wizard/run_module.py @@ -8,6 +8,16 @@ # If that functionality is retained, it might be good to find a way to test # this. def run_category(category, requirements=None): # -no-cov- + """Run a wizard for the given category. + + Parameters + ---------- + category : str + name of the category wizard to run + requirements : dict + requirements for the :class:`.Wizard`. See :class:`.Wizard` for + details on the format. + """ # TODO: if we keep this, fix it up so that it also saves the resulting # objects register_installed_plugins() diff --git a/paths_cli/wizard/standard_categories.py b/paths_cli/wizard/standard_categories.py index 1f0ad0f6..e8d8f1a7 100644 --- a/paths_cli/wizard/standard_categories.py +++ b/paths_cli/wizard/standard_categories.py @@ -24,6 +24,24 @@ CATEGORIES = {cat.name: cat for cat in _CATEGORY_LIST} def get_category_info(category): + """Obtain info for a stanard (or registered) category. + + This provides a convenience for mapping various string names, which is + especially useful in user interactions. Each ``Category`` consists of: + + * ``name``: the name used by the plugin infrastructure + * ``singular``: the singular name of the type as it should appear in + user interaction + * ``plural``: the plural name of the type as it should appear in user + interactions + * ``storage``: the (pseudo)store name in OPS; used for loading object of + this category type by name. + + Parameters + ---------- + category : str + the name of the category to load + """ try: return CATEGORIES[category] except KeyError: From f8d0db4702626dc4f2d6ec8ba11b934d0f94a19d Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 10:20:26 -0400 Subject: [PATCH 171/251] add EvalHelperFunc, help for CVs, lambda_min/max --- paths_cli/wizard/cvs.py | 5 +++-- paths_cli/wizard/helper.py | 31 +++++++++++++++++++++++++++++++ paths_cli/wizard/volumes.py | 12 ++++++++++-- paths_cli/wizard/wizard.py | 9 +++++++-- 4 files changed, 51 insertions(+), 6 deletions(-) diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index d5aab50b..ce700fcc 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -138,7 +138,7 @@ def _mdtraj_summary(wizard, context, result): builder=partial(_mdtraj_cv_builder, func_name='compute_angles'), prerequisite=TOPOLOGY_CV_PREREQ, intro=_MDTRAJ_INTRO.format(user_str="angle made by three atoms"), - description=..., + description="This CV will calculate the angle between three atoms.", summary=_mdtraj_summary, ) @@ -148,7 +148,8 @@ def _mdtraj_summary(wizard, context, result): builder=partial(_mdtraj_cv_builder, func_name='compute_dihedrals'), prerequisite=TOPOLOGY_CV_PREREQ, intro=_MDTRAJ_INTRO.format(user_str="dihedral made by four atoms"), - description=..., + description=("This CV will calculate the dihedral angle made by " + "four atoms"), summary=_mdtraj_summary, ) diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index fc93173a..ddaadd1b 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -45,6 +45,37 @@ def force_exit(cmd, ctx): 'restart': _RESTART_HELP, } + +_SHORT_EVAL_HELP = ("This parameter can be generated using our custom " + "evaluation method. For details, ask for help with " + "'?eval'.") +_LONG_EVAL_HELP = ("The value for this parameter can be generated using a " + "Python-like syntax. You're limited to a single " + "expression (generally, a single line of Python) and " + "the imports math and numpy (as np). However, this " + "allows you to do simple calculations such as:\n" + " 100 * np.pi / 180 # 100 degrees in radians") +class EvalHelperFunc: + """Helper function (input to :class:`.Helper`) for evaluated parameters. + + Parameters + ---------- + param_helper : str or Callable[str, dict] -> str + help string or method that takes arguments and context dict and + results the help string + """ + def __init__(self, param_helper, context=None): + if isinstance(param_helper, str): + helper = lambda wizard, context: param_helper + else: + helper = param_helper + self.helper = helper + + def __call__(self, helpargs, context): + if helpargs == "eval": + return _LONG_EVAL_HELP + return self.helper(helpargs, context) + "\n\n" + _SHORT_EVAL_HELP + class Helper: """Manage help and command passing on command line. diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index b96ac5ef..cf8ba85f 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -4,6 +4,7 @@ LoadFromOPS, WizardParameterObjectPlugin, WizardObjectPlugin, WrapCategory ) +from paths_cli.wizard.helper import EvalHelperFunc, Helper from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes @@ -22,6 +23,9 @@ def _binary_func_volume(wizard, context, op): vol = op(vol1, vol2) return vol +_LAMBDA_HELP = ("This is the {minmax} boundary value for this volume. " + "Note that periodic CVs will correctly wrap values " + "outside the periodic bounds.") _LAMBDA_STR = ("What is the {minmax} allowed value for " "'{{obj_dict[cv].name}}' in this volume?") CV_DEFINED_VOLUME_PLUGIN = WizardParameterObjectPlugin.from_proxies( @@ -38,12 +42,16 @@ def _binary_func_volume(wizard, context, op): ProxyParameter( name="lambda_min", ask=_LAMBDA_STR.format(minmax="minimum"), - helper="foo", + helper=Helper( + EvalHelperFunc(_LAMBDA_HELP.format(minmax="minimum")) + ), ), ProxyParameter( name='lambda_max', ask=_LAMBDA_STR.format(minmax="maximum"), - helper="foo", + helper=Helper( + EvalHelperFunc(_LAMBDA_HELP.format(minmax="maximum")) + ), ), ], compiler_plugin=paths_cli.compiling.volumes.CV_VOLUME_PLUGIN, diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 3223dc6d..dc86bb98 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -65,8 +65,13 @@ def _speak(self, content, preface): lines = statement.split("\n") wrapped = textwrap.wrap(lines[0], width=width, subsequent_indent=" "*3) for line in lines[1:]: - wrap_line = textwrap.indent(line, " "*3) - wrapped.append(wrap_line) + if line == "": + wrapped.append("") + wrap_line = textwrap.wrap(line, width=width, + initial_indent=" "*3, + subsequent_indent=" "*3) + # wrap_line = textwrap.indent(line, " "*3) + wrapped.extend(wrap_line) self.console.print("\n".join(wrapped)) @get_object From d7ec33d0c4f18e83c14bdfb3aed438c80943a714 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 11:49:56 -0400 Subject: [PATCH 172/251] fix use of LoadFromOPSFile --- paths_cli/tests/wizard/test_load_from_ops.py | 9 ++++----- paths_cli/wizard/load_from_ops.py | 7 +++++-- paths_cli/wizard/wizard.py | 2 ++ 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index 02ce4a2f..e54a6a65 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -45,12 +45,11 @@ def ops_file_fixture(): return storage def test_named_objs_helper(ops_file_fixture): - wizard = mock_wizard([]) helper_func = named_objs_helper(ops_file_fixture, 'foo') - helper_func(wizard, 'any') - assert "what I found" in wizard.console.log_text - assert "bar" in wizard.console.log_text - assert "baz" in wizard.console.log_text + result = helper_func('any') + assert "what I found" in result + assert "bar" in result + assert "baz" in result @pytest.mark.parametrize('with_failure', [False, True]) def test_get_ops_storage(tmpdir, with_failure): diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 70e09d03..33e20ecb 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -1,19 +1,22 @@ from paths_cli.parameters import INPUT_FILE from paths_cli.wizard.core import get_object from paths_cli.wizard.standard_categories import CATEGORIES +from paths_cli.wizard.helper import Helper +from functools import partial from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG LABEL = "Load existing from OPS file" def named_objs_helper(storage, store_name): - def list_items(wizard, user_input): + def list_items(user_input, context=None): store = getattr(storage, store_name) names = [obj for obj in store if obj.is_named] outstr = "\n".join(['* ' + obj.name for obj in names]) - wizard.say(f"Here's what I found:\n\n{outstr}") + return f"Here's what I found:\n\n{outstr}" return list_items + @get_object def _get_ops_storage(wizard): filename = wizard.ask("What file can it be found in?", diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index dc86bb98..13712ae2 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -48,10 +48,12 @@ def __init__(self, steps): def _patch(self): # no-cov import openpathsampling as paths from openpathsampling.experimental.storage import monkey_patch_all + from paths_cli.param_core import StorageLoader if not self._patched: paths = monkey_patch_all(paths) paths.InterfaceSet.simstore = True self._patched = True + StorageLoader.has_simstore_patch = True def debug(content): # no-cov # debug does no pretty-printing From 448088e66069f6a2e89b84c1c58632af29298c25 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 12:42:50 -0400 Subject: [PATCH 173/251] tests for EvalHelperFunc --- paths_cli/tests/wizard/test_helper.py | 24 ++++++++++++++++++ paths_cli/wizard/helper.py | 36 ++++++++++++++++++--------- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/paths_cli/tests/wizard/test_helper.py b/paths_cli/tests/wizard/test_helper.py index 01424fc2..2dba49bf 100644 --- a/paths_cli/tests/wizard/test_helper.py +++ b/paths_cli/tests/wizard/test_helper.py @@ -1,6 +1,7 @@ import pytest from paths_cli.wizard.helper import * +from paths_cli.wizard.helper import _LONG_EVAL_HELP from paths_cli.tests.wizard.mock_wizard import mock_wizard def test_raise_quit(): @@ -15,6 +16,29 @@ def test_force_exit(): with pytest.raises(SystemExit): force_exit("foo", None) +class TestEvalHelperFunc: + def setup(self): + self.param_helper = { + 'str': "help_string", + 'method': lambda help_args, context: f"help_{help_args}" + } + self.expected = { + 'str': "help_string", + 'method': "help_foo" + } + + @pytest.mark.parametrize('helper_type', ['str', 'method']) + def test_call(self, helper_type): + help_func = EvalHelperFunc(self.param_helper[helper_type]) + help_str = help_func("foo") + assert self.expected[helper_type] in help_str + assert "ask for help with '?eval'" in help_str + + @pytest.mark.parametrize('helper_type', ['str', 'method']) + def test_call_eval(self, helper_type): + help_func = EvalHelperFunc(self.param_helper[helper_type]) + assert help_func("eval") == _LONG_EVAL_HELP + class TestHelper: def setup(self): self.helper = Helper(help_func=lambda s, ctx: s) diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index ddaadd1b..aeae7aec 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -1,5 +1,6 @@ from .errors import RestartObjectException + class QuitWizard(BaseException): pass @@ -10,13 +11,16 @@ class QuitWizard(BaseException): def raise_quit(cmd, ctx): raise QuitWizard() + def raise_restart(cmd, ctx): raise RestartObjectException() + def force_exit(cmd, ctx): print("Exiting...") exit() + HELPER_COMMANDS = { 'q': raise_quit, 'quit': raise_quit, @@ -46,15 +50,23 @@ def force_exit(cmd, ctx): } -_SHORT_EVAL_HELP = ("This parameter can be generated using our custom " - "evaluation method. For details, ask for help with " - "'?eval'.") -_LONG_EVAL_HELP = ("The value for this parameter can be generated using a " - "Python-like syntax. You're limited to a single " - "expression (generally, a single line of Python) and " - "the imports math and numpy (as np). However, this " - "allows you to do simple calculations such as:\n" - " 100 * np.pi / 180 # 100 degrees in radians") +_SHORT_EVAL_HELP = ("You can use expression evaluation with this " + "parameter! For details, ask for help with '?eval'.") +_LONG_EVAL_HELP = ( + "You can use a Python expression to create the value for this " + "parameter. However, there are a few limitations:\n\n", + " * You're limited to a single expression. That basically means a " + "single line of Python, and no control structures like for loops.\n\n" + " * You can't import any libraries. However, math and numpy are " + "included (use numpy as 'np').\n\n" + "The expression evaluator means that you can use simple expressions " + "as input. For example, say you wanted 100 degrees in radians. You " + "could just type\n\n" + " 100 * np.pi / 180\n\n" + "as your input for the parameter." +) + + class EvalHelperFunc: """Helper function (input to :class:`.Helper`) for evaluated parameters. @@ -64,18 +76,19 @@ class EvalHelperFunc: help string or method that takes arguments and context dict and results the help string """ - def __init__(self, param_helper, context=None): + def __init__(self, param_helper): if isinstance(param_helper, str): helper = lambda wizard, context: param_helper else: helper = param_helper self.helper = helper - def __call__(self, helpargs, context): + def __call__(self, helpargs, context=None): if helpargs == "eval": return _LONG_EVAL_HELP return self.helper(helpargs, context) + "\n\n" + _SHORT_EVAL_HELP + class Helper: """Manage help and command passing on command line. @@ -121,7 +134,6 @@ def _command_help(self, help_args, context): return result - def _run_command(self, command, context): """Runs a the given command. From 0f1403906b3ee7e7f021da5a14885a911ed01a44 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 14:50:26 -0400 Subject: [PATCH 174/251] more help strings, add LoadFromOPS in volumes - Add help for `n_frames_max` - Add help for volume category - Add help for Union, Intersection, and Complement volumes - Add LoadFromOPSFile to volumes --- paths_cli/wizard/openmm.py | 7 ++++++- paths_cli/wizard/volumes.py | 16 +++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 659d332d..10dc88c8 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -61,7 +61,12 @@ ProxyParameter( name='n_frames_max', ask="How many frames before aborting a trajectory?", - helper=None, + helper=("Sometimes trajectories can get stuck in " + "unexpected basins. To prevent your trajectory " + "from running forever, you need to add a cutoff " + "trajectory length. This should be significantly " + "longer than you would expect a transition to " + "take."), ), ], compiler_plugin=OPENMM_COMPILING, diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index cf8ba85f..c69b8aab 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -28,6 +28,7 @@ def _binary_func_volume(wizard, context, op): "outside the periodic bounds.") _LAMBDA_STR = ("What is the {minmax} allowed value for " "'{{obj_dict[cv].name}}' in this volume?") + CV_DEFINED_VOLUME_PLUGIN = WizardParameterObjectPlugin.from_proxies( name="CV-defined volume (allowed values of CV)", category="volume", @@ -64,6 +65,9 @@ def _binary_func_volume(wizard, context, op): "This means that it only allows phase space points that are " "in both of the constituent volumes."), builder=partial(_binary_func_volume, op=operator.__and__), + description=("Create a volume that is the intersection of two existing " + "volumes -- that is, all points in this volume are in " + "both of the volumes that define it."), ) UNION_VOLUME_PLUGIN = WizardObjectPlugin( @@ -73,6 +77,9 @@ def _binary_func_volume(wizard, context, op): "This means that it allows phase space points that are in " "either of the constituent volumes."), builder=partial(_binary_func_volume, op=operator.__or__), + description=("Create a volume that is the union of two existing " + "volumes -- that is, any point in either of the volumes " + "that define this are also in this volume"), ) NEGATED_VOLUME_PLUGIN = WizardObjectPlugin( @@ -80,6 +87,8 @@ def _binary_func_volume(wizard, context, op): category='volume', intro="This volume will be everything not in the subvolume.", builder=lambda wizard, context: ~VOLUMES_PLUGIN(wizard, context), + description=("Create a volume that includes every that is NOT " + "in the existing volume."), ) _FIRST_STATE = ("Now let's define state states for your system. " @@ -115,12 +124,17 @@ def volume_ask(wizard, context): obj = {True: 'state', False: 'volume'}[as_state] return f"What describes this {obj}?" +VOLUME_FROM_FILE = LoadFromOPS('volume') + VOLUMES_PLUGIN = WrapCategory( name='volume', intro=volume_intro, ask=volume_ask, set_context=volume_set_context, - helper="No volume help yet" + helper=("This will define a (hyper)volume in phase space. States and " + "interfaces in OPS are described by volumes. You can combine " + "ranges along different CVs in any way you want to defined the " + "volume.") ) if __name__ == "__main__": # no-cov From f3344d5c80185d7f84fbe4e45ea8a5ea468b6c60 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 26 Oct 2021 16:55:13 -0400 Subject: [PATCH 175/251] switch _get_ops_object to use ask_enumerate_dict --- paths_cli/tests/wizard/test_load_from_ops.py | 2 +- paths_cli/wizard/load_from_ops.py | 20 ++++++++------------ 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index e54a6a65..d94d7aa4 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -79,7 +79,7 @@ def test_get_ops_object(ops_file_fixture, with_failure): assert obj.name == 'bar' log = wizard.console.log_text assert 'name of the FOOMAGIC' in log - fail_msg = 'Something went wrong' + fail_msg = 'not a valid option' if with_failure: assert fail_msg in log else: diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index 33e20ecb..d10ddf14 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -31,18 +31,14 @@ def _get_ops_storage(wizard): @get_object def _get_ops_object(wizard, storage, store_name, obj_name): - name = wizard.ask(f"What's the name of the {obj_name} you want to " - "load? (Type '?' to get a list of them)", - helper=named_objs_helper(storage, store_name)) - if name: - try: - obj = getattr(storage, store_name)[name] - except Exception as e: - wizard.exception("Something went wrong when loading " - f"{name}. Maybe check the spelling?", e) - return - else: - return obj + # TODO: switch this to using wizard.ask_enumerate_dict, I think + store = getattr(storage, store_name) + options = {obj.name: obj for obj in store if obj.is_named} + result = wizard.ask_enumerate_dict( + f"What's the name of the {obj_name} you want to load?", + options + ) + return result def load_from_ops(wizard, store_name, obj_name): wizard.say("Okay, we'll load it from an OPS file.") From 7649c93dd154b660062909b1a9bc5f79fc40f373 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 07:38:56 -0400 Subject: [PATCH 176/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/commands/wizard.py | 2 +- paths_cli/tests/compiling/test_volumes.py | 2 +- paths_cli/tests/wizard/test_helper.py | 3 +-- paths_cli/tests/wizard/test_parameters.py | 2 +- paths_cli/tests/wizard/test_plugin_classes.py | 1 - paths_cli/tests/wizard/test_plugin_registration.py | 2 +- paths_cli/wizard/openmm.py | 1 - paths_cli/wizard/plugin_classes.py | 9 ++++----- paths_cli/wizard/shooting.py | 1 - paths_cli/wizard/steps.py | 2 -- paths_cli/wizard/tps.py | 1 - paths_cli/wizard/two_state_tps.py | 3 --- paths_cli/wizard/volumes.py | 2 -- paths_cli/wizard/wizard.py | 2 +- 14 files changed, 10 insertions(+), 23 deletions(-) diff --git a/paths_cli/commands/wizard.py b/paths_cli/commands/wizard.py index 50c176d2..976351da 100644 --- a/paths_cli/commands/wizard.py +++ b/paths_cli/commands/wizard.py @@ -9,9 +9,9 @@ ) def wizard(): # no-cov register_installed_plugins() - # breakpoint() TWO_STATE_TPS_WIZARD.run_wizard() + PLUGIN = OPSCommandPlugin( command=wizard, section="Simulation setup", diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index 54d0b730..f28bf4e3 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -5,7 +5,7 @@ import yaml import numpy as np import openpathsampling as paths -from openpathsampling.experimental.storage.collective_variables import \ +from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV from openpathsampling.tests.test_helpers import make_1d_traj diff --git a/paths_cli/tests/wizard/test_helper.py b/paths_cli/tests/wizard/test_helper.py index 2dba49bf..d31c63ce 100644 --- a/paths_cli/tests/wizard/test_helper.py +++ b/paths_cli/tests/wizard/test_helper.py @@ -2,7 +2,6 @@ from paths_cli.wizard.helper import * from paths_cli.wizard.helper import _LONG_EVAL_HELP -from paths_cli.tests.wizard.mock_wizard import mock_wizard def test_raise_quit(): with pytest.raises(QuitWizard): @@ -44,7 +43,7 @@ def setup(self): self.helper = Helper(help_func=lambda s, ctx: s) def test_help_string(self): - # if the helper "function" is a string, retuwn that string whether + # if the helper "function" is a string, return that string whether # or not additional arguments to help are given helper = Helper(help_func="a string") assert helper("?") == "a string" diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py index 79566a67..9cdf3ff6 100644 --- a/paths_cli/tests/wizard/test_parameters.py +++ b/paths_cli/tests/wizard/test_parameters.py @@ -179,7 +179,7 @@ def test_call(self, objs): 'more': ['1', '2']}[objs] wrappers = {"name" + i: self.Wrapper(i*2) for i in ['1', '2', '3']} - items = [(key, val) for key, val in list(wrappers.items())[:n_objs]] + items = list(wrappers.items())[:n_objs] wiz = mock_wizard(inputs) wiz.store = dict(items) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 7d9b1497..c0cbb614 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -248,7 +248,6 @@ def test_bad_arg(self, input_type): class TestWrapCategory: def setup(self): - # TODO: finishing this now self.wrapper = WrapCategory("foo", "ask foo", intro="intro_foo") self.plugin_no_format = WizardObjectPlugin( name="bar", diff --git a/paths_cli/tests/wizard/test_plugin_registration.py b/paths_cli/tests/wizard/test_plugin_registration.py index 83b56b01..9536f174 100644 --- a/paths_cli/tests/wizard/test_plugin_registration.py +++ b/paths_cli/tests/wizard/test_plugin_registration.py @@ -13,7 +13,7 @@ def _simple_func(wizard, context=None): return 10 class MockPlugin(OPSPlugin): - def __init__(self, name='foo', requires_ops=(1,0), requires_cli=(0,3)): + def __init__(self, name='foo', requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) def __call__(self, wizard, context): diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 659d332d..1139249b 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -73,6 +73,5 @@ if __name__ == "__main__": from paths_cli.wizard.wizard import Wizard wizard = Wizard([]) - # engine = openmm_builder(wizard) engine = OPENMM_PLUGIN(wizard) print(engine) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index d2739efb..22f4f1e8 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -39,7 +39,7 @@ class LoadFromOPS(OPSPlugin): can be found """ + _PLUGIN_DOCSTRING def __init__(self, category, *, obj_name=None, store_name=None, - requires_ops=(1,0), requires_cli=(0,3)): + requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) self.category = category self.name = "Load existing from OPS file" @@ -114,7 +114,7 @@ class WizardObjectPlugin(OPSPlugin): """ + _WIZARD_KWONLY def __init__(self, name, category, builder, *, prerequisite=None, intro=None, description=None, summary=None, - requires_ops=(1,0), requires_cli=(0,3)): + requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) self.name = name self.category = category @@ -207,7 +207,7 @@ def __init__(self, name, category, parameters, builder, *, @classmethod def from_proxies(cls, name, category, parameters, compiler_plugin, *, prerequisite=None, intro=None, description=None, - summary=None, requires_ops=(1,0), requires_cli=(0,3)): + summary=None, requires_ops=(1, 0), requires_cli=(0, 3)): """ Create plugin from proxy parameters and existing compiler plugin. @@ -245,7 +245,6 @@ def _build(self, wizard, prereqs): context = {'obj_dict': dct} for param in self.parameters: dct[param.name] = param(wizard, context) - # dct.update({p.name: p(wizard) for p in self.parameters}) result = self.build_func(**dct) return result @@ -327,7 +326,7 @@ class WrapCategory(OPSPlugin): """ def __init__(self, name, ask, helper=None, *, intro=None, - set_context=None, requires_ops=(1,0), requires_cli=(0,3)): + set_context=None, requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) self.name = name if isinstance(intro, str): diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index 3c8774a0..a333b6fd 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -1,7 +1,6 @@ from functools import partial from paths_cli.wizard.core import get_missing_object -# from paths_cli.wizard.engines import engines from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.compiling.tools import custom_eval diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py index f353f966..b06b66c1 100644 --- a/paths_cli/wizard/steps.py +++ b/paths_cli/wizard/steps.py @@ -1,8 +1,6 @@ from collections import namedtuple from functools import partial -# from paths_cli.wizard.cvs import cvs -# from paths_cli.wizard.engines import engines from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.tps import tps_scheme diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index eaf035a8..00830e21 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -1,7 +1,6 @@ from paths_cli.wizard.tools import a_an from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.shooting import shooting -from paths_cli.wizard.volumes import VOLUMES_PLUGIN as volumes from paths_cli.wizard.plugin_registration import get_category_wizard from functools import partial diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index 53e40a4a..e2e0a434 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -16,15 +16,12 @@ def two_state_tps(wizard, fixed_length=False): "Let's start with your initial state.", _VOL_DESC, ] - # wizard.say("Now let's define the stable states for your system. " - # "Let's start with your initial state.") initial_state = volumes(wizard, context={'intro': intro}) wizard.register(initial_state, 'initial state', 'states') intro = [ "Next let's define your final state.", _VOL_DESC ] - # wizard.say("Next let's define your final state.") final_state = volumes(wizard, context={'intro': intro}) wizard.register(final_state, 'final state', 'states') if fixed_length: diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index cf8ba85f..f3d3eeeb 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -5,13 +5,11 @@ WrapCategory ) from paths_cli.wizard.helper import EvalHelperFunc, Helper -from paths_cli.wizard.plugin_registration import get_category_wizard from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes from functools import partial def _binary_func_volume(wizard, context, op): - as_state = context.get('depth', 0) == 0 wizard.say("Let's make the first constituent volume:") new_context = volume_set_context(wizard, context, selected=None) new_context['part'] = 1 diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 13712ae2..28be789e 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -69,10 +69,10 @@ def _speak(self, content, preface): for line in lines[1:]: if line == "": wrapped.append("") + continue wrap_line = textwrap.wrap(line, width=width, initial_indent=" "*3, subsequent_indent=" "*3) - # wrap_line = textwrap.indent(line, " "*3) wrapped.extend(wrap_line) self.console.print("\n".join(wrapped)) From 9129def18d23fb4442843a5b85418232fb76a5a7 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 07:50:13 -0400 Subject: [PATCH 177/251] import mock_wizard from mock_wizard A couple imports were confused and thought that test_helper was a module for things that are used in testing, instead of a module to test helper.py. When the import of mock_wizard into test_helper was removed (not needed) the transitive imports led to errors. --- paths_cli/tests/wizard/test_plugin_classes.py | 2 +- paths_cli/tests/wizard/test_plugin_registration.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index c0cbb614..5cca18a5 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -1,7 +1,7 @@ import pytest from unittest import mock from paths_cli.wizard.plugin_classes import * -from paths_cli.tests.wizard.test_helper import mock_wizard +from paths_cli.tests.wizard.mock_wizard import mock_wizard from paths_cli.wizard.standard_categories import Category from paths_cli.wizard.parameters import WizardParameter, ProxyParameter diff --git a/paths_cli/tests/wizard/test_plugin_registration.py b/paths_cli/tests/wizard/test_plugin_registration.py index 9536f174..5a05a42f 100644 --- a/paths_cli/tests/wizard/test_plugin_registration.py +++ b/paths_cli/tests/wizard/test_plugin_registration.py @@ -2,7 +2,7 @@ from unittest import mock from paths_cli.wizard.plugin_registration import * from paths_cli.wizard.plugin_registration import _register_category_plugin -from paths_cli.tests.wizard.test_helper import mock_wizard +from paths_cli.tests.wizard.mock_wizard import mock_wizard from paths_cli.plugin_management import OPSPlugin from paths_cli.wizard.plugin_classes import WrapCategory, WizardObjectPlugin From 1474c8dbdb9b3ac926bd113c259e4e88ad974661 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 07:55:36 -0400 Subject: [PATCH 178/251] add debug logging to plugin registration --- paths_cli/wizard/plugin_registration.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index 3a92a6d1..6d2f4067 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -4,12 +4,17 @@ from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader +import logging +logger = logging.getLogger(__name__) + class CategoryWizardPluginRegistrationError(Exception): pass + _CATEGORY_PLUGINS = {} + def get_category_wizard(category): """Get the wizard category object of the given name. @@ -65,13 +70,13 @@ def register_plugins(plugins): object_plugins.append(plugin) for plugin in categories: - # print("Registering " + str(plugin)) + logger.debug("Registering " + str(plugin)) _register_category_plugin(plugin) for plugin in object_plugins: - # print("Registering " + str(plugin)) category = _CATEGORY_PLUGINS[plugin.category] - # print(category) + logger.debug("Registering " + str(plugin)) + logger.debug("Category: " + str(category)) category.register_plugin(plugin) def register_installed_plugins(): From da5acdc79b972deb50d074b0afbffb3c9cffb294 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 08:40:50 -0400 Subject: [PATCH 179/251] Error on duplicate WizardObjectPlugins --- paths_cli/tests/wizard/test_plugin_classes.py | 9 ++++++--- paths_cli/wizard/plugin_classes.py | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 5cca18a5..7c333e98 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -290,9 +290,12 @@ def test_register_plugin(self): self.wrapper.register_plugin(self.plugin_no_format) assert len(self.wrapper.choices) == 1 assert self.wrapper.choices['bar'] == self.plugin_no_format - # TODO: what is the desired behavior if more that one plugin tries - # to register with the same name? override or error? currently - # overrides, but that should not be considered API + + def test_register_plugin_duplicate(self): + self.wrapper.choices['bar'] = self.plugin_no_format + with pytest.raises(WizardObjectPluginRegistrationError, + match="already been registered"): + self.wrapper.register_plugin(self.plugin_format) @pytest.mark.parametrize('input_type', ['method', 'format', 'string']) def test_get_ask(self, input_type): diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 22f4f1e8..7031db00 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -5,6 +5,11 @@ from paths_cli.wizard.parameters import WizardParameter from paths_cli.wizard.helper import Helper + +class WizardObjectPluginRegistrationError(Exception): + pass + + _PLUGIN_DOCSTRING = """ requires_ops : Tuple[int, int] version of OpenPathSampling required for this plugin @@ -360,6 +365,11 @@ def register_plugin(self, plugin): plugin : :class:`.WizardObjectPlugin` the plugin to register """ + if plugin.name in self.choices: + raise WizardObjectPluginRegistrationError( + f"A plugin named '{plugin.name}' has already been " + f"registered with the category '{self.name}'" + ) self.choices[plugin.name] = plugin def _get_intro(self, wizard, context): From 3696d69b58adc9570fe8515d11ec70c053b12e36 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 09:00:35 -0400 Subject: [PATCH 180/251] Fix lack of context in negated volume --- paths_cli/wizard/volumes.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index f3d3eeeb..b0a5c5b6 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -21,11 +21,13 @@ def _binary_func_volume(wizard, context, op): vol = op(vol1, vol2) return vol + _LAMBDA_HELP = ("This is the {minmax} boundary value for this volume. " "Note that periodic CVs will correctly wrap values " "outside the periodic bounds.") _LAMBDA_STR = ("What is the {minmax} allowed value for " "'{{obj_dict[cv].name}}' in this volume?") + CV_DEFINED_VOLUME_PLUGIN = WizardParameterObjectPlugin.from_proxies( name="CV-defined volume (allowed values of CV)", category="volume", @@ -77,12 +79,14 @@ def _binary_func_volume(wizard, context, op): name='Complement of a volume (not in given volume)', category='volume', intro="This volume will be everything not in the subvolume.", - builder=lambda wizard, context: ~VOLUMES_PLUGIN(wizard, context), + builder=lambda wizard, context: ~VOLUMES_PLUGIN( + wizard, volume_set_context(wizard, context, None) + ), ) _FIRST_STATE = ("Now let's define state states for your system. " "You'll need to define {n_states_string} of them.") -_ADDITIONAL_STATES = "Okay, let's define another stable state" +_ADDITIONAL_STATES = "Okay, let's define another stable state." _VOL_DESC = ("You can describe this as either a range of values for some " "CV, or as some combination of other such volumes " "(i.e., intersection or union).") From 9b81d9afd1226a4d4bb9b0a378482d9c26676d58 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 10:09:09 -0400 Subject: [PATCH 181/251] Add OrderedSet to preserve plugin load order --- paths_cli/tests/test_utils.py | 33 +++++++++++++++++++++++++++ paths_cli/utils.py | 43 ++++++++++++++++++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 paths_cli/tests/test_utils.py diff --git a/paths_cli/tests/test_utils.py b/paths_cli/tests/test_utils.py new file mode 100644 index 00000000..ae9b120f --- /dev/null +++ b/paths_cli/tests/test_utils.py @@ -0,0 +1,33 @@ +from paths_cli.utils import * + +class TestOrderedSet: + def setup(self): + self.set = OrderedSet(['a', 'b', 'a', 'c', 'd', 'c', 'd']) + + def test_len(self): + assert len(self.set) == 4 + + def test_order(self): + for truth, beauty in zip("abcd", self.set): + assert truth == beauty + + def test_add_existing(self): + assert len(self.set) == 4 + self.set.add('a') + assert len(self.set) == 4 + assert list(self.set) == ['a', 'b', 'c', 'd'] + + def test_contains(self): + assert 'a' in self.set + assert not 'q' in self.set + + def test_discard(self): + self.set.discard('a') + assert list(self.set) == ['b', 'c', 'd'] + + def test_discard_add_order(self): + assert list(self.set) == ['a', 'b', 'c', 'd'] + self.set.discard('a') + self.set.add('a') + assert list(self.set) == ['b', 'c', 'd', 'a'] + diff --git a/paths_cli/utils.py b/paths_cli/utils.py index cbfe3cde..ee6c0c29 100644 --- a/paths_cli/utils.py +++ b/paths_cli/utils.py @@ -1,8 +1,49 @@ import importlib import pathlib +from collections import abc import click from .plugin_management import FilePluginLoader, NamespacePluginLoader + +class OrderedSet(abc.MutableSet): + """Set-like object with ordered iterator (insertion order). + + This is used to ensure that only one copy of each plugin is loaded + (set-like behavior) while retaining the insertion order. + + Parameters + ---------- + iterable : Iterable + iterable of objects to initialize with + """ + def __init__(self, iterable=None): + self._set = set([]) + self._list = [] + if iterable is None: + iterable = [] + for item in iterable: + self.add(item) + + def __contains__(self, item): + return item in self._set + + def __len__(self): + return len(self._set) + + def __iter__(self): + return iter(self._list) + + def add(self, item): + if item in self._set: + return + self._set.add(item) + self._list.append(item) + + def discard(self, item): + self._list.remove(item) + self._set.discard(item) + + def tag_final_result(result, storage, tag='final_conditions'): """Save results to a tag in storage. @@ -41,5 +82,5 @@ def get_installed_plugins(default_loader, plugin_types): NamespacePluginLoader('paths_cli_plugins', plugin_types) ] - plugins = set(sum([loader() for loader in loaders], [])) + plugins = OrderedSet(sum([loader() for loader in loaders], [])) return list(plugins) From 7401c9e42deecc691624c1fb342d85678783e0f0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 27 Oct 2021 23:27:19 -0400 Subject: [PATCH 182/251] Start to fixes for pylint/flake8 in compiling --- paths_cli/compiling/core.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 188c8f5c..59712bc4 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -1,13 +1,10 @@ import json -import yaml - -from collections import namedtuple - import logging -from .errors import InputError from paths_cli.utils import import_thing from paths_cli.plugin_management import OPSPlugin +from paths_cli.compiling.errors import InputError + def listify(obj): listified = False @@ -16,16 +13,20 @@ def listify(obj): listified = True return obj, listified + def unlistify(obj, listified): if listified: assert len(obj) == 1 obj = obj[0] return obj + REQUIRED_PARAMETER = object() + class Parameter: SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" + def __init__(self, name, loader, *, json_type=None, description=None, default=REQUIRED_PARAMETER, aliases=None): if isinstance(json_type, str): @@ -150,6 +151,7 @@ class InstanceCompilerPlugin(OPSPlugin): """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" category = None + def __init__(self, builder, parameters, name=None, aliases=None, requires_ops=(1, 0), requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) @@ -239,7 +241,7 @@ def __call__(self, dct): ops_dct = self.compile_attrs(dct) self.logger.debug("Building...") self.logger.debug(ops_dct) - obj = self.builder(**ops_dct) + obj = self.builder(**ops_dct) self.logger.debug(obj) return obj @@ -260,7 +262,7 @@ def _compile_str(self, name): self.logger.debug(f"Looking for '{name}'") try: return self.named_objs[name] - except KeyError as e: + except KeyError: raise InputError.unknown_name(self.label, name) def _compile_dict(self, dct): From df656ed47cde2324f8541cacabadb557cba33bfb Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 09:39:56 -0400 Subject: [PATCH 183/251] more pylint/pep8 cleanup in compiling --- paths_cli/compiling/cvs.py | 12 +++++------- paths_cli/compiling/engines.py | 8 +++++--- paths_cli/compiling/errors.py | 1 - paths_cli/compiling/networks.py | 8 ++++++-- paths_cli/compiling/plugins.py | 8 +++++++- paths_cli/compiling/root_compiler.py | 13 ++++++++++++- paths_cli/compiling/schemes.py | 1 + paths_cli/compiling/shooting.py | 4 ++++ 8 files changed, 40 insertions(+), 15 deletions(-) diff --git a/paths_cli/compiling/cvs.py b/paths_cli/compiling/cvs.py index 9a78699d..5b46da98 100644 --- a/paths_cli/compiling/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -1,10 +1,7 @@ -import os -import importlib - -from .core import Parameter, Builder -from .tools import custom_eval -from .topology import build_topology -from .errors import InputError +from paths_cli.compiling.core import Parameter, Builder +from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.topology import build_topology +from paths_cli.compiling.errors import InputError from paths_cli.utils import import_thing from paths_cli.compiling.plugins import CVCompilerPlugin, CategoryPlugin @@ -21,6 +18,7 @@ def __call__(self, source): # on ImportError, we leave the error unchanged return func + def _cv_kwargs_remapper(dct): kwargs = dct.pop('kwargs', {}) dct.update(kwargs) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 28c067ef..daf7d2ff 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -1,9 +1,10 @@ -from .topology import build_topology -from .core import Builder +from paths_cli.compiling.topology import build_topology +from paths_cli.compiling.core import Builder from paths_cli.compiling.core import Parameter -from .tools import custom_eval_int_strict_pos +from paths_cli.compiling.tools import custom_eval_int_strict_pos from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin + def load_openmm_xml(filename): from paths_cli.compat.openmm import HAS_OPENMM, mm if not HAS_OPENMM: # -no-cov- @@ -14,6 +15,7 @@ def load_openmm_xml(filename): return obj + def _openmm_options(dct): n_steps_per_frame = dct.pop('n_steps_per_frame') n_frames_max = dct.pop('n_frames_max') diff --git a/paths_cli/compiling/errors.py b/paths_cli/compiling/errors.py index 8517f250..5f89ac15 100644 --- a/paths_cli/compiling/errors.py +++ b/paths_cli/compiling/errors.py @@ -9,4 +9,3 @@ def invalid_input(cls, value, attr): @classmethod def unknown_name(cls, type_name, name): return cls(f"Unable to find object named {name} in {type_name}") - diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index a0b0b05f..7e7c87a6 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -10,12 +10,13 @@ parameters=[ Parameter('cv', compiler_for('cv'), description="the collective " "variable for this interface set"), - Parameter('minvals', custom_eval), # TODO fill in JSON types - Parameter('maxvals', custom_eval), # TODO fill in JSON types + Parameter('minvals', custom_eval), # TODO fill in JSON types + Parameter('maxvals', custom_eval), # TODO fill in JSON types ], name='interface-set' ) + def mistis_trans_info(dct): dct = dct.copy() transitions = dct.pop('transitions') @@ -31,6 +32,7 @@ def mistis_trans_info(dct): dct['trans_info'] = trans_info return dct + def tis_trans_info(dct): # remap TIS into MISTIS format dct = dct.copy() @@ -42,6 +44,7 @@ def tis_trans_info(dct): 'interfaces': interface_set}] return mistis_trans_info(dct) + TPS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.TPSNetwork'), parameters=[ @@ -60,6 +63,7 @@ def tis_trans_info(dct): name='mistis' ) + TIS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.MISTISNetwork'), parameters=[Parameter('trans_info', tis_trans_info)], diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index c1b5954e..8dbc0c96 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -1,12 +1,13 @@ from paths_cli.compiling.core import InstanceCompilerPlugin from paths_cli.plugin_management import OPSPlugin + class CategoryPlugin(OPSPlugin): """ Category plugins only need to be made for top-level """ def __init__(self, plugin_class, aliases=None, requires_ops=(1, 0), - requires_cli=(0,4)): + requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) self.plugin_class = plugin_class if aliases is None: @@ -25,17 +26,22 @@ def __repr__(self): class EngineCompilerPlugin(InstanceCompilerPlugin): category = 'engine' + class CVCompilerPlugin(InstanceCompilerPlugin): category = 'cv' + class VolumeCompilerPlugin(InstanceCompilerPlugin): category = 'volume' + class NetworkCompilerPlugin(InstanceCompilerPlugin): category = 'network' + class SchemeCompilerPlugin(InstanceCompilerPlugin): category = 'scheme' + class StrategyCompilerPlugin(InstanceCompilerPlugin): category = 'strategy' diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index 359b1a84..8be0e2c3 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -6,6 +6,7 @@ import logging logger = logging.getLogger(__name__) + class CategoryCompilerRegistrationError(Exception): pass @@ -22,6 +23,7 @@ class CategoryCompilerRegistrationError(Exception): COMPILE_ORDER = _DEFAULT_COMPILE_ORDER.copy() + def clean_input_key(key): # TODO: move this to core """ @@ -34,12 +36,14 @@ def clean_input_key(key): key = key.replace("-", "_") return key + ### Managing known compilers and aliases to the known compilers ############ _COMPILERS = {} # mapping: {canonical_name: CategoryCompiler} _ALIASES = {} # mapping: {alias: canonical_name} # NOTE: _ALIASES does *not* include self-mapping of the canonical names + def _canonical_name(alias): """Take an alias or a compiler name and return the compiler name @@ -51,6 +55,7 @@ def _canonical_name(alias): alias_to_canonical.update({pname: pname for pname in _COMPILERS}) return alias_to_canonical.get(alias, None) + def _get_compiler(category): """ _get_compiler must only be used after the CategoryCompilers have been @@ -69,6 +74,7 @@ def _get_compiler(category): _COMPILERS[category] = CategoryCompiler(None, category) return _COMPILERS[canonical_name] + def _register_compiler_plugin(plugin): DUPLICATE_ERROR = CategoryCompilerRegistrationError( f"The category '{plugin.name}' has been reserved by another plugin" @@ -87,7 +93,7 @@ def _register_compiler_plugin(plugin): ### Handling delayed loading of compilers ################################## -# + # Many objects need to use compilers to create their input parameters. In # order for them to be able to access dynamically-loaded plugins, we delay # the loading of the compiler by using a proxy object. @@ -111,6 +117,7 @@ def named_objs(self): def __call__(self, dct): return self._proxy(dct) + def compiler_for(category): """Delayed compiler calling. @@ -142,11 +149,13 @@ def _get_registration_names(plugin): found_names.add(name) return ordered_names + def _register_builder_plugin(plugin): compiler = _get_compiler(plugin.category) for name in _get_registration_names(plugin): compiler.register_builder(plugin, name) + def register_plugins(plugins): builders = [] compilers = [] @@ -162,6 +171,7 @@ def register_plugins(plugins): for plugin in builders: _register_builder_plugin(plugin) + ### Performing the compiling of user input ################################# def _sort_user_categories(user_categories): @@ -179,6 +189,7 @@ def _sort_user_categories(user_categories): ) return sorted_keys + def do_compile(dct): """Main function for compiling user input to objects. """ diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index 0a0d446f..518d039f 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -26,6 +26,7 @@ name='spring-shooting', ) + class BuildSchemeStrategy: def __init__(self, scheme_class, default_global_strategy): self.scheme_class = scheme_class diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index be474785..56972fd8 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -4,18 +4,21 @@ from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.tools import custom_eval + build_uniform_selector = InstanceCompilerPlugin( builder=Builder('openpathsampling.UniformSelector'), parameters=[], name='uniform', ) + def _remapping_gaussian_stddev(dct): dct['alpha'] = 0.5 / dct.pop('stddev')**2 dct['collectivevariable'] = dct.pop('cv') dct['l_0'] = dct.pop('mean') return dct + build_gaussian_selector = InstanceCompilerPlugin( builder=Builder('openpathsampling.GaussianBiasSelector', remapper=_remapping_gaussian_stddev), @@ -27,6 +30,7 @@ def _remapping_gaussian_stddev(dct): name='gaussian', ) + shooting_selector_compiler = CategoryCompiler( type_dispatch={ 'uniform': build_uniform_selector, From 20aba5af4a4a06ba65db1321ade7da23a3c62405 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 10:01:25 -0400 Subject: [PATCH 184/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/tests/wizard/test_cvs.py | 2 +- paths_cli/tests/wizard/test_volumes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/tests/wizard/test_cvs.py b/paths_cli/tests/wizard/test_cvs.py index e7793422..c5788ce0 100644 --- a/paths_cli/tests/wizard/test_cvs.py +++ b/paths_cli/tests/wizard/test_cvs.py @@ -8,7 +8,7 @@ from paths_cli.wizard.cvs import ( TOPOLOGY_CV_PREREQ, _get_atom_indices, MDTRAJ_DISTANCE, MDTRAJ_ANGLE, - MDTRAJ_DIHEDRAL, COORDINATE_CV, CV_PLUGIN + MDTRAJ_DIHEDRAL, COORDINATE_CV ) import openpathsampling as paths diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index ebce094d..81e05be2 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -5,7 +5,7 @@ from paths_cli.wizard.volumes import ( INTERSECTION_VOLUME_PLUGIN, UNION_VOLUME_PLUGIN, NEGATED_VOLUME_PLUGIN, - CV_DEFINED_VOLUME_PLUGIN, VOLUMES_PLUGIN, volume_intro, _VOL_DESC, + CV_DEFINED_VOLUME_PLUGIN, volume_intro, _VOL_DESC, volume_ask ) From acc6be213991639d071ca4a694467dc8c87c2b35 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 10:20:39 -0400 Subject: [PATCH 185/251] finish pylint/flake8 for compiling --- paths_cli/compiling/strategies.py | 5 ++++- paths_cli/compiling/tools.py | 8 ++++++-- paths_cli/compiling/topology.py | 7 ++++++- paths_cli/compiling/volumes.py | 9 +++++++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 9d00de5e..18ef1827 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -5,17 +5,19 @@ ) from paths_cli.compiling.root_compiler import compiler_for + def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" + def _group_parameter(group_name): return Parameter('group', str, default=group_name, description="the group name for these movers") + # TODO: maybe this moves into shooting once we have the metadata? SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler, default=None) - ENGINE_PARAMETER = Parameter('engine', compiler_for('engine'), description="the engine for moves of this " "type") @@ -39,6 +41,7 @@ def _group_parameter(group_name): name='one-way-shooting', ) build_one_way_shooting_strategy = ONE_WAY_SHOOTING_STRATEGY_PLUGIN + # build_two_way_shooting_strategy = StrategyCompilerPlugin( # builder=Builder(_strategy_name("TwoWayShootingStrategy")), # parameters = [ diff --git a/paths_cli/compiling/tools.py b/paths_cli/compiling/tools.py index c31a6e0d..22d55c29 100644 --- a/paths_cli/compiling/tools.py +++ b/paths_cli/compiling/tools.py @@ -1,5 +1,6 @@ import numpy as np -from .errors import InputError +from paths_cli.compiling.errors import InputError + def custom_eval(obj, named_objs=None): """Parse user input to allow simple math. @@ -19,10 +20,12 @@ def custom_eval(obj, named_objs=None): } return eval(string, namespace) + def custom_eval_int(obj, named_objs=None): val = custom_eval(obj, named_objs) return int(val) + def custom_eval_int_strict_pos(obj, named_objs=None): val = custom_eval_int(obj, named_objs) if val <= 0: @@ -33,6 +36,7 @@ def custom_eval_int_strict_pos(obj, named_objs=None): class UnknownAtomsError(RuntimeError): pass + def mdtraj_parse_atomlist(inp_str, n_atoms, topology=None): """ n_atoms: int @@ -47,7 +51,7 @@ def mdtraj_parse_atomlist(inp_str, n_atoms, topology=None): raise TypeError("Input is not integers") if arr.shape != (1, n_atoms): # try to clean it up - if len(arr.shape) == 1 and arr.shape[0] == n_atoms: + if len(arr.shape) == 1 and arr.shape[0] == n_atoms: arr.shape = (1, n_atoms) else: raise TypeError(f"Invalid input. Requires {n_atoms} " diff --git a/paths_cli/compiling/topology.py b/paths_cli/compiling/topology.py index 238a043f..a5a72ddc 100644 --- a/paths_cli/compiling/topology.py +++ b/paths_cli/compiling/topology.py @@ -1,13 +1,16 @@ import os -from .errors import InputError +from paths_cli.compiling.errors import InputError from paths_cli.compiling.root_compiler import compiler_for + def get_topology_from_engine(dct): """If given the name of an engine, use that engine's topology""" engine_compiler = compiler_for('engine') if dct in engine_compiler.named_objs: engine = engine_compiler.named_objs[dct] return getattr(engine, 'topology', None) + return None + def get_topology_from_file(dct): """If given the name of a file, use that to create the topology""" @@ -16,6 +19,7 @@ def get_topology_from_file(dct): import openpathsampling as paths trj = md.load(dct) return paths.engines.MDTrajTopology(trj.topology) + return None class MultiStrategyBuilder: @@ -35,6 +39,7 @@ def __call__(self, dct): # only get here if we failed raise InputError.invalid_input(dct, self.label) + build_topology = MultiStrategyBuilder( [get_topology_from_file, get_topology_from_engine], label='topology', diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index cdc1c958..6fcc35a8 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -1,11 +1,12 @@ import operator import functools -from .core import Parameter -from .tools import custom_eval +from paths_cli.compiling.core import Parameter +from paths_cli.compiling.tools import custom_eval from paths_cli.compiling.plugins import VolumeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for + # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): import openpathsampling as paths @@ -18,6 +19,7 @@ def cv_volume_build_func(**dct): # TODO: wrap this with some logging return builder(**dct) + CV_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=cv_volume_build_func, parameters=[ @@ -39,6 +41,7 @@ def cv_volume_build_func(**dct): 'items': {"$ref": "#/definitions/volume_type"} } + INTERSECTION_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), @@ -52,6 +55,7 @@ def cv_volume_build_func(**dct): build_intersection_volume = INTERSECTION_VOLUME_PLUGIN + UNION_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=lambda subvolumes: functools.reduce(operator.__or__, subvolumes), @@ -65,5 +69,6 @@ def cv_volume_build_func(**dct): build_union_volume = UNION_VOLUME_PLUGIN + VOLUME_COMPILER = CategoryPlugin(VolumeCompilerPlugin, aliases=['state', 'states']) From 4441d18fc69447f029eba887d163505a14722ed0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 11:09:01 -0400 Subject: [PATCH 186/251] Remove `plugins/` directory The existence of this directory was based on an early misunderstanding of namespace plugins. It currently serves no purpose and possibly adds to contributor confusion. --- paths_cli/plugins/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 paths_cli/plugins/__init__.py diff --git a/paths_cli/plugins/__init__.py b/paths_cli/plugins/__init__.py deleted file mode 100644 index e69de29b..00000000 From d2a321cb7d9d55d3402e18ad3f297335279c53da Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 12:19:47 -0400 Subject: [PATCH 187/251] add test for empty ordered set --- paths_cli/tests/test_utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/paths_cli/tests/test_utils.py b/paths_cli/tests/test_utils.py index ae9b120f..e2940885 100644 --- a/paths_cli/tests/test_utils.py +++ b/paths_cli/tests/test_utils.py @@ -7,6 +7,12 @@ def setup(self): def test_len(self): assert len(self.set) == 4 + def test_empty(self): + ordered = OrderedSet() + assert len(ordered) == 0 + for _ in ordered: + raise RuntimeError("This should not happen") + def test_order(self): for truth, beauty in zip("abcd", self.set): assert truth == beauty From 750bb55ba56dc70e61b30f0cdc9d4825549af98a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 13:14:16 -0400 Subject: [PATCH 188/251] Update the negated volume intro text --- paths_cli/wizard/volumes.py | 3 ++- paths_cli/wizard/wizard.py | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index b0a5c5b6..7c42512a 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -78,7 +78,8 @@ def _binary_func_volume(wizard, context, op): NEGATED_VOLUME_PLUGIN = WizardObjectPlugin( name='Complement of a volume (not in given volume)', category='volume', - intro="This volume will be everything not in the subvolume.", + intro=("This volume will be everything not in the input volume, which " + "you will define now."), builder=lambda wizard, context: ~VOLUMES_PLUGIN( wizard, volume_set_context(wizard, context, None) ), diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 28be789e..627b7cd4 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -79,7 +79,6 @@ def _speak(self, content, preface): @get_object def ask(self, question, options=None, default=None, helper=None, autohelp=False): - # TODO: if helper is None, create a default helper if helper is None: helper = Helper(None) if isinstance(helper, str): From 2f2b37cbc685222540fa484114d4ca0dd4f257df Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 28 Oct 2021 14:29:15 -0400 Subject: [PATCH 189/251] cleanup (remove comments; pep8/pylint) --- paths_cli/wizard/load_from_ops.py | 9 ++++----- paths_cli/wizard/volumes.py | 12 +++++++++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index d10ddf14..fdeb5dec 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -1,12 +1,10 @@ from paths_cli.parameters import INPUT_FILE from paths_cli.wizard.core import get_object -from paths_cli.wizard.standard_categories import CATEGORIES -from paths_cli.wizard.helper import Helper -from functools import partial from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG LABEL = "Load existing from OPS file" + def named_objs_helper(storage, store_name): def list_items(user_input, context=None): store = getattr(storage, store_name) @@ -25,13 +23,13 @@ def _get_ops_storage(wizard): storage = INPUT_FILE.get(filename) except Exception as e: wizard.exception(FILE_LOADING_ERROR_MSG, e) - return + return None return storage + @get_object def _get_ops_object(wizard, storage, store_name, obj_name): - # TODO: switch this to using wizard.ask_enumerate_dict, I think store = getattr(storage, store_name) options = {obj.name: obj for obj in store if obj.is_named} result = wizard.ask_enumerate_dict( @@ -40,6 +38,7 @@ def _get_ops_object(wizard, storage, store_name, obj_name): ) return result + def load_from_ops(wizard, store_name, obj_name): wizard.say("Okay, we'll load it from an OPS file.") storage = _get_ops_storage(wizard) diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index a9bd38d9..4c58fee7 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -1,4 +1,5 @@ import operator +from functools import partial from paths_cli.wizard.parameters import ProxyParameter from paths_cli.wizard.plugin_classes import ( LoadFromOPS, WizardParameterObjectPlugin, WizardObjectPlugin, @@ -7,7 +8,7 @@ from paths_cli.wizard.helper import EvalHelperFunc, Helper from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes -from functools import partial + def _binary_func_volume(wizard, context, op): wizard.say("Let's make the first constituent volume:") @@ -28,6 +29,7 @@ def _binary_func_volume(wizard, context, op): _LAMBDA_STR = ("What is the {minmax} allowed value for " "'{{obj_dict[cv].name}}' in this volume?") + CV_DEFINED_VOLUME_PLUGIN = WizardParameterObjectPlugin.from_proxies( name="CV-defined volume (allowed values of CV)", category="volume", @@ -57,6 +59,7 @@ def _binary_func_volume(wizard, context, op): compiler_plugin=paths_cli.compiling.volumes.CV_VOLUME_PLUGIN, ) + INTERSECTION_VOLUME_PLUGIN = WizardObjectPlugin( name='Intersection of two volumes (must be in both)', category="volume", @@ -69,6 +72,7 @@ def _binary_func_volume(wizard, context, op): "both of the volumes that define it."), ) + UNION_VOLUME_PLUGIN = WizardObjectPlugin( name='Union of two volumes (must be in at least one)', category="volume", @@ -81,6 +85,7 @@ def _binary_func_volume(wizard, context, op): "that define this are also in this volume"), ) + NEGATED_VOLUME_PLUGIN = WizardObjectPlugin( name='Complement of a volume (not in given volume)', category='volume', @@ -93,6 +98,7 @@ def _binary_func_volume(wizard, context, op): "in the existing volume."), ) + _FIRST_STATE = ("Now let's define state states for your system. " "You'll need to define {n_states_string} of them.") _ADDITIONAL_STATES = "Okay, let's define another stable state." @@ -100,6 +106,7 @@ def _binary_func_volume(wizard, context, op): "CV, or as some combination of other such volumes " "(i.e., intersection or union).") + def volume_intro(wizard, context): as_state = context.get('depth', 0) == 0 n_states = len(wizard.states) @@ -114,6 +121,7 @@ def volume_intro(wizard, context): intro += [_VOL_DESC] return intro + def volume_set_context(wizard, context, selected): depth = context.get('depth', 0) + 1 new_context = { @@ -121,11 +129,13 @@ def volume_set_context(wizard, context, selected): } return new_context + def volume_ask(wizard, context): as_state = context.get('depth', 0) == 0 obj = {True: 'state', False: 'volume'}[as_state] return f"What describes this {obj}?" + VOLUME_FROM_FILE = LoadFromOPS('volume') VOLUMES_PLUGIN = WrapCategory( From 967371da08e5156873279e588c2a17f2ee71740f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 29 Oct 2021 11:05:11 -0400 Subject: [PATCH 190/251] Start to wizard pauses (including tests) --- paths_cli/tests/wizard/test_pause.py | 67 ++++++++++++++++++++++++++ paths_cli/wizard/pause.py | 72 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 paths_cli/tests/wizard/test_pause.py create mode 100644 paths_cli/wizard/pause.py diff --git a/paths_cli/tests/wizard/test_pause.py b/paths_cli/tests/wizard/test_pause.py new file mode 100644 index 00000000..2f09692d --- /dev/null +++ b/paths_cli/tests/wizard/test_pause.py @@ -0,0 +1,67 @@ +import pytest +import time +from paths_cli.tests.wizard.mock_wizard import mock_wizard + +from paths_cli.wizard.pause import * + + +def test_get_pause_style(): + default_style = PAUSE_STYLES['default'] + assert get_pause_style() == default_style + + +@pytest.mark.parametrize('input_type', ['string', 'tuple', 'PauseStyle']) +def test_set_pause_style(input_type): + # check that we have the default settings + default_style = PAUSE_STYLES['default'] + test_style = PAUSE_STYLES['testing'] + input_val = { + 'string': 'testing', + 'tuple': tuple(test_style), + 'PauseStyle': PauseStyle(*test_style) + }[input_type] + assert input_val is not test_style # always a different object + assert get_pause_style() == default_style + set_pause_style(input_val) + assert get_pause_style() != default_style + assert get_pause_style() == test_style + set_pause_style('default') # explicitly reset default + + +def test_set_pause_bad_name(): + with pytest.raises(RuntimeError, match="Unknown pause style"): + set_pause_style('foo') + + # ensure we didn't break anything for later tests + assert get_pause_style() == PAUSE_STYLES['default'] + + +def test_pause_style_context(): + assert get_pause_style() == PAUSE_STYLES['default'] + with pause_style('testing'): + assert get_pause_style() == PAUSE_STYLES['testing'] + assert get_pause_style() == PAUSE_STYLES['default'] + + +def _run_pause_test(func): + test_style = PAUSE_STYLES['testing'] + expected = getattr(test_style, func.__name__) + wiz = mock_wizard([]) + with pause_style(test_style): + start = time.time() + func(wiz) + duration = time.time() - start + assert expected <= duration < 1.1 * duration + return wiz + + +def test_short(): + _ = _run_pause_test(short) + + +def test_long(): + _ = _run_pause_test(long) + + +def test_section(): + wiz = _run_pause_test(section) diff --git a/paths_cli/wizard/pause.py b/paths_cli/wizard/pause.py new file mode 100644 index 00000000..9bd5fb40 --- /dev/null +++ b/paths_cli/wizard/pause.py @@ -0,0 +1,72 @@ +import time +import contextlib +from collections import namedtuple + +PauseStyle = namedtuple("PauseStyle", ['short', 'long', 'section']) + +PAUSE_STYLES = { + 'testing': PauseStyle(0.01, 0.03, 0.05), + 'default': PauseStyle(0.1, 0.5, 0.5), + 'nopause': PauseStyle(0.0, 0.0, 0.0), +} + +_PAUSE_STYLE = PAUSE_STYLES['default'] + + +@contextlib.contextmanager +def pause_style(style): + """Context manager for pause styles. + + Parameters + ---------- + style : :class:`.PauseStyle` + pause style to use within the context + """ + old_style = get_pause_style() + try: + set_pause_style(style) + yield + finally: + set_pause_style(old_style) + + +def get_pause_style(): + """Get the current pause style""" + global _PAUSE_STYLE + return _PAUSE_STYLE + + +def set_pause_style(style): + """Set the pause style + + Parameters + ---------- + pause_style : :class:`.PauseStyle` or str + pause style to use, can be a string if the style is registered in + pause.PAUSE_STYLES + """ + global _PAUSE_STYLE + if isinstance(style, str): + try: + _PAUSE_STYLE = PAUSE_STYLES[style] + except KeyError as exc: + raise RuntimeError(f"Unknown pause style: '{style}'") from exc + else: + _PAUSE_STYLE = style + + +def section(wizard): + """Section break (pause and possible visual cue). + """ + time.sleep(_PAUSE_STYLE.section) + # TODO: have the wizard draw a line? + + +def long(wizard): + """Long pause from the wizard""" + time.sleep(_PAUSE_STYLE.long) + + +def short(wizard): + """Short pause from the wizard""" + time.sleep(_PAUSE_STYLE.short) From 5b99df02ef7d2c202452df8343cea2e721c338bc Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 29 Oct 2021 11:37:44 -0400 Subject: [PATCH 191/251] support for catching QuitWizard exception --- paths_cli/wizard/wizard.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 627b7cd4..1bfaafba 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -9,7 +9,7 @@ FILE_LOADING_ERROR_MSG, RestartObjectException ) from paths_cli.wizard.joke import name_joke -from paths_cli.wizard.helper import Helper +from paths_cli.wizard.helper import Helper, QuitWizard from paths_cli.compiling.tools import custom_eval import shutil @@ -313,13 +313,33 @@ def run_wizard(self): # TODO: next line is only temporary self.say("Today I'll help you set up a 2-state TPS simulation.") self._patch() # try to hide the slowness of our first import - for step in self.steps: - req = step.store_name, step.minimum, step.maximum - do_another = True - while do_another: - do_another = self._do_one(step, req) - storage = self.get_storage() - self.save_to_file(storage) + try: + for step in self.steps: + req = step.store_name, step.minimum, step.maximum + do_another = True + while do_another: + do_another = self._do_one(step, req) + except QuitWizard: + do_save = self._ask_save() + else: + do_save = True + + if do_save: + storage = self.get_storage() + self.save_to_file(storage) + else: + self.say("Goodbye! 👋") + + @get_object + def _ask_save(self): + do_save_char = self.ask("Before quitting, would you like to " + "the objects you've created so far?") + try: + do_save = yes_no(do_save_char) + except Exception: + self.bad_input("Sorry, I didn't understance that.") + return None + return do_save # FIXED_LENGTH_TPS_WIZARD From 75684966058ef5f199281c0f929ee67095450f5e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 29 Oct 2021 18:34:23 -0400 Subject: [PATCH 192/251] tests for wizard quit support --- paths_cli/tests/wizard/test_wizard.py | 28 +++++++++++++++++++++++++++ paths_cli/wizard/wizard.py | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index 12953253..e42948c3 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -368,3 +368,31 @@ def test_run_wizard(self, toy_engine): assert len(storage.networks) == len(storage.schemes) == 0 assert len(storage.engines) == 1 assert storage.engines[toy_engine.name] == toy_engine + + def test_run_wizard_quit(self): + console = MockConsole() + self.wizard.console = console + self.wizard._patched = True + step = mock.Mock( + func=mock.Mock(side_effect=QuitWizard()), + display_name='Engine', + store_name='engines', + minimum=1, + maximum=1 + ) + self.wizard.steps = [step] + mock_ask_save = mock.Mock(return_value=False) + with mock.patch.object(Wizard, '_ask_save', mock_ask_save): + self.wizard.run_wizard() + assert "Goodbye!" in self.wizard.console.log_text + + @pytest.mark.parametrize('inputs', ['y', 'n']) + def test_ask_save(self, inputs): + expected = {'y': True, 'n': False}[inputs] + console = MockConsole(['foo', inputs]) + self.wizard.console = console + result = self.wizard._ask_save() + assert result is expected + assert "Before quitting" in self.wizard.console.log_text + assert "Sorry" in self.wizard.console.log_text + diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index 1bfaafba..a41f084e 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -332,7 +332,7 @@ def run_wizard(self): @get_object def _ask_save(self): - do_save_char = self.ask("Before quitting, would you like to " + do_save_char = self.ask("Before quitting, would you like to save " "the objects you've created so far?") try: do_save = yes_no(do_save_char) From dd67a6950f81bd884328d1f9f39fbf72b8ad07ae Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 30 Oct 2021 09:35:36 -0400 Subject: [PATCH 193/251] roughly working automatic compile docs generation --- paths_cli/compiling/_gendocs/README.md | 6 ++ paths_cli/compiling/_gendocs/__init__.py | 2 + .../compiling/_gendocs/config_handler.py | 15 +++ .../compiling/_gendocs/docs_generator.py | 91 +++++++++++++++++++ .../compiling/_gendocs/json_type_handlers.py | 86 ++++++++++++++++++ paths_cli/compiling/core.py | 8 +- paths_cli/compiling/engines.py | 1 + paths_cli/compiling/gendocs.py | 16 ++++ paths_cli/compiling/shooting.py | 2 +- paths_cli/compiling/strategies.py | 10 +- paths_cli/compiling/volumes.py | 5 +- 11 files changed, 233 insertions(+), 9 deletions(-) create mode 100644 paths_cli/compiling/_gendocs/README.md create mode 100644 paths_cli/compiling/_gendocs/__init__.py create mode 100644 paths_cli/compiling/_gendocs/config_handler.py create mode 100644 paths_cli/compiling/_gendocs/docs_generator.py create mode 100644 paths_cli/compiling/_gendocs/json_type_handlers.py create mode 100644 paths_cli/compiling/gendocs.py diff --git a/paths_cli/compiling/_gendocs/README.md b/paths_cli/compiling/_gendocs/README.md new file mode 100644 index 00000000..f09543e3 --- /dev/null +++ b/paths_cli/compiling/_gendocs/README.md @@ -0,0 +1,6 @@ +# _docgen + +Tools for generating documentation for the tools import `paths_cli.compiling`. +Note that this entire directory is considered outside the API, so nothing in +here should be strongly relied on. + diff --git a/paths_cli/compiling/_gendocs/__init__.py b/paths_cli/compiling/_gendocs/__init__.py new file mode 100644 index 00000000..c69a9009 --- /dev/null +++ b/paths_cli/compiling/_gendocs/__init__.py @@ -0,0 +1,2 @@ +from .config_handler import load_config, DocCategoryInfo +from .docs_generator import DocsGenerator diff --git a/paths_cli/compiling/_gendocs/config_handler.py b/paths_cli/compiling/_gendocs/config_handler.py new file mode 100644 index 00000000..58b8df97 --- /dev/null +++ b/paths_cli/compiling/_gendocs/config_handler.py @@ -0,0 +1,15 @@ +from collections import namedtuple +from paths_cli.commands.compile import select_loader + +DocCategoryInfo = namedtuple('DocCategoryInfo', ['header', 'description'], + defaults=[None]) + + +def load_config(config_file): + loader = select_loader(config_file) + with open(config_file, mode='r') as f: + dct = loader(f) + + result = {category: DocCategoryInfo(**details) + for category, details in dct.items()} + return result diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py new file mode 100644 index 00000000..551d1ada --- /dev/null +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -0,0 +1,91 @@ +import sys +from paths_cli.compiling.core import Parameter +from .json_type_handlers import json_type_to_string +from .config_handler import DocCategoryInfo + +PARAMETER_RST = """* **{p.name}**{type_str} - {p.description}{required}\n""" + + +class DocsGenerator: + """This generates the RST to describe options for compile input files. + """ + + parameter_template = PARAMETER_RST + _ANCHOR_SEP = "--" + def __init__(self, config): + self.config = config + + def format_parameter(self, parameter, type_str=None): + required = " (required)" if parameter.required else "" + return self.parameter_template.format( + p=parameter, type_str=type_str, required=required + ) + + def _get_cat_info(self, category_plugin): + cat_info = self.config.get(category_plugin.label, None) + if cat_info is None: + cat_info = DocCategoryInfo(category_plugin.label) + return cat_info + + def generate_category_rst(self, category_plugin, type_required=True): + cat_info = self._get_cat_info(category_plugin) + rst = f".. _compiling--{category_plugin.label}:\n\n" + rst += f"{cat_info.header}\n{'=' * len(str(cat_info.header))}\n\n" + if cat_info.description: + rst += cat_info.description + "\n\n" + rst += ".. contents:: :local:\n\n" + for obj in category_plugin.type_dispatch.values(): + rst += self.generate_plugin_rst( + obj, category_plugin.label, type_required + ) + return rst + + def generate_plugin_rst(self, plugin, strategy_name, + type_required=True): + rst_anchor = f".. _{strategy_name}{self._ANCHOR_SEP}{plugin.name}:" + rst = f"{rst_anchor}\n\n{plugin.name}\n{'-' * len(plugin.name)}\n\n" + if plugin.description: + rst += plugin.description + "\n\n" + if type_required: + type_param = Parameter( + "type", + json_type="", + loader=None, + description=(f"type identifier; must exactly match the " + f"string ``{plugin.name}``"), + ) + rst += self.format_parameter( + type_param, type_str="" + ) + + name_param = Parameter( + "name", + json_type="string", + loader=None, + default="", + description="name this object in order to reuse it", + ) + rst += self.format_parameter(name_param, type_str=" (*string*)") + for param in plugin.parameters: + type_str = f" ({json_type_to_string(param.json_type)})" + rst += self.format_parameter(param, type_str) + + rst += "\n\n" + return rst + + def _get_filename(self, cat_info): + fname = str(cat_info.header).lower() + fname = fname.translate(str.maketrans(' ', '_')) + return f"{fname}.rst" + + def generate(self, category_plugins, stdout=False): + for plugin in category_plugins: + rst = self.generate_category_rst(plugin) + if stdout: + sys.stdout.write(rst) + sys.stdout.flush() + else: + cat_info = self._get_cat_info(plugin) + filename = self._get_filename(cat_info) + with open(filename, 'w') as f: + f.write(rst) diff --git a/paths_cli/compiling/_gendocs/json_type_handlers.py b/paths_cli/compiling/_gendocs/json_type_handlers.py new file mode 100644 index 00000000..a85e91a4 --- /dev/null +++ b/paths_cli/compiling/_gendocs/json_type_handlers.py @@ -0,0 +1,86 @@ +class JsonTypeHandler: + def __init__(self, is_my_type, handler): + self._is_my_type = is_my_type + self.handler = handler + + def is_my_type(self, json_type): + return self._is_my_type(json_type) + + def __call__(self, json_type): + if self.is_my_type(json_type): + return self.handler(json_type) + return json_type + + +handle_object = JsonTypeHandler( + is_my_type=lambda json_type: json_type == "object", + handler=lambda json_type: "dict", +) + + +def _is_listof(json_type): + try: + return json_type["type"] == "array" + except: + return False + + +handle_listof = JsonTypeHandler( + is_my_type=_is_listof, + handler=lambda json_type: "list of " + + json_type_to_string(json_type["items"]), +) + + +class RefTypeHandler(JsonTypeHandler): + def __init__(self, type_name, def_string, link_to): + self.type_name = type_name + self.def_string = def_string + self.link_to = link_to + self.json_check = {"$ref": "#/definitions/" + def_string} + super().__init__(is_my_type=self._reftype, handler=self._refhandler) + + def _reftype(self, json_type): + return json_type == self.json_check + + def _refhandler(self, json_type): + rst = f"{self.type_name}" + if self.link_to: + rst = f":ref:`{rst} <{self.link_to}>`" + return rst + + +class CategoryHandler(RefTypeHandler): + def __init__(self, category): + self.category = category + def_string = f"{category}_type" + link_to = f"compiling--{category}" + super().__init__( + type_name=category, def_string=def_string, link_to=link_to + ) + + +class EvalHandler(RefTypeHandler): + def __init__(self, type_name, link_to=None): + super().__init__( + type_name=type_name, def_string=type_name, link_to=link_to + ) + + +JSON_TYPE_HANDLERS = [ + handle_object, + handle_listof, + CategoryHandler("engine"), + CategoryHandler("cv"), + CategoryHandler("volume"), + EvalHandler("EvalInt"), + EvalHandler("EvalFloat"), +] + + +def json_type_to_string(json_type): + for handler in JSON_TYPE_HANDLERS: + handled = handler(json_type) + if handled != json_type: + return handled + return json_type diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 188c8f5c..9152c1b3 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -143,6 +143,8 @@ class InstanceCompilerPlugin(OPSPlugin): Name used in the text input for this object. aliases : List[str] Other names that can be used. + description : str + String description used in the documentation requires_ops : Tuple[int, int] version of OpenPathSampling required for this functionality requires_cli : Tuple[int, int] @@ -150,10 +152,12 @@ class InstanceCompilerPlugin(OPSPlugin): """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" category = None - def __init__(self, builder, parameters, name=None, aliases=None, - requires_ops=(1, 0), requires_cli=(0, 3)): + def __init__(self, builder, parameters, name=None, *, aliases=None, + description=None, requires_ops=(1, 0), + requires_cli=(0, 3)): super().__init__(requires_ops, requires_cli) self.aliases = aliases + self.description = description self.attribute_table = {p.name: p.loader for p in parameters if p.required} self.optional_attributes = {p.name: p.loader for p in parameters diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 28c067ef..9c15f4f6 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -43,6 +43,7 @@ def _openmm_options(dct): remapper=_openmm_options), parameters=OPENMM_PARAMETERS, name='openmm', + description="Use OpenMM for the dynamics.", ) ENGINE_COMPILER = CategoryPlugin(EngineCompilerPlugin, aliases=['engines']) diff --git a/paths_cli/compiling/gendocs.py b/paths_cli/compiling/gendocs.py new file mode 100644 index 00000000..e67b3c78 --- /dev/null +++ b/paths_cli/compiling/gendocs.py @@ -0,0 +1,16 @@ +import click +from paths_cli.compiling._gendocs import DocsGenerator, load_config +from paths_cli.compiling.root_compiler import _COMPILERS +from paths_cli.commands.compile import register_installed_plugins + +@click.command() +@click.argument("config_file") +@click.option("--stdout", type=bool, is_flag=True, default=False) +def main(config_file, stdout): # -no-cov- + register_installed_plugins() + config = load_config(config_file) + generator = DocsGenerator(config) + generator.generate(_COMPILERS.values(), stdout) + +if __name__ == "__main__": + main() diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index be474785..170fe26f 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -32,5 +32,5 @@ def _remapping_gaussian_stddev(dct): 'uniform': build_uniform_selector, 'gaussian': build_gaussian_selector, }, - label='shooting selectors' + label='shooting-selectors' ) diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 9d00de5e..773cf0e5 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -4,6 +4,7 @@ StrategyCompilerPlugin, CategoryPlugin ) from paths_cli.compiling.root_compiler import compiler_for +from paths_cli.compiling.json_type import json_type_ref def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" @@ -16,9 +17,10 @@ def _group_parameter(group_name): SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler, default=None) -ENGINE_PARAMETER = Parameter('engine', compiler_for('engine'), - description="the engine for moves of this " - "type") +ENGINE_PARAMETER = Parameter( + 'engine', compiler_for('engine'), json_type=json_type_ref('engine'), + description="the engine for moves of this type" +) SHOOTING_GROUP_PARAMETER = _group_parameter('shooting') REPEX_GROUP_PARAMETER = _group_parameter('repex') @@ -57,7 +59,7 @@ def _group_parameter(group_name): REPEX_GROUP_PARAMETER, REPLACE_TRUE_PARAMETER ], - name='nearest-neighbor=repex', + name='nearest-neighbor-repex', ) build_all_set_repex_strategy = StrategyCompilerPlugin( diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index a1952a14..9def6e33 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -5,6 +5,7 @@ from .tools import custom_eval from paths_cli.compiling.plugins import VolumeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for +from paths_cli.compiling.json_type import json_type_ref # TODO: extra function for volumes should not be necessary as of OPS 2.0 def cv_volume_build_func(**dct): @@ -23,7 +24,7 @@ def cv_volume_build_func(**dct): CV_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=cv_volume_build_func, parameters=[ - Parameter('cv', compiler_for('cv'), + Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), description="CV that defines this volume"), Parameter('lambda_min', custom_eval, description="Lower bound for this volume"), @@ -38,7 +39,7 @@ def cv_volume_build_func(**dct): # jsonschema type for combination volumes VOL_ARRAY_TYPE = { 'type': 'array', - 'items': {"$ref": "#/definitions/volume_type"} + 'items': json_type_ref('volume'), } INTERSECTION_VOLUME_PLUGIN = VolumeCompilerPlugin( From 936e0b39d498037c22abd4699ad4647b33f70640 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 30 Oct 2021 09:45:34 -0400 Subject: [PATCH 194/251] add json_type --- paths_cli/compiling/json_type.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 paths_cli/compiling/json_type.py diff --git a/paths_cli/compiling/json_type.py b/paths_cli/compiling/json_type.py new file mode 100644 index 00000000..1a02f67a --- /dev/null +++ b/paths_cli/compiling/json_type.py @@ -0,0 +1,3 @@ + +def json_type_ref(category): + return {"$ref": f"#/definitions/{category}_type"} From b0cea2133d1318932b952a85fbce483840c2cdee Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 31 Oct 2021 06:46:46 -0400 Subject: [PATCH 195/251] Add tests for config_handler, json_type_handlers --- .../compiling/_gendocs/test_config_handler.py | 40 +++++++++++++++ .../_gendocs/test_json_type_handlers.py | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 paths_cli/tests/compiling/_gendocs/test_config_handler.py create mode 100644 paths_cli/tests/compiling/_gendocs/test_json_type_handlers.py diff --git a/paths_cli/tests/compiling/_gendocs/test_config_handler.py b/paths_cli/tests/compiling/_gendocs/test_config_handler.py new file mode 100644 index 00000000..035ba491 --- /dev/null +++ b/paths_cli/tests/compiling/_gendocs/test_config_handler.py @@ -0,0 +1,40 @@ +import pytest +from unittest import mock + +from paths_cli.compiling._gendocs.config_handler import * + +GOOD_DATA = {'engine': {'header': "Engines", 'description': "desc"}} + +MODULE_LOC = "paths_cli.compiling._gendocs.config_handler." + + +def test_load_config(): + # load_config should create the correct DocCategoryInfo objects + mock_open = mock.mock_open(read_data="unused") + mock_loader = mock.Mock(return_value=mock.Mock(return_value=GOOD_DATA)) + with mock.patch(MODULE_LOC + "open", mock_open) as m_open, \ + mock.patch(MODULE_LOC + "select_loader", mock_loader) as m_load: + config = load_config("foo.yml") + assert 'engine' in config + data = config['engine'] + assert isinstance(data, DocCategoryInfo) + assert data.header == "Engines" + assert data.description == "desc" + + +def test_load_config_file_error(): + # if the filename is bad, we should get a FileNotFoundError + with pytest.raises(FileNotFoundError): + load_config("foo.yml") + + +def test_load_config_data_error(): + # if the data can't be mapped to a DocCategoryInfo class, we should get + # a TypeError + BAD_DATA = {'engine': {'foo': 'bar'}} + mock_open = mock.mock_open(read_data="unused") + mock_loader = mock.Mock(return_value=mock.Mock(return_value=BAD_DATA)) + with mock.patch(MODULE_LOC + "open", mock_open) as m_open, \ + mock.patch(MODULE_LOC + "select_loader", mock_loader) as m_load: + with pytest.raises(TypeError, match='unexpected keyword'): + load_config("foo.yml") diff --git a/paths_cli/tests/compiling/_gendocs/test_json_type_handlers.py b/paths_cli/tests/compiling/_gendocs/test_json_type_handlers.py new file mode 100644 index 00000000..25dfe1f5 --- /dev/null +++ b/paths_cli/tests/compiling/_gendocs/test_json_type_handlers.py @@ -0,0 +1,50 @@ +import pytest + +from paths_cli.compiling._gendocs.json_type_handlers import * + + +@pytest.mark.parametrize('json_type', ['object', 'foo']) +def test_handle_object(json_type): + # object should convert to dict; anything else should be is-identical + if json_type == "foo": + json_type = {'foo': 'bar'} # make the `is` test meaningful + result = handle_object(json_type) + if json_type == 'object': + assert result == 'dict' + else: + assert result is json_type + + +def test_handle_listof(): + json_type = {'type': "array", "items": "float"} + assert handle_listof(json_type) == "list of float" + + +def test_category_handler(): + json_type = {"$ref": "#/definitions/engine_type"} + handler = CategoryHandler('engine') + assert handler(json_type) == ":ref:`engine `" + + +def test_eval_handler(): + json_type = {"$ref": "#/definitions/EvalInt"} + handler = EvalHandler("EvalInt", link_to="EvalInt") + assert handler(json_type) == ":ref:`EvalInt `" + + +@pytest.mark.parametrize('json_type', ['string', 'float', 'list-float', + 'engine', 'list-engine']) +def test_json_type_to_string(json_type): + # integration tests for json_type_to_string + inputs, expected = { + 'string': ("string", "string"), + 'float': ("float", "float"), + 'list-float': ({'type': 'array', 'items': 'float'}, + "list of float"), + 'engine': ({"$ref": "#/definitions/engine_type"}, + ":ref:`engine `"), + 'list-engine': ({"type": "array", + "items": {"$ref": "#/definitions/engine_type"}}, + "list of :ref:`engine `"), + }[json_type] + assert json_type_to_string(inputs) == expected From 084eef17eacb3a955d768e74af0a8220744935b0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 31 Oct 2021 12:14:51 +0100 Subject: [PATCH 196/251] Update paths_cli/tests/test_utils.py Co-authored-by: Sander Roet --- paths_cli/tests/test_utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/paths_cli/tests/test_utils.py b/paths_cli/tests/test_utils.py index e2940885..b6234ec5 100644 --- a/paths_cli/tests/test_utils.py +++ b/paths_cli/tests/test_utils.py @@ -26,6 +26,7 @@ def test_add_existing(self): def test_contains(self): assert 'a' in self.set assert not 'q' in self.set + assert 'q' not in self.set def test_discard(self): self.set.discard('a') From 83de5f947442a28ad7d48d8de131151a98a0ff9a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 09:55:05 -0400 Subject: [PATCH 197/251] tests for docs_generator --- paths_cli/compiling/_gendocs/README.md | 2 +- .../compiling/_gendocs/docs_generator.py | 13 +- paths_cli/compiling/gendocs.py | 2 +- .../compiling/_gendocs/test_docs_generator.py | 143 ++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) create mode 100644 paths_cli/tests/compiling/_gendocs/test_docs_generator.py diff --git a/paths_cli/compiling/_gendocs/README.md b/paths_cli/compiling/_gendocs/README.md index f09543e3..ed4aa069 100644 --- a/paths_cli/compiling/_gendocs/README.md +++ b/paths_cli/compiling/_gendocs/README.md @@ -1,4 +1,4 @@ -# _docgen +# _gendocs Tools for generating documentation for the tools import `paths_cli.compiling`. Note that this entire directory is considered outside the API, so nothing in diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 551d1ada..4ecf2879 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -5,9 +5,16 @@ PARAMETER_RST = """* **{p.name}**{type_str} - {p.description}{required}\n""" +# TODO: add templates here instead of adding up strings in the code class DocsGenerator: """This generates the RST to describe options for compile input files. + + Parameters + ---------- + config : Dict[str, DocCategoryInfo] + mapping of category name to DocCategoryInfo for that category; + usually generated by :method:`.load_config` """ parameter_template = PARAMETER_RST @@ -16,6 +23,8 @@ def __init__(self, config): self.config = config def format_parameter(self, parameter, type_str=None): + """Format a single :class:`.paths_cli.compiling.Parameter` in RST + """ required = " (required)" if parameter.required else "" return self.parameter_template.format( p=parameter, type_str=type_str, required=required @@ -40,9 +49,9 @@ def generate_category_rst(self, category_plugin, type_required=True): ) return rst - def generate_plugin_rst(self, plugin, strategy_name, + def generate_plugin_rst(self, plugin, category_name, type_required=True): - rst_anchor = f".. _{strategy_name}{self._ANCHOR_SEP}{plugin.name}:" + rst_anchor = f".. _{category_name}{self._ANCHOR_SEP}{plugin.name}:" rst = f"{rst_anchor}\n\n{plugin.name}\n{'-' * len(plugin.name)}\n\n" if plugin.description: rst += plugin.description + "\n\n" diff --git a/paths_cli/compiling/gendocs.py b/paths_cli/compiling/gendocs.py index e67b3c78..d20bfb4d 100644 --- a/paths_cli/compiling/gendocs.py +++ b/paths_cli/compiling/gendocs.py @@ -6,7 +6,7 @@ @click.command() @click.argument("config_file") @click.option("--stdout", type=bool, is_flag=True, default=False) -def main(config_file, stdout): # -no-cov- +def main(config_file, stdout): register_installed_plugins() config = load_config(config_file) generator = DocsGenerator(config) diff --git a/paths_cli/tests/compiling/_gendocs/test_docs_generator.py b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py new file mode 100644 index 00000000..2dc694f9 --- /dev/null +++ b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py @@ -0,0 +1,143 @@ +import pytest +from unittest import mock +import io + +from paths_cli.compiling._gendocs.docs_generator import * +from paths_cli.compiling._gendocs import DocCategoryInfo + +from paths_cli.compiling import Parameter, InstanceCompilerPlugin +from paths_cli.compiling.core import CategoryCompiler + +class TestDocsGenerator: + def setup(self): + self.required_parameter = Parameter( + name="req_param", + loader=None, + json_type="string", + description="req_desc", + ) + self.optional_parameter = Parameter( + name="opt_param", + loader=None, + json_type="string", + description="opt_desc", + default="foo", + ) + self.plugin = InstanceCompilerPlugin( + builder=None, + parameters=[self.required_parameter, + self.optional_parameter], + name="object_plugin", + description="object_plugin_desc", + ) + self.category = CategoryCompiler( + type_dispatch=None, + label="category", + ) + self.category.register_builder(self.plugin, self.plugin.name) + + self.generator = DocsGenerator( + {'category': DocCategoryInfo(header="Header", + description="category_desc")} + ) + self.param_strs = ['req_param', 'req_desc', 'opt_param', 'opt_desc'] + self.obj_plugin_strs = [ + ".. _category--object_plugin:", + "object_plugin\n-------------\n", + "object_plugin_desc", + ] + self.category_strs = [ + ".. _compiling--category:", + "Header\n======\n", + ".. contents:: :local:", + "\ncategory_desc\n", + ] + + @pytest.mark.parametrize('param_type', ['req', 'opt']) + def test_format_parameter(self, param_type): + # when formatting individual parameters, we should obtain the + # correct RST string + param = {'req': self.required_parameter, + 'opt': self.optional_parameter}[param_type] + expected = { # NOTE: these can change without breaking API + 'req': "* **req_param** (string) - req_desc (required)\n", + 'opt': "* **opt_param** (string) - opt_desc\n", + }[param_type] + type_str = f" ({param.json_type})" + result = self.generator.format_parameter(param, type_str=type_str) + assert result == expected + + @pytest.mark.parametrize('label', ['category', 'unknown']) + def test_get_cat_info(self, label): + # getting a DocCategoryInfo should either give the result from the + # config or give the best-guess default + category = { + 'category': self.category, + 'unknown': CategoryCompiler(type_dispatch=None, + label="unknown"), + }[label] + expected = { + 'category': DocCategoryInfo(header="Header", + description="category_desc"), + 'unknown': DocCategoryInfo(header='unknown') + }[label] + assert self.generator._get_cat_info(category) == expected + + def test_generate_category_rst(self): + # generating RST for a category plugin should include the expected + # RST strings + result = self.generator.generate_category_rst(self.category) + expected_strs = (self.param_strs + self.obj_plugin_strs + + self.category_strs) + for string in expected_strs: + assert string in result + + @pytest.mark.parametrize('type_required', [True, False]) + def test_generate_plugin_rst(self, type_required): + # generating RST for an object plugin should include the expected + # RST strings + result = self.generator.generate_plugin_rst( + self.plugin, + self.category.label, + type_required=type_required + ) + for string in self.param_strs + self.obj_plugin_strs: + assert string in result + + type_str = "**type** - type identifier; must exactly match" + if type_required: + assert type_str in result + else: + assert type_str not in result + + @pytest.mark.parametrize('inputs,expected', [ + ("Collective Variables", "collective_variables.rst"), + ]) + def test_get_filename(self, inputs, expected): + # filenames creating from a DocCategoryInfo (using _get_filename) + # should give expected filenames + cat_info = DocCategoryInfo(inputs) + assert self.generator._get_filename(cat_info) == expected + + @pytest.mark.parametrize('stdout', [True, False]) + def test_generate(self, stdout): + base_loc = "paths_cli.compiling._gendocs.docs_generator." + if stdout: + mock_loc = base_loc + "sys.stdout" + mock_obj = mock.Mock() + else: + mock_loc = base_loc + "open" + mock_obj = mock.mock_open() + + mock_generate_category = mock.Mock(return_value="foo") + self.generator.generate_category_rst =mock_generate_category + + with mock.patch(mock_loc, mock_obj): + self.generator.generate([self.category], stdout=stdout) + + mock_generate_category.assert_called_once() + if not stdout: + mock_obj.assert_called_once_with('header.rst', 'w') + + write = mock_obj.write if stdout else mock_obj().write + write.assert_called_once_with("foo") From e19b5a0c59db6e3278401e9b6a9442aadf89ceba Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 10:11:21 -0400 Subject: [PATCH 198/251] test for the gendocs script --- paths_cli/compiling/gendocs.py | 1 + paths_cli/tests/compiling/test_gendocs.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 paths_cli/tests/compiling/test_gendocs.py diff --git a/paths_cli/compiling/gendocs.py b/paths_cli/compiling/gendocs.py index d20bfb4d..8e9e6cb2 100644 --- a/paths_cli/compiling/gendocs.py +++ b/paths_cli/compiling/gendocs.py @@ -7,6 +7,7 @@ @click.argument("config_file") @click.option("--stdout", type=bool, is_flag=True, default=False) def main(config_file, stdout): + """Generate documentation for installed compiling plugins.""" register_installed_plugins() config = load_config(config_file) generator = DocsGenerator(config) diff --git a/paths_cli/tests/compiling/test_gendocs.py b/paths_cli/tests/compiling/test_gendocs.py new file mode 100644 index 00000000..8b3acfd9 --- /dev/null +++ b/paths_cli/tests/compiling/test_gendocs.py @@ -0,0 +1,14 @@ +from click.testing import CliRunner +from paths_cli.compiling.gendocs import * + +from unittest import mock + +@mock.patch('paths_cli.compiling.gendocs.load_config', + mock.Mock(return_value={})) +def test_main(): + # this is a smoke test, just to ensure that it doesn't crash; + # functionality testing is in the _gendocs directory + runner = CliRunner() + with runner.isolated_filesystem(): + _ = runner.invoke(main, 'fake_config.yml', '--stdout') + From d3567ccb6396d1de96d4e370ed89aeff94fe7348 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 10:56:25 -0400 Subject: [PATCH 199/251] docstrings --- .../compiling/_gendocs/config_handler.py | 11 +++ .../compiling/_gendocs/docs_generator.py | 50 +++++++++++- .../compiling/_gendocs/json_type_handlers.py | 77 ++++++++++++++++++- paths_cli/compiling/gendocs.py | 2 + 4 files changed, 137 insertions(+), 3 deletions(-) diff --git a/paths_cli/compiling/_gendocs/config_handler.py b/paths_cli/compiling/_gendocs/config_handler.py index 58b8df97..b98d2672 100644 --- a/paths_cli/compiling/_gendocs/config_handler.py +++ b/paths_cli/compiling/_gendocs/config_handler.py @@ -6,6 +6,17 @@ def load_config(config_file): + """Load a configuration file for gendocs. + + The configuration file should be YAML or JSON, and should map each + category name to the headings necessary to fill a DocCategoryInfo + instance. + + Parameters + ---------- + config_file : str + name of YAML or JSON file + """ loader = select_loader(config_file) with open(config_file, mode='r') as f: dct = loader(f) diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 4ecf2879..9e977980 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -4,9 +4,9 @@ from .config_handler import DocCategoryInfo PARAMETER_RST = """* **{p.name}**{type_str} - {p.description}{required}\n""" - # TODO: add templates here instead of adding up strings in the code + class DocsGenerator: """This generates the RST to describe options for compile input files. @@ -19,6 +19,7 @@ class DocsGenerator: parameter_template = PARAMETER_RST _ANCHOR_SEP = "--" + def __init__(self, config): self.config = config @@ -37,6 +38,19 @@ def _get_cat_info(self, category_plugin): return cat_info def generate_category_rst(self, category_plugin, type_required=True): + """Generate the RST for a given category plugin. + + Parameters + ---------- + category_plugin : :class:`.CategoryPlugin` + the plugin for which we should generate the RST page + + Returns + ------- + str : + RST string for this category + """ + # TODO: move type_required to DocCategoryInfo (default True) cat_info = self._get_cat_info(category_plugin) rst = f".. _compiling--{category_plugin.label}:\n\n" rst += f"{cat_info.header}\n{'=' * len(str(cat_info.header))}\n\n" @@ -51,6 +65,23 @@ def generate_category_rst(self, category_plugin, type_required=True): def generate_plugin_rst(self, plugin, category_name, type_required=True): + """Generate the RST for a given object plugin. + + Parameters + ---------- + plugin : class:`.InstanceCompilerPlugin` + the object plugin for to generate the RST for + category_name : str + the name of the category for this object + type_required : bool + whether the ``type`` parameter is required in the dict input for + compiling this type of object (usually category-dependent) + + Returns + ------- + str : + RST string for this object plugin + """ rst_anchor = f".. _{category_name}{self._ANCHOR_SEP}{plugin.name}:" rst = f"{rst_anchor}\n\n{plugin.name}\n{'-' * len(plugin.name)}\n\n" if plugin.description: @@ -82,12 +113,27 @@ def generate_plugin_rst(self, plugin, category_name, rst += "\n\n" return rst - def _get_filename(self, cat_info): + @staticmethod + def _get_filename(cat_info): fname = str(cat_info.header).lower() fname = fname.translate(str.maketrans(' ', '_')) return f"{fname}.rst" def generate(self, category_plugins, stdout=False): + """Generate RST output for the given plugins. + + This is the main method used to generate the entire set of + documentation. + + Parameters + ---------- + category_plugin : List[:class:`.CategoryPlugin`] + list of category plugins document + stdout : bool + if False (default) a separate output file is generated for each + category plugin. If True, all text is output to stdout + (particularly useful for debugging/dry runs). + """ for plugin in category_plugins: rst = self.generate_category_rst(plugin) if stdout: diff --git a/paths_cli/compiling/_gendocs/json_type_handlers.py b/paths_cli/compiling/_gendocs/json_type_handlers.py index a85e91a4..d918807c 100644 --- a/paths_cli/compiling/_gendocs/json_type_handlers.py +++ b/paths_cli/compiling/_gendocs/json_type_handlers.py @@ -1,9 +1,30 @@ class JsonTypeHandler: + """Abstract class to obtain documentation type from JSON schema type. + + Parameters + ---------- + is_my_type : Callable[Any] -> bool + return True if this instance should handle the given input type + handler : Callable[Any] -> str + convert the input type to a string suitable for the RST docs + """ def __init__(self, is_my_type, handler): self._is_my_type = is_my_type self.handler = handler def is_my_type(self, json_type): + """Determine whether this instance should handle this type. + + Parameters + ---------- + json_type : Any + input type from JSON schema + + Returns + ------- + bool : + whether to handle this type with this instance + """ return self._is_my_type(json_type) def __call__(self, json_type): @@ -21,7 +42,7 @@ def __call__(self, json_type): def _is_listof(json_type): try: return json_type["type"] == "array" - except: + except: # any exception should return false (mostly Key/Type Error) return False @@ -33,6 +54,18 @@ def _is_listof(json_type): class RefTypeHandler(JsonTypeHandler): + """Handle JSON types of the form {"$ref": "#/definitions/..."} + + Parameters + ---------- + type_name : str + the name to use in the RST type + def_string : str + the string following "#/definitions/" in the JSON type definition + link_to : str or None + if not None, the RST type will be linked with a ``:ref:`` pointing + to the anchor given by ``link_to`` + """ def __init__(self, type_name, def_string, link_to): self.type_name = type_name self.def_string = def_string @@ -51,6 +84,17 @@ def _refhandler(self, json_type): class CategoryHandler(RefTypeHandler): + """Handle JSON types for OPS category definitions. + + OPS category definitions show up with JSON references pointing to + "#/definitions/{CATEGORY}_type". This provides a convenience class over + the :class:RefTypeHandler to treat OPS categories. + + Parameters + ---------- + category : str + name of the category + """ def __init__(self, category): self.category = category def_string = f"{category}_type" @@ -61,6 +105,22 @@ def __init__(self, category): class EvalHandler(RefTypeHandler): + """Handle JSON types for OPS custom evaluation definitions. + + Some parameters for the OPS compiler use the OPS custom evaluation + mechanism, which evaluates certain Python-like string input. These are + treated as special definition types in the JSON schema, and this object + provides a convenience class over :class:`.RefTypeHandler` to treat + custom evaluation types. + + Parameters + ---------- + type_name : str + name of the custom evaluation type + link_to : str or None + if not None, the RST type will be linked with a ``:ref:`` pointing + to the anchor given by ``link_to`` + """ def __init__(self, type_name, link_to=None): super().__init__( type_name=type_name, def_string=type_name, link_to=link_to @@ -79,6 +139,21 @@ def __init__(self, type_name, link_to=None): def json_type_to_string(json_type): + """Convert JSON schema type to string for RST docs. + + This is the primary public-facing method for dealing with JSON schema + types in RST document generation. + + Parameters + ---------- + json_type : Any + the type from the JSON schema + + Returns + ------- + str : + the type string description to be used in the RST document + """ for handler in JSON_TYPE_HANDLERS: handled = handler(json_type) if handled != json_type: diff --git a/paths_cli/compiling/gendocs.py b/paths_cli/compiling/gendocs.py index 8e9e6cb2..aa3ca1db 100644 --- a/paths_cli/compiling/gendocs.py +++ b/paths_cli/compiling/gendocs.py @@ -3,6 +3,7 @@ from paths_cli.compiling.root_compiler import _COMPILERS from paths_cli.commands.compile import register_installed_plugins + @click.command() @click.argument("config_file") @click.option("--stdout", type=bool, is_flag=True, default=False) @@ -13,5 +14,6 @@ def main(config_file, stdout): generator = DocsGenerator(config) generator.generate(_COMPILERS.values(), stdout) + if __name__ == "__main__": main() From def35ebed145115793d207152e673cf1ba9d307c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 11:10:40 -0400 Subject: [PATCH 200/251] a little cleanup; should be ready for review --- paths_cli/compiling/_gendocs/config_handler.py | 5 +++-- paths_cli/compiling/_gendocs/docs_generator.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/paths_cli/compiling/_gendocs/config_handler.py b/paths_cli/compiling/_gendocs/config_handler.py index b98d2672..06ba940d 100644 --- a/paths_cli/compiling/_gendocs/config_handler.py +++ b/paths_cli/compiling/_gendocs/config_handler.py @@ -1,8 +1,9 @@ from collections import namedtuple from paths_cli.commands.compile import select_loader -DocCategoryInfo = namedtuple('DocCategoryInfo', ['header', 'description'], - defaults=[None]) +DocCategoryInfo = namedtuple('DocCategoryInfo', ['header', 'description', + 'type_required'], + defaults=[None, True]) def load_config(config_file): diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 9e977980..699ed48a 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -4,7 +4,6 @@ from .config_handler import DocCategoryInfo PARAMETER_RST = """* **{p.name}**{type_str} - {p.description}{required}\n""" -# TODO: add templates here instead of adding up strings in the code class DocsGenerator: @@ -37,7 +36,7 @@ def _get_cat_info(self, category_plugin): cat_info = DocCategoryInfo(category_plugin.label) return cat_info - def generate_category_rst(self, category_plugin, type_required=True): + def generate_category_rst(self, category_plugin): """Generate the RST for a given category plugin. Parameters @@ -52,6 +51,7 @@ def generate_category_rst(self, category_plugin, type_required=True): """ # TODO: move type_required to DocCategoryInfo (default True) cat_info = self._get_cat_info(category_plugin) + type_required = cat_info.type_required rst = f".. _compiling--{category_plugin.label}:\n\n" rst += f"{cat_info.header}\n{'=' * len(str(cat_info.header))}\n\n" if cat_info.description: From 6fb06fce03612d7f1febc195456223217854d404 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 11:51:13 -0400 Subject: [PATCH 201/251] Use the pauses, add section splitting line --- paths_cli/wizard/pause.py | 4 ++-- paths_cli/wizard/two_state_tps.py | 2 ++ paths_cli/wizard/volumes.py | 9 +++++++-- paths_cli/wizard/wizard.py | 6 ++++++ 4 files changed, 17 insertions(+), 4 deletions(-) diff --git a/paths_cli/wizard/pause.py b/paths_cli/wizard/pause.py index 9bd5fb40..42ecb9b2 100644 --- a/paths_cli/wizard/pause.py +++ b/paths_cli/wizard/pause.py @@ -6,7 +6,7 @@ PAUSE_STYLES = { 'testing': PauseStyle(0.01, 0.03, 0.05), - 'default': PauseStyle(0.1, 0.5, 0.5), + 'default': PauseStyle(0.1, 0.5, 0.75), 'nopause': PauseStyle(0.0, 0.0, 0.0), } @@ -59,7 +59,7 @@ def section(wizard): """Section break (pause and possible visual cue). """ time.sleep(_PAUSE_STYLE.section) - # TODO: have the wizard draw a line? + wizard.console.draw_hline() def long(wizard): diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index e2e0a434..1509488a 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -4,6 +4,7 @@ SINGLE_ENGINE_STEP, CVS_STEP, WizardStep ) from paths_cli.wizard.wizard import Wizard +from paths_cli.wizard import pause volumes = get_category_wizard('volume') from paths_cli.wizard.volumes import _FIRST_STATE, _VOL_DESC @@ -18,6 +19,7 @@ def two_state_tps(wizard, fixed_length=False): ] initial_state = volumes(wizard, context={'intro': intro}) wizard.register(initial_state, 'initial state', 'states') + pause.section(wizard) intro = [ "Next let's define your final state.", _VOL_DESC diff --git a/paths_cli/wizard/volumes.py b/paths_cli/wizard/volumes.py index 4c58fee7..e1282a58 100644 --- a/paths_cli/wizard/volumes.py +++ b/paths_cli/wizard/volumes.py @@ -8,16 +8,21 @@ from paths_cli.wizard.helper import EvalHelperFunc, Helper from paths_cli.wizard.core import interpret_req import paths_cli.compiling.volumes +from paths_cli.wizard import pause def _binary_func_volume(wizard, context, op): - wizard.say("Let's make the first constituent volume:") + wizard.say("Let's make the first constituent volume.") + pause.long(wizard) new_context = volume_set_context(wizard, context, selected=None) new_context['part'] = 1 vol1 = VOLUMES_PLUGIN(wizard, new_context) - wizard.say("Let's make the second constituent volume:") + pause.section(wizard) + wizard.say("Let's make the second constituent volume.") + pause.long(wizard) new_context['part'] = 2 vol2 = VOLUMES_PLUGIN(wizard, new_context) + pause.section(wizard) wizard.say("Now we'll combine those two constituent volumes...") vol = op(vol1, vol2) return vol diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index a41f084e..81e89e90 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -12,6 +12,8 @@ from paths_cli.wizard.helper import Helper, QuitWizard from paths_cli.compiling.tools import custom_eval +from paths_cli.wizard import pause + import shutil class Console: # no-cov @@ -26,6 +28,9 @@ def input(self, content): def width(self): return shutil.get_terminal_size((80, 24)).columns + def draw_hline(self): + self.print('═' * self.width) + class Wizard: def __init__(self, steps): self.steps = steps @@ -299,6 +304,7 @@ def _do_one(self, step, req): self.say("Okay, let's try that again.") return True self.register(obj, step.display_name, step.store_name) + pause.section(self) requires_another, allows_another = self._req_do_another(req) if requires_another: do_another = True From d1f430a1811e7eccd9adcbd57c5c14f734cc2592 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 17:03:43 +0100 Subject: [PATCH 202/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/compiling/_gendocs/config_handler.py | 2 +- paths_cli/compiling/_gendocs/docs_generator.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/paths_cli/compiling/_gendocs/config_handler.py b/paths_cli/compiling/_gendocs/config_handler.py index 06ba940d..c50f7a74 100644 --- a/paths_cli/compiling/_gendocs/config_handler.py +++ b/paths_cli/compiling/_gendocs/config_handler.py @@ -19,7 +19,7 @@ def load_config(config_file): name of YAML or JSON file """ loader = select_loader(config_file) - with open(config_file, mode='r') as f: + with open(config_file, mode='r', encoding='utf-8') as f: dct = loader(f) result = {category: DocCategoryInfo(**details) diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 699ed48a..93a3887d 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -49,7 +49,6 @@ def generate_category_rst(self, category_plugin): str : RST string for this category """ - # TODO: move type_required to DocCategoryInfo (default True) cat_info = self._get_cat_info(category_plugin) type_required = cat_info.type_required rst = f".. _compiling--{category_plugin.label}:\n\n" From 72669fd7a508f1a6a6b1b6e45c50b033dcc8632b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 13:02:45 -0400 Subject: [PATCH 203/251] fix tests for pausing --- paths_cli/tests/wizard/conftest.py | 9 +++++++++ paths_cli/tests/wizard/mock_wizard.py | 4 ++++ paths_cli/tests/wizard/test_pause.py | 5 +++++ paths_cli/tests/wizard/test_volumes.py | 2 +- 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/paths_cli/tests/wizard/conftest.py b/paths_cli/tests/wizard/conftest.py index 38a55af0..adfdfafd 100644 --- a/paths_cli/tests/wizard/conftest.py +++ b/paths_cli/tests/wizard/conftest.py @@ -5,6 +5,15 @@ from paths_cli.compat.openmm import HAS_OPENMM, mm, unit +from paths_cli.wizard import pause + + +@pytest.fixture(autouse=True, scope='session') +def pause_style_testing(): + pause.set_pause_style('testing') + yield + + # TODO: this isn't wizard-specific, and should be moved somwhere more # generally useful (like, oh, maybe openpathsampling.tests.fixtures?) @pytest.fixture diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py index a35d07a4..d273c5be 100644 --- a/paths_cli/tests/wizard/mock_wizard.py +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -37,6 +37,10 @@ def input(self, content): self.log.append(content + " " + user_input) return user_input + def draw_hline(self): + # we don't even bother for the mock console + pass + @property def log_text(self): return "\n".join(self.log) diff --git a/paths_cli/tests/wizard/test_pause.py b/paths_cli/tests/wizard/test_pause.py index 2f09692d..b75ae27b 100644 --- a/paths_cli/tests/wizard/test_pause.py +++ b/paths_cli/tests/wizard/test_pause.py @@ -4,6 +4,11 @@ from paths_cli.wizard.pause import * +@pytest.fixture(autouse=True, scope="module") +def use_default_pause_style(): + with pause_style('default'): + yield + def test_get_pause_style(): default_style = PAUSE_STYLES['default'] diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 81e05be2..368c9244 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -9,13 +9,13 @@ volume_ask ) - import openpathsampling as paths from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV from openpathsampling.tests.test_helpers import make_1d_traj + def _wrap(x, period_min, period_max): # used in testing periodic CVs while x >= period_max: From 61b8da47b3cc7cc00e22ea7c075100feb59ff0bc Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 1 Nov 2021 20:01:18 +0100 Subject: [PATCH 204/251] Update paths_cli/wizard/pause.py Co-authored-by: Sander Roet --- paths_cli/wizard/pause.py | 1 - 1 file changed, 1 deletion(-) diff --git a/paths_cli/wizard/pause.py b/paths_cli/wizard/pause.py index 42ecb9b2..134e0772 100644 --- a/paths_cli/wizard/pause.py +++ b/paths_cli/wizard/pause.py @@ -32,7 +32,6 @@ def pause_style(style): def get_pause_style(): """Get the current pause style""" - global _PAUSE_STYLE return _PAUSE_STYLE From d11d7bc0afb25a1779eda1715582a376ee78a93f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 2 Nov 2021 08:15:48 -0400 Subject: [PATCH 205/251] Fix uncaught exception if user input to empty --- paths_cli/tests/wizard/test_wizard.py | 14 ++++++++++++++ paths_cli/wizard/wizard.py | 5 +++++ 2 files changed, 19 insertions(+) diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index e42948c3..c8d50671 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -81,6 +81,20 @@ def helper(result): assert result == 'foo' assert 'You said: helpme' in console.log_text + @pytest.mark.parametrize('autohelp', [True, False]) + def test_ask_empty(self, autohelp): + # if the use response in an empty string, we should repeat the + # question (possible giving autohelp). This fixes a regression where + # an empty string would cause an uncaught exception. + console = MockConsole(['', 'foo']) + self.wizard.console = console + result = self.wizard.ask("question", + helper=lambda x: "say_help", + autohelp=autohelp) + assert result == "foo" + if autohelp: + assert "say_help" in self.wizard.console.log_text + def _generic_speak_test(self, func_name): console = MockConsole() self.wizard.console = console diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index a41f084e..e93a8405 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -85,6 +85,11 @@ def ask(self, question, options=None, default=None, helper=None, helper = Helper(helper) result = self.console.input("🧙 " + question + " ") self.console.print() + if result == "": + if not autohelp: + return + result = "?" # autohelp in this case + if helper and result[0] in ["?", "!"]: self.say(helper(result)) return From 89bf2cbdb32c674a9bace925b544ceb6be13355b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 3 Nov 2021 11:57:46 -0400 Subject: [PATCH 206/251] Start to pylint/flake8 wizard cleanup --- paths_cli/wizard/core.py | 58 ++++++++++++++++++++++++++++++----- paths_cli/wizard/cvs.py | 60 ++++++++++++++++++++++++++++++------- paths_cli/wizard/engines.py | 3 -- 3 files changed, 100 insertions(+), 21 deletions(-) diff --git a/paths_cli/wizard/core.py b/paths_cli/wizard/core.py index a34eaa19..0afe1624 100644 --- a/paths_cli/wizard/core.py +++ b/paths_cli/wizard/core.py @@ -1,12 +1,17 @@ -import random -from paths_cli.wizard.tools import a_an - -from collections import namedtuple +def interpret_req(req): + """Create user-facing string representation of the input requirement. -WIZARD_STORE_NAMES = ['engines', 'cvs', 'states', 'networks', 'schemes'] -WizardSay = namedtuple("WizardSay", ['msg', 'mode']) + Parameters + ---------- + req : Tuple[..., int, int] + req[1] is the minimum number of objects to create; req[2] is the + maximum number of objects to create -def interpret_req(req): + Returns + ------- + str : + human-reading string for how many objects to create + """ _, min_, max_ = req string = "" if min_ == max_: @@ -23,7 +28,32 @@ def interpret_req(req): return string +# TODO: REFACTOR: It looks like get_missing_object may be redundant with +# other code for obtaining prerequisites for a function def get_missing_object(wizard, obj_dict, display_name, fallback_func): + """Get a prerequisite object. + + The ``obj_dict`` here is typically a mapping of objects known by the + Wizard. If it is empty, the ``fallback_func`` is used to create a new + object. If it has exactly 1 entry, that is used implicitly. If it has + more than 1 entry, the user must select which one to use. + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard for user interaction + obj_dict : Dict[str, Any] + mapping of object name to object + display_name: str + the user-facing name of this type of object + fallback_func: Callable[:class:`.Wizard`] -> Any + method to create a new object of this type + + Returns + ------- + Any : + the prerequisite object + """ if len(obj_dict) == 0: obj = fallback_func(wizard) elif len(obj_dict) == 1: @@ -37,10 +67,22 @@ def get_missing_object(wizard, obj_dict, display_name, fallback_func): def get_object(func): + """Decorator to wrap methods for obtaining objects from user input. + + This decorator implements the user interaction loop when dealing with a + single user input. The wrapped function is intended to create some + object. If the user's input cannot create a valid object, the wrapped + function should return None. + + Parameters + ---------- + func : Callable + object creation method to wrap; should return None on failure + """ + # TODO: use functools.wraps? def inner(*args, **kwargs): obj = None while obj is None: obj = func(*args, **kwargs) return obj return inner - diff --git a/paths_cli/wizard/cvs.py b/paths_cli/wizard/cvs.py index ce700fcc..5835e1df 100644 --- a/paths_cli/wizard/cvs.py +++ b/paths_cli/wizard/cvs.py @@ -1,3 +1,7 @@ +from functools import partial +from collections import namedtuple +import numpy as np + from paths_cli.compiling.tools import mdtraj_parse_atomlist from paths_cli.wizard.plugin_classes import ( LoadFromOPS, WizardObjectPlugin, WrapCategory @@ -5,10 +9,6 @@ from paths_cli.wizard.core import get_object import paths_cli.wizard.engines -from functools import partial -from collections import namedtuple -import numpy as np - from paths_cli.wizard.parameters import ( FromWizardPrerequisite ) @@ -27,6 +27,10 @@ "You should specify atom indices enclosed in double brackets, e.g. " "[{list_range_natoms}]" ) +_MDTrajParams = namedtuple("_MDTrajParams", ['period', 'n_atoms', + 'kwarg_name', 'cv_user_str']) +_MDTRAJ_INTRO = "We'll make a CV that measures the {user_str}." + # TODO: implement so the following can be the help string: # _ATOM_INDICES_HELP_STR = ( @@ -56,6 +60,25 @@ @get_object def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): + """Parameter loader for atom_indices parameters in MDTraj. + + Parameters + ---------- + wizard : :class:`.Wizard` + wizard for user interaction + topology : + topology (reserved for future use) + n_atoms : int + number of atoms to define this CV (i.e., 2 for a distance; 3 for an + angle; 4 for a dihedral) + cv_user_str : str + user-facing name for the CV being created + + Returns + ------- + :class`np.ndarray` : + array of indices for the MDTraj function + """ helper = Helper(_ATOM_INDICES_HELP_STR.format( list_range_natoms=list(range(n_atoms)) )) @@ -67,14 +90,14 @@ def _get_atom_indices(wizard, topology, n_atoms, cv_user_str): except Exception as e: wizard.exception(f"Sorry, I didn't understand '{atoms_str}'.", e) helper("?") - return + return None return arr -_MDTrajParams = namedtuple("_MDTrajParams", ['period', 'n_atoms', - 'kwarg_name', 'cv_user_str']) def _mdtraj_cv_builder(wizard, prereqs, func_name): + """General function to handle building MDTraj CVs. + """ from openpathsampling.experimental.storage.collective_variables import \ MDTrajFunctionCV dct = TOPOLOGY_CV_PREREQ(wizard) @@ -108,9 +131,9 @@ def _mdtraj_cv_builder(wizard, prereqs, func_name): return MDTrajFunctionCV(func, topology, period_min=period_min, period_max=period_max, **kwargs) -_MDTRAJ_INTRO = "We'll make a CV that measures the {user_str}." def _mdtraj_summary(wizard, context, result): + """Standard summary of MDTraj CVs: function, atom, topology""" cv = result func = cv.func topology = cv.topology @@ -121,6 +144,7 @@ def _mdtraj_summary(wizard, context, result): f" Topology: {repr(topology.mdtraj)}") return [summary] + if HAS_MDTRAJ: MDTRAJ_DISTANCE = WizardObjectPlugin( name='Distance', @@ -152,10 +176,24 @@ def _mdtraj_summary(wizard, context, result): "four atoms"), summary=_mdtraj_summary, ) - # TODO: add RMSD -- need to figure out how to select a frame + def coordinate(wizard, prereqs=None): + """Builder for coordinate CV. + + Parameters + ---------- + wizard : :class:`.Wizard` + wizard for user interaction + prereqs : + prerequisites (unused in this method) + + Return + ------ + CoordinateFunctionCV : + the OpenPathSampling CV for this selecting this coordinate + """ # TODO: atom_index should be from wizard.ask_custom_eval from openpathsampling.experimental.storage.collective_variables import \ CoordinateFunctionCV @@ -174,12 +212,13 @@ def coordinate(wizard, prereqs=None): f"atom {atom_index}?") try: coord = {'x': 0, 'y': 1, 'z': 2}[xyz] - except KeyError as e: + except KeyError: wizard.bad_input("Please select one of 'x', 'y', or 'z'") cv = CoordinateFunctionCV(lambda snap: snap.xyz[atom_index][coord]) return cv + COORDINATE_CV = WizardObjectPlugin( name="Coordinate", category="cv", @@ -202,6 +241,7 @@ def coordinate(wizard, prereqs=None): "you can also create your own and load it from a file.") ) + if __name__ == "__main__": # no-cov from paths_cli.wizard.run_module import run_category run_category('cv') diff --git a/paths_cli/wizard/engines.py b/paths_cli/wizard/engines.py index 2ae60b9c..6e60eead 100644 --- a/paths_cli/wizard/engines.py +++ b/paths_cli/wizard/engines.py @@ -1,6 +1,4 @@ -import paths_cli.wizard.openmm as openmm from paths_cli.wizard.plugin_classes import LoadFromOPS, WrapCategory -from functools import partial _ENGINE_HELP = "An engine describes how you'll do the actual dynamics." ENGINE_PLUGIN = WrapCategory( @@ -17,4 +15,3 @@ if __name__ == "__main__": # no-cov from paths_cli.wizard.run_module import run_category run_category('engine') - From 8ed2442e09f7af864df30fef573059105d2011a6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 3 Nov 2021 16:51:46 -0400 Subject: [PATCH 207/251] more pylint cleanup in the wizard --- paths_cli/tests/wizard/test_load_from_ops.py | 8 +----- paths_cli/wizard/errors.py | 25 ++++++++++++++--- paths_cli/wizard/helper.py | 11 ++++++-- paths_cli/wizard/joke.py | 7 +++++ paths_cli/wizard/load_from_ops.py | 28 +++++++++++++------- paths_cli/wizard/openmm.py | 28 +++++++++----------- paths_cli/wizard/parameters.py | 17 ++++++------ 7 files changed, 78 insertions(+), 46 deletions(-) diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index d94d7aa4..a5ce9675 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -6,7 +6,7 @@ from paths_cli.tests.wizard.mock_wizard import mock_wizard from paths_cli.wizard.load_from_ops import ( - named_objs_helper, _get_ops_storage, _get_ops_object, load_from_ops + _get_ops_storage, _get_ops_object, load_from_ops ) # for some reason I couldn't get these to work with MagicMock @@ -44,12 +44,6 @@ def ops_file_fixture(): storage = FakeStorage(foo) return storage -def test_named_objs_helper(ops_file_fixture): - helper_func = named_objs_helper(ops_file_fixture, 'foo') - result = helper_func('any') - assert "what I found" in result - assert "bar" in result - assert "baz" in result @pytest.mark.parametrize('with_failure', [False, True]) def test_get_ops_storage(tmpdir, with_failure): diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py index 2a212310..7e64d02b 100644 --- a/paths_cli/wizard/errors.py +++ b/paths_cli/wizard/errors.py @@ -1,23 +1,42 @@ class ImpossibleError(Exception): + """Error to throw for sections that should be unreachable code""" def __init__(self, msg=None): if msg is None: msg = "Something went really wrong. You should never see this." super().__init__(msg) + class RestartObjectException(BaseException): + """Exception to indicate the restart of an object. + + Raised when the user issues a command to cause an object restart. + """ pass + def not_installed(wizard, package, obj_type): + """Behavior when an integration is not installed. + + In actual practice, this calling code should ensure this doesn't get + used. However, we keep it around as a defensive practice. + + Parameters + ---------- + package : str + name of the missing package + obj_type : str + name of the object category that would have been created + """ retry = wizard.ask(f"Hey, it looks like you don't have {package} " "installed. Do you want to try a different " f"{obj_type}, or do you want to quit?", options=["[R]etry", "[Q]uit"]) if retry == 'r': raise RestartObjectException() - elif retry == 'q': + if retry == 'q': + # TODO: maybe raise QuitWizard instead? exit() - else: # no-cov - raise ImpossibleError() + raise ImpossibleError() FILE_LOADING_ERROR_MSG = ("Sorry, something went wrong when loading that " diff --git a/paths_cli/wizard/helper.py b/paths_cli/wizard/helper.py index aeae7aec..0cf67cac 100644 --- a/paths_cli/wizard/helper.py +++ b/paths_cli/wizard/helper.py @@ -1,24 +1,31 @@ +import sys from .errors import RestartObjectException class QuitWizard(BaseException): - pass + """Exception raised when user expresses desire to quit the wizard""" # the following command functions take cmd and ctx -- future commands might # use the full command text or the context internally. def raise_quit(cmd, ctx): + """Command function to quit the wizard (with option to save). + """ raise QuitWizard() def raise_restart(cmd, ctx): + """Command function to restart the current object. + """ raise RestartObjectException() def force_exit(cmd, ctx): + """Command function to force immediate exit. + """ print("Exiting...") - exit() + sys.exit() HELPER_COMMANDS = { diff --git a/paths_cli/wizard/joke.py b/paths_cli/wizard/joke.py index 10229b1d..051ffbba 100644 --- a/paths_cli/wizard/joke.py +++ b/paths_cli/wizard/joke.py @@ -16,31 +16,38 @@ "It would also be a good name for a death metal band.", ] + def _joke1(name, obj_type): # no-cov return (f"I probably would have named it something like " f"'{random.choice(_NAMES)}'.") + def _joke2(name, obj_type): # no-cov thing = random.choice(_THINGS) joke = (f"I had {a_an(thing)} {thing} named '{name}' " f"when I was young.") return joke + def _joke3(name, obj_type): # no-cov return (f"I wanted to name my {random.choice(_SPAWN)} '{name}', but my " f"wife wouldn't let me.") + def _joke4(name, obj_type): # no-cov a_an_thing = a_an(obj_type) + f" {obj_type}" return random.choice(_MISC).format(name=name, obj_type=obj_type, a_an_thing=a_an_thing) + def name_joke(name, obj_type): # no-cov + """Make a joke about the naming process.""" jokes = [_joke1, _joke2, _joke3, _joke4] weights = [5, 5, 3, 7] joke = random.choices(jokes, weights=weights)[0] return joke(name, obj_type) + if __name__ == "__main__": # no-cov for _ in range(5): print() diff --git a/paths_cli/wizard/load_from_ops.py b/paths_cli/wizard/load_from_ops.py index fdeb5dec..0d91b448 100644 --- a/paths_cli/wizard/load_from_ops.py +++ b/paths_cli/wizard/load_from_ops.py @@ -5,16 +5,6 @@ LABEL = "Load existing from OPS file" -def named_objs_helper(storage, store_name): - def list_items(user_input, context=None): - store = getattr(storage, store_name) - names = [obj for obj in store if obj.is_named] - outstr = "\n".join(['* ' + obj.name for obj in names]) - return f"Here's what I found:\n\n{outstr}" - - return list_items - - @get_object def _get_ops_storage(wizard): filename = wizard.ask("What file can it be found in?", @@ -40,6 +30,24 @@ def _get_ops_object(wizard, storage, store_name, obj_name): def load_from_ops(wizard, store_name, obj_name): + """Load an object from an OPS file + + Parameters + ---------- + wizard : :class:`.Wizard` + the wizard for user interaction + store_name : str + name of the store where this object will be found + obj_name : str + name of the object to load + + Returns + ------- + Any : + the object loaded from the file + """ + # TODO: this might be replaced by something in compiling to load from + # files wizard.say("Okay, we'll load it from an OPS file.") storage = _get_ops_storage(wizard) obj = _get_ops_object(wizard, storage, store_name, obj_name) diff --git a/paths_cli/wizard/openmm.py b/paths_cli/wizard/openmm.py index 419391da..7b4a947f 100644 --- a/paths_cli/wizard/openmm.py +++ b/paths_cli/wizard/openmm.py @@ -1,21 +1,17 @@ -from paths_cli.wizard.errors import FILE_LOADING_ERROR_MSG, not_installed -from paths_cli.wizard.core import get_object - -# should be able to simplify this try block when we drop OpenMM < 7.6 -from paths_cli.compat.openmm import mm, HAS_OPENMM - -OPENMM_SERIALIZATION_URL=( - "http://docs.openmm.org/latest/api-python/generated/" - "openmm.openmm.XmlSerializer.html" -) +from paths_cli.compat.openmm import HAS_OPENMM from paths_cli.wizard.parameters import ProxyParameter from paths_cli.wizard.plugin_classes import WizardParameterObjectPlugin from paths_cli.compiling.engines import OPENMM_PLUGIN as OPENMM_COMPILING -_where_is_xml = "Where is the XML file for your OpenMM {obj_type}?" -_xml_help = ( +OPENMM_SERIALIZATION_URL = ( + "http://docs.openmm.org/latest/api-python/generated/" + "openmm.openmm.XmlSerializer.html" +) + +_WHERE_IS_XML = "Where is the XML file for your OpenMM {obj_type}?" +_XML_HELP = ( "You can write OpenMM objects like systems and integrators to XML " "files using the XMLSerializer class. Learn more here:\n" + OPENMM_SERIALIZATION_URL @@ -43,13 +39,13 @@ ), ProxyParameter( name='integrator', - ask=_where_is_xml.format(obj_type='integrator'), - helper=_xml_help, + ask=_WHERE_IS_XML.format(obj_type='integrator'), + helper=_XML_HELP, ), ProxyParameter( name='system', - ask=_where_is_xml.format(obj_type="system"), - helper=_xml_help, + ask=_WHERE_IS_XML.format(obj_type="system"), + helper=_XML_HELP, ), ProxyParameter( name='n_steps_per_frame', diff --git a/paths_cli/wizard/parameters.py b/paths_cli/wizard/parameters.py index c157fa87..117d8966 100644 --- a/paths_cli/wizard/parameters.py +++ b/paths_cli/wizard/parameters.py @@ -1,8 +1,5 @@ -from paths_cli.compiling.tools import custom_eval -import importlib from collections import namedtuple -from paths_cli.wizard.helper import Helper from paths_cli.compiling.root_compiler import _CategoryCompilerProxy from paths_cli.wizard.standard_categories import get_category_info @@ -16,6 +13,7 @@ defaults=[None, NO_DEFAULT, False, None] ) + class WizardParameter: """Load a single parameter from the wizard. @@ -47,7 +45,7 @@ def __init__(self, name, ask, loader, *, helper=None, default=NO_DEFAULT, self.default = default self.autohelp = autohelp if summarize is None: - summarize = lambda obj: str(obj) + summarize = str self.summarize = summarize @classmethod @@ -66,15 +64,17 @@ def from_proxy(cls, proxy, compiler_plugin): loader = loader_dict[proxy.name] if isinstance(loader, _CategoryCompilerProxy): # TODO: can this import now be moved to top of file? - from paths_cli.wizard.plugin_registration import get_category_wizard + from paths_cli.wizard.plugin_registration import \ + get_category_wizard category = loader.category dct['loader'] = get_category_wizard(category) dct['ask'] = get_category_info(category).singular dct['store_name'] = get_category_info(category).storage - cls = _ExistingObjectParameter + cls_ = _ExistingObjectParameter else: + cls_ = cls dct['loader'] = loader - return cls(**dct) + return cls_(**dct) def __call__(self, wizard, context): """Load the parameter. @@ -268,7 +268,8 @@ def __call__(self, wizard, context=None): if n_existing == self.n_required: # early return in this case (return silently) return {self.name: self._get_existing(wizard)} - elif n_existing > self.n_required: + + if n_existing > self.n_required: sel = [self._select_single_existing(wizard) for _ in range(self.n_required)] dct = {self.name: sel} From 005aeab3604fb4637269a676949fec6e85d87fa4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Wed, 3 Nov 2021 17:12:12 -0400 Subject: [PATCH 208/251] more pylint cleanup for the wizard main thing left is, I think, wizard.py --- paths_cli/wizard/plugin_classes.py | 9 ++++++--- paths_cli/wizard/plugin_registration.py | 11 ++++++----- paths_cli/wizard/run_module.py | 1 + paths_cli/wizard/shooting.py | 17 ++++++++++------- paths_cli/wizard/standard_categories.py | 1 + paths_cli/wizard/steps.py | 5 ----- paths_cli/wizard/tools.py | 1 + paths_cli/wizard/tps.py | 3 +-- paths_cli/wizard/two_state_tps.py | 3 ++- 9 files changed, 28 insertions(+), 23 deletions(-) diff --git a/paths_cli/wizard/plugin_classes.py b/paths_cli/wizard/plugin_classes.py index 7031db00..cf0fa35c 100644 --- a/paths_cli/wizard/plugin_classes.py +++ b/paths_cli/wizard/plugin_classes.py @@ -7,7 +7,8 @@ class WizardObjectPluginRegistrationError(Exception): - pass + """Error during wizard object plugin registration. + """ _PLUGIN_DOCSTRING = """ @@ -30,6 +31,7 @@ class WizardObjectPluginRegistrationError(Exception): created """ + _PLUGIN_DOCSTRING + class LoadFromOPS(OPSPlugin): """Wizard plugin type to load from an existing OPS file. @@ -62,6 +64,7 @@ def __init__(self, category, *, obj_name=None, store_name=None, def __call__(self, wizard, context=None): return load_from_ops(wizard, self.store_name, self.obj_name) + def get_text_from_context(name, instance, default, wizard, context, *args, **kwargs): """Generic method for getting text from context of other sources. @@ -130,6 +133,7 @@ def __init__(self, name, category, builder, *, prerequisite=None, self._summary = summary # func to summarize def default_summarize(self, wizard, context, result): + """Default summary function""" return [f"Here's what we'll make:\n {str(result)}"] def get_summary(self, wizard, context, result): @@ -354,8 +358,7 @@ def _set_context(self, wizard, context, selected): """If user has provided a funtion to create context, use it.""" if self._user_set_context: return self._user_set_context(wizard, context, selected) - else: - return context + return context def register_plugin(self, plugin): """Register a :class:`.WizardObjectPlugin` with this category. diff --git a/paths_cli/wizard/plugin_registration.py b/paths_cli/wizard/plugin_registration.py index 6d2f4067..051cbff0 100644 --- a/paths_cli/wizard/plugin_registration.py +++ b/paths_cli/wizard/plugin_registration.py @@ -1,15 +1,15 @@ +import logging from paths_cli.wizard.plugin_classes import ( LoadFromOPS, WizardObjectPlugin, WrapCategory ) from paths_cli.utils import get_installed_plugins from paths_cli.plugin_management import NamespacePluginLoader -import logging logger = logging.getLogger(__name__) class CategoryWizardPluginRegistrationError(Exception): - pass + """Error with wizard category plugin registration fails""" _CATEGORY_PLUGINS = {} @@ -70,15 +70,16 @@ def register_plugins(plugins): object_plugins.append(plugin) for plugin in categories: - logger.debug("Registering " + str(plugin)) + logger.debug("Registering %s", str(plugin)) _register_category_plugin(plugin) for plugin in object_plugins: category = _CATEGORY_PLUGINS[plugin.category] - logger.debug("Registering " + str(plugin)) - logger.debug("Category: " + str(category)) + logger.debug("Registering %s", str(plugin)) + logger.debug("Category: %s", str(category)) category.register_plugin(plugin) + def register_installed_plugins(): """Register all Wizard plugins found in standard locations. diff --git a/paths_cli/wizard/run_module.py b/paths_cli/wizard/run_module.py index a6cca01c..3dae5b3a 100644 --- a/paths_cli/wizard/run_module.py +++ b/paths_cli/wizard/run_module.py @@ -2,6 +2,7 @@ from paths_cli.wizard.wizard import Wizard from paths_cli.wizard.plugin_registration import get_category_wizard + # TODO: for now we ignore this for coverage -- it's mainly used for UX # testing by allowing each module to be run with, e.g.: # python -m paths_cli.wizard.engines diff --git a/paths_cli/wizard/shooting.py b/paths_cli/wizard/shooting.py index a333b6fd..633742c0 100644 --- a/paths_cli/wizard/shooting.py +++ b/paths_cli/wizard/shooting.py @@ -2,18 +2,19 @@ from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.plugin_registration import get_category_wizard -from paths_cli.compiling.tools import custom_eval -engines = get_category_wizard('engine') -import numpy as np +engines = get_category_wizard('engine') def uniform_selector(wizard): + """Create a uniform selector (using the wizard)""" import openpathsampling as paths return paths.UniformSelector() + def gaussian_selector(wizard): + """Create a Gaussian biased selector (using the wizard)""" import openpathsampling as paths cv_name = wizard.ask_enumerate("Which CV do you want the Gaussian to " "be based on?", @@ -44,6 +45,7 @@ def gaussian_selector(wizard): # return allowed + SHOOTING_SELECTORS = { 'Uniform random': uniform_selector, 'Gaussian bias': gaussian_selector, @@ -59,6 +61,7 @@ def _get_selector(wizard, selectors=None): selector = selectors[sel](wizard) return selector + def one_way_shooting(wizard, selectors=None, engine=None): from openpathsampling import strategies if engine is None: @@ -73,6 +76,7 @@ def one_way_shooting(wizard, selectors=None, engine=None): # def two_way_shooting(wizard, selectors=None, modifiers=None): # pass + def spring_shooting(wizard, engine=None): import openpathsampling as paths if engine is None: @@ -109,10 +113,10 @@ def shooting(wizard, shooting_types=None, engine=None): # allowed_modifiers = get_allowed_modifiers(engine) # TWO_WAY = 'Two-way shooting' # if len(allowed_modifiers) == 0 and TWO_WAY in shooting_types: - # del shooting_types[TWO_WAY] + # del shooting_types[TWO_WAY] # else: - # shooting_types[TWO_WAY] = partial(two_way_shooting, - # allowed_modifiers=allowed_modifiers) + # shooting_types[TWO_WAY] = partial(two_way_shooting, + # allowed_modifiers=allowed_modifiers) shooting_type = None if len(shooting_types) == 1: @@ -126,4 +130,3 @@ def shooting(wizard, shooting_types=None, engine=None): shooting_strategy = shooting_type(wizard) return shooting_strategy - diff --git a/paths_cli/wizard/standard_categories.py b/paths_cli/wizard/standard_categories.py index e8d8f1a7..08617b88 100644 --- a/paths_cli/wizard/standard_categories.py +++ b/paths_cli/wizard/standard_categories.py @@ -23,6 +23,7 @@ CATEGORIES = {cat.name: cat for cat in _CATEGORY_LIST} + def get_category_info(category): """Obtain info for a stanard (or registered) category. diff --git a/paths_cli/wizard/steps.py b/paths_cli/wizard/steps.py index b06b66c1..9e949066 100644 --- a/paths_cli/wizard/steps.py +++ b/paths_cli/wizard/steps.py @@ -1,8 +1,5 @@ from collections import namedtuple -from functools import partial - from paths_cli.wizard.plugin_registration import get_category_wizard -from paths_cli.wizard.tps import tps_scheme volumes = get_category_wizard('volume') @@ -26,5 +23,3 @@ store_name="states", minimum=2, maximum=float('inf')) - - diff --git a/paths_cli/wizard/tools.py b/paths_cli/wizard/tools.py index 058965fa..4b0d5fea 100644 --- a/paths_cli/wizard/tools.py +++ b/paths_cli/wizard/tools.py @@ -1,5 +1,6 @@ def a_an(obj): return "an" if obj[0].lower() in "aeiou" else "a" + def yes_no(char): return {'yes': True, 'no': False, 'y': True, 'n': False}[char.lower()] diff --git a/paths_cli/wizard/tps.py b/paths_cli/wizard/tps.py index 00830e21..bdbd6854 100644 --- a/paths_cli/wizard/tps.py +++ b/paths_cli/wizard/tps.py @@ -1,11 +1,10 @@ -from paths_cli.wizard.tools import a_an from paths_cli.wizard.core import get_missing_object from paths_cli.wizard.shooting import shooting from paths_cli.wizard.plugin_registration import get_category_wizard -from functools import partial volumes = get_category_wizard('volume') + def tps_network(wizard): raise NotImplementedError("Still need to add other network choices") diff --git a/paths_cli/wizard/two_state_tps.py b/paths_cli/wizard/two_state_tps.py index 1509488a..fe3a6418 100644 --- a/paths_cli/wizard/two_state_tps.py +++ b/paths_cli/wizard/two_state_tps.py @@ -5,9 +5,10 @@ ) from paths_cli.wizard.wizard import Wizard from paths_cli.wizard import pause +from paths_cli.wizard.volumes import _FIRST_STATE, _VOL_DESC volumes = get_category_wizard('volume') -from paths_cli.wizard.volumes import _FIRST_STATE, _VOL_DESC + def two_state_tps(wizard, fixed_length=False): import openpathsampling as paths From bc32c3cad1340a6983d76ea89993f357d954baff Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Nov 2021 09:51:21 -0400 Subject: [PATCH 209/251] docstrings/pylint for wizard.py --- paths_cli/wizard/wizard.py | 180 +++++++++++++++++++++++++++++++++---- 1 file changed, 161 insertions(+), 19 deletions(-) diff --git a/paths_cli/wizard/wizard.py b/paths_cli/wizard/wizard.py index d88fcd5a..1e55b62c 100644 --- a/paths_cli/wizard/wizard.py +++ b/paths_cli/wizard/wizard.py @@ -1,6 +1,5 @@ -import random +import shutil import os -from functools import partial import textwrap from paths_cli.wizard.tools import yes_no, a_an @@ -14,24 +13,62 @@ from paths_cli.wizard import pause -import shutil class Console: # no-cov + """Manage actual I/O for the Wizard. + + All direct interaction with the user is performed in this class. + """ # TODO: add logging so we can output the session def print(self, *content): + """Write content to screen""" print(*content) def input(self, content): + """Read user input. + + Parameters + ---------- + content : str + input prompt + """ return input(content) @property def width(self): + """Terminal width in columns""" return shutil.get_terminal_size((80, 24)).columns - def draw_hline(self): - self.print('═' * self.width) + def draw_hline(self, char='═'): + """Draw a separator line. + + Parameters + ---------- + char : str + string to use for line separator + """ + n_chars = self.width // len(char) + self.print(char * n_chars) + class Wizard: + """Friendly interactive Wizard + + This class handles most of the user-facing interaction with the wizard, + including various conveniences for asking for certain user selections + (such as selecting between a number of possible choices.) + + An instance of this class includes information about how the Wizard will + guide the user through various stages of simulation set-up. + + The main method, ``Wizard.run_wizard()``, performs an entire simulation + set-up for that instance. + + Parameters + ---------- + steps : List[:class:`.WizardStep`] + ordered list of steps in this particular simulation set-up process + """ def __init__(self, steps): self.steps = steps self.requirements = { @@ -60,7 +97,8 @@ def _patch(self): # no-cov self._patched = True StorageLoader.has_simstore_patch = True - def debug(content): # no-cov + def debug(self, content): # no-cov + """Print to console without pretty-printing""" # debug does no pretty-printing self.console.print(content) @@ -84,6 +122,7 @@ def _speak(self, content, preface): @get_object def ask(self, question, options=None, default=None, helper=None, autohelp=False): + """Ask the user a question.""" if helper is None: helper = Helper(None) if isinstance(helper, str): @@ -92,30 +131,60 @@ def ask(self, question, options=None, default=None, helper=None, self.console.print() if result == "": if not autohelp: - return + return None result = "?" # autohelp in this case if helper and result[0] in ["?", "!"]: self.say(helper(result)) - return + return None return result def say(self, content, preface="🧙 "): + """Let the wizard make a statement. + + Parameters + ---------- + content : str or List[str] + Content to be presented to user. Input will be wrapped to fit + the user's terminal. If a list of strings, each element is + printed with a blank line separating them. + preface : str + preface, used only on the first line of the first element of the + ``content``. + """ self._speak(content, preface) self.console.print() # adds a blank line def start(self, content): + """Specialized version of :method:`.say` for starting an object""" # eventually, this will tweak so that we preface with a line and use - # green text here + # green text here TODO: possibly remove? self.say(content) def bad_input(self, content, preface="👺 "): + """Specialized version of :method:`.say` for printing errors""" # just changes the default preface; maybe print 1st line red? self.say(content, preface) @get_object def ask_enumerate_dict(self, question, options, helper=None, autohelp=False): + """Ask the user to select from a set of options. + + Parameters + ---------- + question : str + the question to ask the user (asked before the options are + listed) + options: Dict[str, Any] + mapping of the string name (shown to the user in the list of + options) to the object to return + + Returns + ------- + Any : + the object the user selected by either name or number + """ self.say(question) opt_string = "\n".join([f" {(i+1):>3}. {opt}" for i, opt in enumerate(options)]) @@ -138,6 +207,8 @@ def ask_enumerate_dict(self, question, options, helper=None, def ask_enumerate(self, question, options): """Ask the user to select from a list of options""" + # NOTE: new code should use ask_enumerate_dict. If we were past the + # beta stage, this would probably issue a PendingDeprecationWarning self.say(question) opt_string = "\n".join([f" {(i+1):>3}. {opt}" for i, opt in enumerate(options)]) @@ -161,19 +232,36 @@ def ask_enumerate(self, question, options): @get_object def ask_load(self, question, loader, helper=None, autohelp=False): + """Load from user input according to ``loader`` method + + Parameters + ---------- + question : str + string to ask the user + loader : Callable[str] -> Any + method that converts user input into the desired format for the + object + helper : :class:`.Helper` + object to handle user requests for help + """ as_str = self.ask(question, helper=helper) try: result = loader(as_str) except Exception as e: self.exception(f"Sorry, I couldn't understand the input " f"'{as_str}'.", e) - return + return None return result - # this should match the args for wizard.ask @get_object def ask_custom_eval(self, question, options=None, default=None, helper=None, type_=float): + """Get user input and convert using custom_eval. + + .. note:: + New code should use ask_load. If we were past beta, this would + have a PendingDeprecationWarning. + """ as_str = self.ask(question, options=options, default=default, helper=helper) try: @@ -181,11 +269,14 @@ def ask_custom_eval(self, question, options=None, default=None, except Exception as e: self.exception(f"Sorry, I couldn't understand the input " f"'{as_str}'", e) - return + return None return result - def obj_selector(self, store_name, text_name, create_func): + """Select an object from the wizard's pseudo-storage + """ + # TODO: this seems like something that might be possible to refactor + # out opts = {name: lambda wiz, o=obj: o for name, obj in getattr(self, store_name).items()} create_new = f"Create a new {text_name}" @@ -198,10 +289,27 @@ def obj_selector(self, store_name, text_name, create_func): return obj def exception(self, msg, exception): + """Specialized version of :method:`.bad_input` for exceptions""" self.bad_input(f"{msg}\nHere's the error I got:\n" f"{exception.__class__.__name__}: {exception}") - def name(self, obj, obj_type, store_name, default=None): + def name(self, obj, obj_type, store_name): + """Name a newly created object. + + Parameters + ---------- + obj : Any + the new object + obj_type : str + user-facing name of the object type + store_name : str + name of the OPS store in which to save this object type + + Returns + ------- + Any : + named object + """ self.say(f"Now let's name your {obj_type}.") name = None while name is None: @@ -220,8 +328,23 @@ def name(self, obj, obj_type, store_name, default=None): + name_joke(name, obj_type)) return obj - def register(self, obj, obj_type, store_name): + """Register a newly-created object in the storage + + Parameters + ---------- + obj : Any + the new object + obj_type : str + user-facing name of the object type + store_name : str + name of the OPS store in which to save this object type + + Returns + ------- + Any : + input object, possibly after being named + """ if not obj.is_named: obj = self.name(obj, obj_type, store_name) store_dict = getattr(self, store_name) @@ -230,31 +353,38 @@ def register(self, obj, obj_type, store_name): @get_object def get_storage(self): + """Create a file to store the object database to. + + Returns + ------- + :class:`openpathsampling.experimental.storage.Storage` : + the storage file object + """ from openpathsampling.experimental.storage import Storage filename = self.ask("Where would you like to save your setup " "database?") if not filename.endswith(".db"): self.bad_input("Files produced by this wizard must end in " "'.db'.") - return + return None if os.path.exists(filename): overwrite = self.ask(f"{filename} exists. Overwrite it?", options=["[Y]es", "[N]o"]) overwrite = yes_no(overwrite) if not overwrite: - return + return None try: storage = Storage(filename, mode='w') except Exception as e: self.exception(FILE_LOADING_ERROR_MSG, e) - return + return None return storage - def _storage_description_line(self, store_name): + """List number of each type of object to be stored""" store = getattr(self, store_name) if len(store) == 0: return "" @@ -266,6 +396,13 @@ def _storage_description_line(self, store_name): return line def save_to_file(self, storage): + """Save Wizard's pseudostorage to a real file. + + Parameters + ---------- + storage : :class:`openpathsampling.experimental.storage.Storage` + storage file to save to + """ store_names = ['engines', 'cvs', 'states', 'networks', 'schemes'] lines = [self._storage_description_line(store_name) for store_name in store_names] @@ -280,6 +417,7 @@ def save_to_file(self, storage): self.say("Success! Everything has been stored in your file.") def _req_do_another(self, req): + """Check whether requirement requires us to do another""" store, min_, max_ = req if store is None: return (True, False) @@ -290,6 +428,7 @@ def _req_do_another(self, req): return requires, allows def _ask_do_another(self, obj_type): + """Ask the user whether to do another""" do_another = None while do_another is None: do_another_char = self.ask( @@ -303,6 +442,7 @@ def _ask_do_another(self, obj_type): return do_another def _do_one(self, step, req): + """Create a single object of the sort generated by ``step``""" try: obj = step.func(self) except RestartObjectException: @@ -320,6 +460,7 @@ def _do_one(self, step, req): return do_another def run_wizard(self): + """Run this Wizard.""" self.start("Hi! I'm the OpenPathSampling Wizard.") # TODO: next line is only temporary self.say("Today I'll help you set up a 2-state TPS simulation.") @@ -343,6 +484,7 @@ def run_wizard(self): @get_object def _ask_save(self): + """Ask user whether to save before quitting""" do_save_char = self.ask("Before quitting, would you like to save " "the objects you've created so far?") try: From 445cc61a54054b3de580f4fa8885acbbb63bece2 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Nov 2021 10:21:43 -0400 Subject: [PATCH 210/251] no-cov on ImpossibleError --- paths_cli/wizard/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py index 7e64d02b..9e9e4fd6 100644 --- a/paths_cli/wizard/errors.py +++ b/paths_cli/wizard/errors.py @@ -36,7 +36,7 @@ def not_installed(wizard, package, obj_type): if retry == 'q': # TODO: maybe raise QuitWizard instead? exit() - raise ImpossibleError() + raise ImpossibleError() # -no-cov- FILE_LOADING_ERROR_MSG = ("Sorry, something went wrong when loading that " From 9fe98010402ec3a8fa3213db383488d597c2e5e4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Nov 2021 15:05:08 -0400 Subject: [PATCH 211/251] json types for most compiler plugins --- .../compiling/_gendocs/json_type_handlers.py | 4 +++ paths_cli/compiling/cvs.py | 4 +++ paths_cli/compiling/engines.py | 3 ++ paths_cli/compiling/json_type.py | 7 +++++ paths_cli/compiling/networks.py | 20 +++++++++--- paths_cli/compiling/schemes.py | 31 ++++++++++++++++--- paths_cli/compiling/strategies.py | 14 +++++++-- paths_cli/compiling/volumes.py | 24 ++++++++------ 8 files changed, 86 insertions(+), 21 deletions(-) diff --git a/paths_cli/compiling/_gendocs/json_type_handlers.py b/paths_cli/compiling/_gendocs/json_type_handlers.py index d918807c..8275a0b9 100644 --- a/paths_cli/compiling/_gendocs/json_type_handlers.py +++ b/paths_cli/compiling/_gendocs/json_type_handlers.py @@ -133,7 +133,11 @@ def __init__(self, type_name, link_to=None): CategoryHandler("engine"), CategoryHandler("cv"), CategoryHandler("volume"), + CategoryHandler("network"), + CategoryHandler("strategy"), + CategoryHandler("scheme"), EvalHandler("EvalInt"), + EvalHandler("EvalIntStrictPos"), EvalHandler("EvalFloat"), ] diff --git a/paths_cli/compiling/cvs.py b/paths_cli/compiling/cvs.py index 5b46da98..6737afc5 100644 --- a/paths_cli/compiling/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -4,6 +4,7 @@ from paths_cli.compiling.errors import InputError from paths_cli.utils import import_thing from paths_cli.compiling.plugins import CVCompilerPlugin, CategoryPlugin +from paths_cli.compiling.json_type import json_type_eval class AllowedPackageHandler: @@ -40,13 +41,16 @@ def _cv_kwargs_remapper(dct): json_type='object', default=None, description="keyword arguments for ``func``"), Parameter('period_min', custom_eval, default=None, + json_type=json_type_eval('Float'), description=("minimum value for a periodic function, " "None if not periodic")), Parameter('period_max', custom_eval, default=None, + json_type=json_type_eval('Float'), description=("maximum value for a periodic function, " "None if not periodic")), ], + description="Use an MDTraj analysis function to calculate a CV.", name="mdtraj" ) diff --git a/paths_cli/compiling/engines.py b/paths_cli/compiling/engines.py index 446f5cb8..41f72680 100644 --- a/paths_cli/compiling/engines.py +++ b/paths_cli/compiling/engines.py @@ -3,6 +3,7 @@ from paths_cli.compiling.core import Parameter from paths_cli.compiling.tools import custom_eval_int_strict_pos from paths_cli.compiling.plugins import EngineCompilerPlugin, CategoryPlugin +from paths_cli.compiling.json_type import json_type_eval def load_openmm_xml(filename): @@ -34,8 +35,10 @@ def _openmm_options(dct): Parameter('integrator', load_openmm_xml, json_type='string', description="XML file with the OpenMM integrator"), Parameter('n_steps_per_frame', custom_eval_int_strict_pos, + json_type=json_type_eval('IntStrictPos'), description="number of MD steps per saved frame"), Parameter("n_frames_max", custom_eval_int_strict_pos, + json_type=json_type_eval('IntStrictPos'), description=("maximum number of frames before aborting " "trajectory")), ] diff --git a/paths_cli/compiling/json_type.py b/paths_cli/compiling/json_type.py index 1a02f67a..99ff1e22 100644 --- a/paths_cli/compiling/json_type.py +++ b/paths_cli/compiling/json_type.py @@ -1,3 +1,10 @@ def json_type_ref(category): return {"$ref": f"#/definitions/{category}_type"} + +def json_type_eval(check_type): + return {"$ref": f"#/definitions/Eval{check_type}"} + +def json_type_list(item_type): + return {'type': 'array', + 'items': item_type} diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index 7e7c87a6..8b7b185c 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -4,14 +4,24 @@ from paths_cli.compiling.tools import custom_eval from paths_cli.compiling.plugins import NetworkCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for +from paths_cli.compiling.json_type import ( + json_type_ref, json_type_list, json_type_eval +) build_interface_set = InstanceCompilerPlugin( builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ - Parameter('cv', compiler_for('cv'), description="the collective " - "variable for this interface set"), - Parameter('minvals', custom_eval), # TODO fill in JSON types - Parameter('maxvals', custom_eval), # TODO fill in JSON types + Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), + description=("the collective variable for this interface " + "set")), + Parameter('minvals', custom_eval, + json_type=json_type_list(json_type_eval("Float")), + description=("minimum value(s) for interfaces in this" + "interface set")), + Parameter('maxvals', custom_eval, + json_type=json_type_list(json_type_eval("Float")), + description=("maximum value(s) for interfaces in this" + "interface set")), ], name='interface-set' ) @@ -49,8 +59,10 @@ def tis_trans_info(dct): builder=Builder('openpathsampling.TPSNetwork'), parameters=[ Parameter('initial_states', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), description="initial states for this transition"), Parameter('final_states', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), description="final states for this transition") ], name='tps' diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index 518d039f..b1813649 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -1,17 +1,32 @@ from paths_cli.compiling.core import ( Builder, Parameter ) -from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.tools import ( + custom_eval, custom_eval_int_strict_pos +) from paths_cli.compiling.strategies import SP_SELECTOR_PARAMETER from paths_cli.compiling.plugins import SchemeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for +from paths_cli.compiling.json_type import ( + json_type_ref, json_type_list, json_type_eval +) -NETWORK_PARAMETER = Parameter('network', compiler_for('network')) +NETWORK_PARAMETER = Parameter( + 'network', + compiler_for('network'), + json_type=json_type_ref('network'), + description="network to use with this scheme" +) -ENGINE_PARAMETER = Parameter('engine', compiler_for('engine')) # reuse? +ENGINE_PARAMETER = Parameter( + 'engine', compiler_for('engine'), + json_type=json_type_ref('engine'), + description="engine to use with this scheme", +) # reuse? STRATEGIES_PARAMETER = Parameter('strategies', compiler_for('strategy'), + json_type=json_type_ref('strategy'), default=None) @@ -19,8 +34,14 @@ builder=Builder('openpathsampling.SpringShootingMoveScheme'), parameters=[ NETWORK_PARAMETER, - Parameter('k_spring', custom_eval), - Parameter('delta_max', custom_eval), + Parameter('k_spring', custom_eval, + json_type=json_type_eval("Float"), + description="spring constant for the spring shooting move"), + Parameter('delta_max', custom_eval_int_strict_pos, + json_type=json_type_eval("IntStrictPos"), + description=("maximum shift in shooting point (number of " + "frames)"), + ), ENGINE_PARAMETER ], name='spring-shooting', diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 0647f1d5..5ecf66f4 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -12,7 +12,7 @@ def _strategy_name(class_name): def _group_parameter(group_name): - return Parameter('group', str, default=group_name, + return Parameter('group', str, json_type="string", default=group_name, description="the group name for these movers") @@ -29,8 +29,16 @@ def _group_parameter(group_name): REPEX_GROUP_PARAMETER = _group_parameter('repex') MINUS_GROUP_PARAMETER = _group_parameter('minus') -REPLACE_TRUE_PARAMETER = Parameter('replace', bool, default=True) -REPLACE_FALSE_PARAMETER = Parameter('replace', bool, default=False) +REPLACE_TRUE_PARAMETER = Parameter( + 'replace', bool, json_type="bool", default=True, + description=("whether this should replace existing objects (default " + "True)") +) +REPLACE_FALSE_PARAMETER = Parameter( + 'replace', bool, json_type="bool", default=False, + description=("whether this should replace existing objects (default " + "False)") +) ONE_WAY_SHOOTING_STRATEGY_PLUGIN = StrategyCompilerPlugin( diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index d80af9dd..d4f45d56 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -5,7 +5,9 @@ from paths_cli.compiling.tools import custom_eval from paths_cli.compiling.plugins import VolumeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for -from paths_cli.compiling.json_type import json_type_ref +from paths_cli.compiling.json_type import ( + json_type_ref, json_type_eval, json_type_list +) # TODO: extra function for volumes should not be necessary as of OPS 2.0 @@ -29,30 +31,31 @@ def cv_volume_build_func(**dct): Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), description="CV that defines this volume"), Parameter('lambda_min', custom_eval, + json_type=json_type_eval("Float"), description="Lower bound for this volume"), Parameter('lambda_max', custom_eval, + json_type=json_type_eval("Float"), description="Upper bound for this volume") ], + description=("A volume defined by an allowed range of values for the " + "given CV."), name='cv-volume', ) build_cv_volume = CV_VOLUME_PLUGIN -# jsonschema type for combination volumes -VOL_ARRAY_TYPE = { - 'type': 'array', - 'items': json_type_ref('volume'), -} - INTERSECTION_VOLUME_PLUGIN = VolumeCompilerPlugin( builder=lambda subvolumes: functools.reduce(operator.__and__, subvolumes), parameters=[ Parameter('subvolumes', compiler_for('volume'), - json_type=VOL_ARRAY_TYPE, + json_type=json_type_list(json_type_ref('volume')), description="List of the volumes to intersect") ], + description=("A volume determined by the intersection of its " + "constituent volumes; i.e., to be in this volume a point " + "must be in *all* the constituent volumes."), name='intersection', ) @@ -64,9 +67,12 @@ def cv_volume_build_func(**dct): subvolumes), parameters=[ Parameter('subvolumes', compiler_for('volume'), - json_type=VOL_ARRAY_TYPE, + json_type=json_type_list(json_type_ref('volume')), description="List of the volumes to join into a union") ], + description=("A volume defined by the union of its constituent " + "volumes; i.e., a point that is in *any* of the " + "constituent volumes is also in this volume."), name='union', ) From 3fb15715f7a2e95e4ee0e04e047e6a9aafd92305 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 4 Nov 2021 15:06:38 -0400 Subject: [PATCH 212/251] lowercase retry/quit in missing-integration --- paths_cli/wizard/errors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/wizard/errors.py b/paths_cli/wizard/errors.py index 9e9e4fd6..d87c693b 100644 --- a/paths_cli/wizard/errors.py +++ b/paths_cli/wizard/errors.py @@ -30,7 +30,7 @@ def not_installed(wizard, package, obj_type): retry = wizard.ask(f"Hey, it looks like you don't have {package} " "installed. Do you want to try a different " f"{obj_type}, or do you want to quit?", - options=["[R]etry", "[Q]uit"]) + options=["[r]etry", "[q]uit"]) if retry == 'r': raise RestartObjectException() if retry == 'q': From 559d0227284ae22da9b099c677957973a90e150f Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 5 Nov 2021 10:58:24 -0400 Subject: [PATCH 213/251] add custom_eval_float --- paths_cli/compiling/cvs.py | 6 +++--- paths_cli/compiling/tools.py | 5 +++++ paths_cli/compiling/volumes.py | 6 +++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/paths_cli/compiling/cvs.py b/paths_cli/compiling/cvs.py index 6737afc5..0bc2645b 100644 --- a/paths_cli/compiling/cvs.py +++ b/paths_cli/compiling/cvs.py @@ -1,5 +1,5 @@ from paths_cli.compiling.core import Parameter, Builder -from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.tools import custom_eval, custom_eval_float from paths_cli.compiling.topology import build_topology from paths_cli.compiling.errors import InputError from paths_cli.utils import import_thing @@ -40,11 +40,11 @@ def _cv_kwargs_remapper(dct): for key, arg in kwargs.items()}, json_type='object', default=None, description="keyword arguments for ``func``"), - Parameter('period_min', custom_eval, default=None, + Parameter('period_min', custom_eval_float, default=None, json_type=json_type_eval('Float'), description=("minimum value for a periodic function, " "None if not periodic")), - Parameter('period_max', custom_eval, default=None, + Parameter('period_max', custom_eval_float, default=None, json_type=json_type_eval('Float'), description=("maximum value for a periodic function, " "None if not periodic")), diff --git a/paths_cli/compiling/tools.py b/paths_cli/compiling/tools.py index 22d55c29..e37a853c 100644 --- a/paths_cli/compiling/tools.py +++ b/paths_cli/compiling/tools.py @@ -33,6 +33,11 @@ def custom_eval_int_strict_pos(obj, named_objs=None): return val +def custom_eval_float(obj, named_objs=None): + val = custom_eval(obj, named_objs) + return float(val) + + class UnknownAtomsError(RuntimeError): pass diff --git a/paths_cli/compiling/volumes.py b/paths_cli/compiling/volumes.py index d4f45d56..a5f0229c 100644 --- a/paths_cli/compiling/volumes.py +++ b/paths_cli/compiling/volumes.py @@ -2,7 +2,7 @@ import functools from paths_cli.compiling.core import Parameter -from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.tools import custom_eval_float from paths_cli.compiling.plugins import VolumeCompilerPlugin, CategoryPlugin from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.json_type import ( @@ -30,10 +30,10 @@ def cv_volume_build_func(**dct): parameters=[ Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), description="CV that defines this volume"), - Parameter('lambda_min', custom_eval, + Parameter('lambda_min', custom_eval_float, json_type=json_type_eval("Float"), description="Lower bound for this volume"), - Parameter('lambda_max', custom_eval, + Parameter('lambda_max', custom_eval_float, json_type=json_type_eval("Float"), description="Upper bound for this volume") ], From c455e9ce0ba7235e2e266d307896db8ec558cecf Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 5 Nov 2021 11:49:32 -0400 Subject: [PATCH 214/251] handle None; split do_main; link eval types --- paths_cli/compiling/_gendocs/json_type_handlers.py | 10 ++++++++++ paths_cli/compiling/gendocs.py | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/paths_cli/compiling/_gendocs/json_type_handlers.py b/paths_cli/compiling/_gendocs/json_type_handlers.py index 8275a0b9..817d30f3 100644 --- a/paths_cli/compiling/_gendocs/json_type_handlers.py +++ b/paths_cli/compiling/_gendocs/json_type_handlers.py @@ -53,6 +53,12 @@ def _is_listof(json_type): ) +handle_none = JsonTypeHandler( + is_my_type=lambda obj: obj is None, + handler=lambda json_type: "type information missing", +) + + class RefTypeHandler(JsonTypeHandler): """Handle JSON types of the form {"$ref": "#/definitions/..."} @@ -122,6 +128,9 @@ class EvalHandler(RefTypeHandler): to the anchor given by ``link_to`` """ def __init__(self, type_name, link_to=None): + if link_to is None: + link_to = type_name + super().__init__( type_name=type_name, def_string=type_name, link_to=link_to ) @@ -129,6 +138,7 @@ def __init__(self, type_name, link_to=None): JSON_TYPE_HANDLERS = [ handle_object, + handle_none, handle_listof, CategoryHandler("engine"), CategoryHandler("cv"), diff --git a/paths_cli/compiling/gendocs.py b/paths_cli/compiling/gendocs.py index aa3ca1db..157c2866 100644 --- a/paths_cli/compiling/gendocs.py +++ b/paths_cli/compiling/gendocs.py @@ -9,6 +9,11 @@ @click.option("--stdout", type=bool, is_flag=True, default=False) def main(config_file, stdout): """Generate documentation for installed compiling plugins.""" + do_main(config_file, stdout) + + +def do_main(config_file, stdout=False): + """Separate method so this can be imported and run""" register_installed_plugins() config = load_config(config_file) generator = DocsGenerator(config) From 472574debb59601dae996f398fe280916e46cfb2 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 5 Nov 2021 14:13:39 -0400 Subject: [PATCH 215/251] shooting-point-selector compilers also commenting out TIS networks for now --- .../compiling/_gendocs/docs_generator.py | 2 +- paths_cli/compiling/networks.py | 22 ++++++------- paths_cli/compiling/plugins.py | 8 +++++ paths_cli/compiling/root_compiler.py | 4 +-- paths_cli/compiling/shooting.py | 19 ++++++----- paths_cli/compiling/strategies.py | 4 ++- .../tests/compiling/test_root_compiler.py | 33 ++++++++++++++++++- 7 files changed, 66 insertions(+), 26 deletions(-) diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 93a3887d..2ec89b3b 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -104,7 +104,7 @@ def generate_plugin_rst(self, plugin, category_name, default="", description="name this object in order to reuse it", ) - rst += self.format_parameter(name_param, type_str=" (*string*)") + rst += self.format_parameter(name_param, type_str=" (string)") for param in plugin.parameters: type_str = f" ({json_type_to_string(param.json_type)})" rst += self.format_parameter(param, type_str) diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index 8b7b185c..51c6e798 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -69,24 +69,22 @@ def tis_trans_info(dct): ) -MISTIS_NETWORK_PLUGIN = NetworkCompilerPlugin( - parameters=[Parameter('trans_info', mistis_trans_info)], - builder=Builder('openpathsampling.MISTISNetwork'), - name='mistis' -) +# MISTIS_NETWORK_PLUGIN = NetworkCompilerPlugin( +# parameters=[Parameter('trans_info', mistis_trans_info)], +# builder=Builder('openpathsampling.MISTISNetwork'), +# name='mistis' +# ) -TIS_NETWORK_PLUGIN = NetworkCompilerPlugin( - builder=Builder('openpathsampling.MISTISNetwork'), - parameters=[Parameter('trans_info', tis_trans_info)], - name='tis' -) +# TIS_NETWORK_PLUGIN = NetworkCompilerPlugin( +# builder=Builder('openpathsampling.MISTISNetwork'), +# parameters=[Parameter('trans_info', tis_trans_info)], +# name='tis' +# ) # old names not yet replaced in testing THESE ARE WHY WE'RE DOUBLING! GET # RID OF THEM! (also, use an is-check) build_tps_network = TPS_NETWORK_PLUGIN -build_mistis_network = MISTIS_NETWORK_PLUGIN -build_tis_network = TIS_NETWORK_PLUGIN NETWORK_COMPILER = CategoryPlugin(NetworkCompilerPlugin, aliases=['networks']) diff --git a/paths_cli/compiling/plugins.py b/paths_cli/compiling/plugins.py index 8dbc0c96..2bfb0d43 100644 --- a/paths_cli/compiling/plugins.py +++ b/paths_cli/compiling/plugins.py @@ -45,3 +45,11 @@ class SchemeCompilerPlugin(InstanceCompilerPlugin): class StrategyCompilerPlugin(InstanceCompilerPlugin): category = 'strategy' + + +class ShootingPointSelectorPlugin(InstanceCompilerPlugin): + category = 'shooting-point-selector' + + +class InterfaceSetPlugin(InstanceCompilerPlugin): + category = 'interface-set' diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index 8be0e2c3..3f1b2781 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -70,8 +70,8 @@ def _get_compiler(category): canonical_name = _canonical_name(category) # create a new compiler if none exists if canonical_name is None: - canonical_name = category - _COMPILERS[category] = CategoryCompiler(None, category) + canonical_name = clean_input_key(category) + _COMPILERS[canonical_name] = CategoryCompiler(None, category) return _COMPILERS[canonical_name] diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index 3693975c..2ba99f8c 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -3,9 +3,10 @@ ) from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.plugins import ShootingPointSelectorPlugin -build_uniform_selector = InstanceCompilerPlugin( +build_uniform_selector = ShootingPointSelectorPlugin( builder=Builder('openpathsampling.UniformSelector'), parameters=[], name='uniform', @@ -19,7 +20,7 @@ def _remapping_gaussian_stddev(dct): return dct -build_gaussian_selector = InstanceCompilerPlugin( +build_gaussian_selector = ShootingPointSelectorPlugin( builder=Builder('openpathsampling.GaussianBiasSelector', remapper=_remapping_gaussian_stddev), parameters=[ @@ -31,10 +32,10 @@ def _remapping_gaussian_stddev(dct): ) -shooting_selector_compiler = CategoryCompiler( - type_dispatch={ - 'uniform': build_uniform_selector, - 'gaussian': build_gaussian_selector, - }, - label='shooting-selectors' -) +# shooting_selector_compiler = CategoryCompiler( +# type_dispatch={ +# 'uniform': build_uniform_selector, +# 'gaussian': build_gaussian_selector, +# }, +# label='shooting-point-selectors' +# ) diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 5ecf66f4..f78e74ab 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -1,11 +1,13 @@ from paths_cli.compiling.core import Builder, Parameter -from paths_cli.compiling.shooting import shooting_selector_compiler +# from paths_cli.compiling.shooting import shooting_selector_compiler from paths_cli.compiling.plugins import ( StrategyCompilerPlugin, CategoryPlugin ) from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.json_type import json_type_ref +shooting_selector_compiler = compiler_for('shooting-point-selector') + def _strategy_name(class_name): return f"openpathsampling.strategies.{class_name}" diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 8e125547..5eae81e5 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -20,22 +20,26 @@ def foo_compiler(): return CategoryCompiler(None, 'foo') + @pytest.fixture def foo_compiler_plugin(): return CategoryPlugin(Mock(category='foo', __name__='foo'), ['bar']) + @pytest.fixture def foo_baz_builder_plugin(): - builder = InstanceCompilerPlugin(lambda: "FOO" , [], name='baz', + builder = InstanceCompilerPlugin(lambda: "FOO", [], name='baz', aliases=['qux']) builder.category = 'foo' return builder + ### CONSTANTS ############################################################## COMPILER_LOC = "paths_cli.compiling.root_compiler._COMPILERS" BASE = "paths_cli.compiling.root_compiler." + ### TESTS ################################################################## @pytest.mark.parametrize('input_string', ["foo-bar", "FOO_bar", "foo bar", @@ -43,6 +47,7 @@ def foo_baz_builder_plugin(): def test_clean_input_key(input_string): assert clean_input_key(input_string) == "foo_bar" + @pytest.mark.parametrize('input_name', ['canonical', 'alias']) def test_canonical_name(input_name): compilers = {'canonical': "FOO"} @@ -51,6 +56,7 @@ def test_canonical_name(input_name): patch.dict(BASE + "_ALIASES", aliases) as aliases_: assert _canonical_name(input_name) == "canonical" + class TestCategoryCompilerProxy: def setup(self): self.compiler = CategoryCompiler(None, "foo") @@ -86,6 +92,7 @@ def _bar_dispatch(dct): with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): assert proxy(user_input) == "bazbaz" + def test_compiler_for_nonexisting(): # if nothing is ever registered with the compiler, then compiler_for # should error @@ -97,6 +104,7 @@ def test_compiler_for_nonexisting(): with pytest.raises(RuntimeError, match="No CategoryCompiler"): proxy._proxy + def test_compiler_for_existing(foo_compiler): # if a compiler already exists when compiler_for is called, then # compiler_for should get that as its proxy @@ -104,6 +112,7 @@ def test_compiler_for_existing(foo_compiler): proxy = compiler_for('foo') assert proxy._proxy is foo_compiler + def test_compiler_for_unregistered(foo_compiler): # if a compiler is registered after compiler_for is called, then # compiler_for should use that as its proxy @@ -111,6 +120,7 @@ def test_compiler_for_unregistered(foo_compiler): with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): assert proxy._proxy is foo_compiler + def test_compiler_for_registered_alias(foo_compiler): # if compiler_for is registered as an alias, compiler_for should still # get the correct compiler @@ -121,12 +131,14 @@ def test_compiler_for_registered_alias(foo_compiler): proxy = compiler_for('bar') assert proxy._proxy is foo_compiler + def test_get_compiler_existing(foo_compiler): # if a compiler has been registered, then _get_compiler should return the # registered compiler with patch.dict(COMPILER_LOC, {'foo': foo_compiler}): assert _get_compiler('foo') is foo_compiler + def test_get_compiler_nonexisting(foo_compiler): # if a compiler has not been registered, then _get_compiler should create # the compiler @@ -136,6 +148,18 @@ def test_get_compiler_nonexisting(foo_compiler): assert compiler.label == 'foo' assert 'foo' in _COMPILERS + +def test_get_compiler_nonstandard_name_multiple(): + # regression test based on real issue -- there was an error where + # non-canonical names (e.g., names that involved hyphens instead of + # underscores) would overwrite the previously created compiler instead + # of getting the identical object + with patch.dict(COMPILER_LOC, {}): + c1 = _get_compiler('non-canonical-name') + c2 = _get_compiler('non-canonical-name') + assert c1 is c2 + + @pytest.mark.parametrize('canonical,aliases,expected', [ ('foo', ['bar', 'baz'], ['foo', 'bar', 'baz']), ('foo', ['baz', 'bar'], ['foo', 'baz', 'bar']), @@ -149,6 +173,7 @@ def test_get_registration_names(canonical, aliases, expected): type(plugin).name = PropertyMock(return_value=canonical) assert _get_registration_names(plugin) == expected + def test_register_compiler_plugin(foo_compiler_plugin): # _register_compiler_plugin should register compilers that don't exist compilers = {} @@ -162,6 +187,7 @@ def test_register_compiler_plugin(foo_compiler_plugin): assert 'foo' not in _COMPILERS + @pytest.mark.parametrize('duplicate_of', ['canonical', 'alias']) @pytest.mark.parametrize('duplicate_from', ['canonical', 'alias']) def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): @@ -184,6 +210,7 @@ def test_register_compiler_plugin_duplicate(duplicate_of, duplicate_from): with pytest.raises(CategoryCompilerRegistrationError): _register_compiler_plugin(plugin) + @pytest.mark.parametrize('compiler_exists', [True, False]) def test_register_builder_plugin(compiler_exists, foo_baz_builder_plugin, foo_compiler): @@ -203,6 +230,7 @@ def test_register_builder_plugin(compiler_exists, foo_baz_builder_plugin, assert type_dispatch['baz'] is foo_baz_builder_plugin assert type_dispatch['qux'] is foo_baz_builder_plugin + def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): # register_plugins should correctly sort builder and compiler plugins, # and call the correct registration functions @@ -212,6 +240,7 @@ def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): assert builder.called_once_with(foo_baz_builder_plugin) assert compiler.called_once_with(foo_compiler_plugin) + def test_register_plugins_integration(foo_compiler_plugin, foo_baz_builder_plugin): # register_plugins should correctly register plugins @@ -225,6 +254,7 @@ def test_register_plugins_integration(foo_compiler_plugin, type_dispatch = _COMPILERS['foo'].type_dispatch assert type_dispatch['baz'] is foo_baz_builder_plugin + def test_sort_user_categories(): # sorted user categories should match the expected compile order aliases = {'quux': 'qux'} @@ -245,6 +275,7 @@ def test_sort_user_categories(): # check that we unset properly (test the test) assert paths_cli.compiling.root_compiler.COMPILE_ORDER[0] == 'engine' + def test_do_compile(): # compiler should correctly compile a basic input dict compilers = { From 1027f8b62cab4222b07d5215f6eb2659e0c2e321 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 6 Nov 2021 10:37:51 -0400 Subject: [PATCH 216/251] more documentation for compiling objects --- .../compiling/_gendocs/json_type_handlers.py | 2 + paths_cli/compiling/networks.py | 51 ++++++++++++++----- paths_cli/compiling/schemes.py | 11 +++- paths_cli/compiling/shooting.py | 31 +++++++++-- paths_cli/compiling/strategies.py | 6 +-- 5 files changed, 79 insertions(+), 22 deletions(-) diff --git a/paths_cli/compiling/_gendocs/json_type_handlers.py b/paths_cli/compiling/_gendocs/json_type_handlers.py index 817d30f3..93898eb4 100644 --- a/paths_cli/compiling/_gendocs/json_type_handlers.py +++ b/paths_cli/compiling/_gendocs/json_type_handlers.py @@ -146,6 +146,8 @@ def __init__(self, type_name, link_to=None): CategoryHandler("network"), CategoryHandler("strategy"), CategoryHandler("scheme"), + CategoryHandler("shooting-point-selector"), + CategoryHandler("interface-set"), EvalHandler("EvalInt"), EvalHandler("EvalIntStrictPos"), EvalHandler("EvalFloat"), diff --git a/paths_cli/compiling/networks.py b/paths_cli/compiling/networks.py index 51c6e798..360621d1 100644 --- a/paths_cli/compiling/networks.py +++ b/paths_cli/compiling/networks.py @@ -2,13 +2,44 @@ InstanceCompilerPlugin, Builder, Parameter ) from paths_cli.compiling.tools import custom_eval -from paths_cli.compiling.plugins import NetworkCompilerPlugin, CategoryPlugin +from paths_cli.compiling.plugins import ( + NetworkCompilerPlugin, CategoryPlugin, InterfaceSetPlugin +) from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.json_type import ( json_type_ref, json_type_list, json_type_eval ) -build_interface_set = InstanceCompilerPlugin( + +INITIAL_STATES_PARAM = Parameter( + 'initial_states', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), + description="initial states for this transition", +) + + +INITIAL_STATE_PARAM = Parameter( + 'initial_state', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), + description="initial state for this transition", +) + + +FINAL_STATES_PARAM = Parameter( + 'final_states', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), + description="final states for this transition", +) + + +FINAL_STATE_PARAM = Parameter( + 'final_state', compiler_for('volume'), + json_type=json_type_list(json_type_ref('volume')), + description="final state for this transition", +) + + +build_interface_set = InterfaceSetPlugin( builder=Builder('openpathsampling.VolumeInterfaceSet'), parameters=[ Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), @@ -23,7 +54,8 @@ description=("maximum value(s) for interfaces in this" "interface set")), ], - name='interface-set' + name='interface-set', + description="Interface set used in transition interface sampling.", ) @@ -57,15 +89,10 @@ def tis_trans_info(dct): TPS_NETWORK_PLUGIN = NetworkCompilerPlugin( builder=Builder('openpathsampling.TPSNetwork'), - parameters=[ - Parameter('initial_states', compiler_for('volume'), - json_type=json_type_list(json_type_ref('volume')), - description="initial states for this transition"), - Parameter('final_states', compiler_for('volume'), - json_type=json_type_list(json_type_ref('volume')), - description="final states for this transition") - ], - name='tps' + parameters=[INITIAL_STATES_PARAM, FINAL_STATES_PARAM], + name='tps', + description=("Network for transition path sampling (two state TPS or " + "multiple state TPS)."), ) diff --git a/paths_cli/compiling/schemes.py b/paths_cli/compiling/schemes.py index b1813649..a52276d7 100644 --- a/paths_cli/compiling/schemes.py +++ b/paths_cli/compiling/schemes.py @@ -45,6 +45,9 @@ ENGINE_PARAMETER ], name='spring-shooting', + description=("Move scheme for TPS with the spring-shooting algorithm. " + "Under most circumstances, the network provided here " + "should be a 2-state TPS network."), ) @@ -78,6 +81,8 @@ def __call__(self, **dct): STRATEGIES_PARAMETER, ], name='one-way-shooting', + description=("One-way-shooting move scheme. This can be extended with " + "additional user-defined move strategies."), ) MOVESCHEME_PLUGIN = SchemeCompilerPlugin( @@ -87,7 +92,11 @@ def __call__(self, **dct): NETWORK_PARAMETER, STRATEGIES_PARAMETER, ], - name='scheme' + name='scheme', + description=("Generic move scheme. Add strategies to this to make it " + "useful. This defaults to a scheme that first chooses a " + "move type, and then chooses the specific move within " + "that type (i.e., ``OrganizeByMoveGroupStrategy``)"), ) SCHEME_COMPILER = CategoryPlugin(SchemeCompilerPlugin, aliases=['schemes']) diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index 2ba99f8c..9547a283 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -2,14 +2,24 @@ InstanceCompilerPlugin, CategoryCompiler, Builder, Parameter ) from paths_cli.compiling.root_compiler import compiler_for -from paths_cli.compiling.tools import custom_eval +from paths_cli.compiling.tools import custom_eval_float from paths_cli.compiling.plugins import ShootingPointSelectorPlugin +from paths_cli.compiling.json_type import json_type_ref, json_type_eval + +shooting_selector_compiler = compiler_for('shooting-point-selector') +SP_SELECTOR_PARAMETER = Parameter( + 'selector', compiler_for('shooting-point-selector'), default=None, + json_type=json_type_ref('shooting-point-selector'), + description="shooting point selection algorithm to use.", +) build_uniform_selector = ShootingPointSelectorPlugin( builder=Builder('openpathsampling.UniformSelector'), parameters=[], name='uniform', + description=("Uniform shooting point selection probability: all frames " + "have equal probability (endpoints excluded)."), ) @@ -24,11 +34,24 @@ def _remapping_gaussian_stddev(dct): builder=Builder('openpathsampling.GaussianBiasSelector', remapper=_remapping_gaussian_stddev), parameters=[ - Parameter('cv', compiler_for('cv')), - Parameter('mean', custom_eval), - Parameter('stddev', custom_eval), + Parameter('cv', compiler_for('cv'), json_type=json_type_ref('cv'), + description="bias as a Gaussian in this CV"), + Parameter('mean', custom_eval_float, + json_type=json_type_eval("Float"), + description="mean of the Gaussian"), + Parameter('stddev', custom_eval_float, + json_type=json_type_eval("Float"), + description="standard deviation of the Gaussian"), ], name='gaussian', + description=( + "Bias shooting point selection based on a Gaussian. That is, for a " + "configuration :math:`x`, the probability of selecting that " + "configruation is proportional to " + r":math:`\exp(-(\lambda(x)-\bar{\lambda})^2 / (2\sigma^2))`, " + r"where :math:`\lambda` is the given CV, :math:`\bar{\lambda}` is " + r"the mean, and :math:`\sigma` is the standard deviation." + ), ) diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index f78e74ab..c0f887bb 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -1,12 +1,10 @@ from paths_cli.compiling.core import Builder, Parameter -# from paths_cli.compiling.shooting import shooting_selector_compiler from paths_cli.compiling.plugins import ( StrategyCompilerPlugin, CategoryPlugin ) from paths_cli.compiling.root_compiler import compiler_for from paths_cli.compiling.json_type import json_type_ref - -shooting_selector_compiler = compiler_for('shooting-point-selector') +from paths_cli.compiling.shooting import SP_SELECTOR_PARAMETER def _strategy_name(class_name): @@ -19,8 +17,6 @@ def _group_parameter(group_name): # TODO: maybe this moves into shooting once we have the metadata? -SP_SELECTOR_PARAMETER = Parameter('selector', shooting_selector_compiler, - default=None) ENGINE_PARAMETER = Parameter( 'engine', compiler_for('engine'), json_type=json_type_ref('engine'), From 8773e7fe6b627a43511d7e68593853eb0508d063 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Nov 2021 10:40:20 -0500 Subject: [PATCH 217/251] add descriptions to strategies 'scheme' root compiler name, not movescheme --- paths_cli/compiling/root_compiler.py | 2 +- paths_cli/compiling/strategies.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/paths_cli/compiling/root_compiler.py b/paths_cli/compiling/root_compiler.py index 3f1b2781..f12fe8c6 100644 --- a/paths_cli/compiling/root_compiler.py +++ b/paths_cli/compiling/root_compiler.py @@ -18,7 +18,7 @@ class CategoryCompilerRegistrationError(Exception): 'volume', 'state', 'network', - 'movescheme', + 'scheme', ] COMPILE_ORDER = _DEFAULT_COMPILE_ORDER.copy() diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index c0f887bb..94321526 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -48,6 +48,7 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER ], name='one-way-shooting', + description="Use one-way-shooting moves in this move scheme.", ) build_one_way_shooting_strategy = ONE_WAY_SHOOTING_STRATEGY_PLUGIN @@ -70,6 +71,8 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER ], name='nearest-neighbor-repex', + description=("Use replica exchange only between neearest-neighbor " + "interfaces in this move scheme"), ) build_all_set_repex_strategy = StrategyCompilerPlugin( @@ -79,6 +82,8 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER ], name='all-set-repex', + description=("Use replica exchange allowing swap attempts between any " + "pair of ensembles within the same interface set."), ) build_path_reversal_strategy = StrategyCompilerPlugin( @@ -88,6 +93,7 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER, ], name='path-reversal', + description="Use path reversal moves in this move scheme.", ) build_minus_move_strategy = StrategyCompilerPlugin( @@ -98,6 +104,9 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER, ], name='minus', + description=("Use the minus move in this move scheme. This strategy " + "uses the M-shaped, or multiple interface set minus " + "move, and always keeps a sample in the minus interface."), ) build_single_replica_minus_move_strategy = StrategyCompilerPlugin( @@ -108,6 +117,12 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER, ], name='single-replica-minus', + description=("Use the single-replica minus move in this move scheme. " + "This strategy does not keep a replica in the minus " + "ensemble; instead, that trajectory is only temporarily " + "created during this move. This should not be used if " + "there are multiple interface sets with the same initial " + "state."), ) STRATEGY_COMPILER = CategoryPlugin(StrategyCompilerPlugin, From 6ee8545513f86ae425957043443d6968cf168bb4 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 7 Nov 2021 10:51:06 -0500 Subject: [PATCH 218/251] add test for get_compiler_none --- paths_cli/tests/compiling/test_root_compiler.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 5eae81e5..0397b382 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -149,6 +149,16 @@ def test_get_compiler_nonexisting(foo_compiler): assert 'foo' in _COMPILERS +def test_get_compiler_none(): + # if trying to get the None compiler, the same None compiler should + # always be returned + with patch.dict(COMPILER_LOC, {}): + compiler1 = _get_compiler(None) + assert compiler1.label is None + compiler2 = _get_compiler(None) + assert compiler1 is compiler2 + + def test_get_compiler_nonstandard_name_multiple(): # regression test based on real issue -- there was an error where # non-canonical names (e.g., names that involved hyphens instead of From a74d29d48841a5e9cd4290c91987d515f9a7c723 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 8 Nov 2021 15:32:47 +0100 Subject: [PATCH 219/251] Apply suggestions from code review Co-authored-by: Sander Roet --- paths_cli/compiling/shooting.py | 7 ------- paths_cli/compiling/strategies.py | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/paths_cli/compiling/shooting.py b/paths_cli/compiling/shooting.py index 9547a283..6160c499 100644 --- a/paths_cli/compiling/shooting.py +++ b/paths_cli/compiling/shooting.py @@ -55,10 +55,3 @@ def _remapping_gaussian_stddev(dct): ) -# shooting_selector_compiler = CategoryCompiler( -# type_dispatch={ -# 'uniform': build_uniform_selector, -# 'gaussian': build_gaussian_selector, -# }, -# label='shooting-point-selectors' -# ) diff --git a/paths_cli/compiling/strategies.py b/paths_cli/compiling/strategies.py index 94321526..292c7d03 100644 --- a/paths_cli/compiling/strategies.py +++ b/paths_cli/compiling/strategies.py @@ -71,8 +71,8 @@ def _group_parameter(group_name): REPLACE_TRUE_PARAMETER ], name='nearest-neighbor-repex', - description=("Use replica exchange only between neearest-neighbor " - "interfaces in this move scheme"), + description=("Use replica exchange only between nearest-neighbor " + "interfaces in this move scheme."), ) build_all_set_repex_strategy = StrategyCompilerPlugin( From 9d2a90a9b5af8d4aca1daf10b55c24d3a7cf6c96 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 12 Nov 2021 00:01:01 -0500 Subject: [PATCH 220/251] Stop excluding tests from coverage --- .coveragerc | 1 - 1 file changed, 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index d5a61508..66545f18 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,5 @@ [report] omit = - */paths_cli/tests/* */paths_cli/_installed_version.py */paths_cli/version.py exclude_lines = From 674d3fb43655d21bbdaeea97eb99ed46bc4d0a36 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 12 Nov 2021 21:33:34 -0500 Subject: [PATCH 221/251] Exclude some test code that doesn't need coverage --- paths_cli/tests/commands/test_md.py | 2 +- paths_cli/tests/commands/utils.py | 2 +- paths_cli/tests/compiling/test_core.py | 2 +- paths_cli/tests/test_file_copying.py | 3 ++- paths_cli/tests/utils.py | 4 ++-- paths_cli/tests/wizard/mock_wizard.py | 4 +++- paths_cli/tests/wizard/test_load_from_ops.py | 2 +- 7 files changed, 11 insertions(+), 8 deletions(-) diff --git a/paths_cli/tests/commands/test_md.py b/paths_cli/tests/commands/test_md.py index f4aab68f..b35df94b 100644 --- a/paths_cli/tests/commands/test_md.py +++ b/paths_cli/tests/commands/test_md.py @@ -159,7 +159,7 @@ def test_md_main(md_fixture, inp): nsteps, ensembles = 5, None elif inp == 'ensemble': nsteps, ensembles = None, [ens] - else: + else: # -no-cov- raise RuntimeError("pytest went crazy") traj, foo = md_main( diff --git a/paths_cli/tests/commands/utils.py b/paths_cli/tests/commands/utils.py index 27a31d16..c1f665c0 100644 --- a/paths_cli/tests/commands/utils.py +++ b/paths_cli/tests/commands/utils.py @@ -1,7 +1,7 @@ import traceback def assert_click_success(result): - if result.exit_code != 0: + if result.exit_code != 0: # -no-cov- (only occurs on test error) print(result.output) traceback.print_tb(result.exc_info[2]) print(result.exc_info[0], result.exc_info[1]) diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index 6c8be07e..86fd95f5 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -332,7 +332,7 @@ def _validate_obj(obj, input_type): assert obj == 'bar' elif input_type == 'dict': assert obj.data == 'qux' - else: + else: # -no-cov- raise RuntimeError("Error in test setup") @pytest.mark.parametrize('input_type', ['str', 'dict']) diff --git a/paths_cli/tests/test_file_copying.py b/paths_cli/tests/test_file_copying.py index 0a59cbc6..a32408ed 100644 --- a/paths_cli/tests/test_file_copying.py +++ b/paths_cli/tests/test_file_copying.py @@ -59,7 +59,8 @@ def __init__(self): self.previously_seen = set([]) def __call__(self, snap): - if snap in self.previously_seen: + if snap in self.previously_seen: # -no-cov- + # this is only covered if an error occurs raise AssertionError("Second CV eval for " + str(snap)) self.previously_seen.update({snap}) return snap.xyz[0][0] diff --git a/paths_cli/tests/utils.py b/paths_cli/tests/utils.py index 7dbcf247..a00ffa7a 100644 --- a/paths_cli/tests/utils.py +++ b/paths_cli/tests/utils.py @@ -3,13 +3,13 @@ try: urllib.request.urlopen('https://www.google.com') -except: +except: # -no-cov- HAS_INTERNET = False else: HAS_INTERNET = True def assert_url(url): - if not HAS_INTERNET: + if not HAS_INTERNET: # -no-cov- pytest.skip("Internet connection seems faulty") # TODO: On a 404 this will raise a urllib.error.HTTPError. It would be diff --git a/paths_cli/tests/wizard/mock_wizard.py b/paths_cli/tests/wizard/mock_wizard.py index d273c5be..59dc07d5 100644 --- a/paths_cli/tests/wizard/mock_wizard.py +++ b/paths_cli/tests/wizard/mock_wizard.py @@ -30,7 +30,9 @@ def input(self, content): self.input_call_count += 1 try: user_input = next(self._input_iter) - except StopIteration as e: + except StopIteration as e: # -no-cov- + # this only occurs on a test error and provides diagnostic + # information print(self.log_text) raise e diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index a5ce9675..c9229ad8 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -25,7 +25,7 @@ def __getitem__(self, key): return self._objects[key] elif isinstance(key, str): return self._named_objects[key] - else: + else: # -nocov- raise TypeError("Huh?") def __iter__(self): From 40e2ec4e43d407925828593bde7dcb7084c97205 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Dec 2021 16:54:38 -0500 Subject: [PATCH 222/251] Ignore coverage on some unreachable lines in tests --- paths_cli/tests/test_utils.py | 2 +- paths_cli/tests/wizard/test_volumes.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/paths_cli/tests/test_utils.py b/paths_cli/tests/test_utils.py index b6234ec5..1d43c6ff 100644 --- a/paths_cli/tests/test_utils.py +++ b/paths_cli/tests/test_utils.py @@ -10,7 +10,7 @@ def test_len(self): def test_empty(self): ordered = OrderedSet() assert len(ordered) == 0 - for _ in ordered: + for _ in ordered: # -no-cov- raise RuntimeError("This should not happen") def test_order(self): diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index 368c9244..df499d80 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -54,7 +54,7 @@ def test_volume_intro(as_state, has_state): assert "You'll need to define" in intro elif not as_state: assert intro == _VOL_DESC - else: + else: # -no-cov- raise RuntimeError("WTF?") def _binary_volume_test(volume_setup, func): From 4e3ff3297a43677a414d773ebd04684443616d0b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 7 Dec 2021 20:09:07 -0500 Subject: [PATCH 223/251] maybe fixed missing compiling/volumes testings --- paths_cli/compiling/core.py | 2 +- paths_cli/tests/compiling/test_volumes.py | 37 +++++++++++++++++------ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/paths_cli/compiling/core.py b/paths_cli/compiling/core.py index 7791005f..0a68c01e 100644 --- a/paths_cli/compiling/core.py +++ b/paths_cli/compiling/core.py @@ -153,7 +153,7 @@ class InstanceCompilerPlugin(OPSPlugin): """ SCHEMA = "http://openpathsampling.org/schemas/sim-setup/draft01.json" category = None - + def __init__(self, builder, parameters, name=None, *, aliases=None, description=None, requires_ops=(1, 0), requires_cli=(0, 3)): diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index f28bf4e3..cc3cc408 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -1,6 +1,8 @@ import pytest from unittest import mock from paths_cli.tests.compiling.utils import mock_compiler +from paths_cli.compiling.plugins import CVCompilerPlugin +from paths_cli.compiling.core import Parameter import yaml import numpy as np @@ -23,20 +25,20 @@ def setup(self): } self.func = { - 'inline': "\n ".join(["name: foo", "type: mdtraj"]), + 'inline': "\n " + "\n ".join([ + "name: foo", + "type: fake_type", + "input_data: bar", + ]), 'external': 'foo' } - def create_inputs(self, inline, periodic): - yml = "\n".join(["type: cv-volume", "cv: {func}", - "lambda_min: 0", "lambda_max: 1"]) - def set_periodic(self, periodic): if periodic == 'periodic': self.named_objs_dict['foo']['period_max'] = 'np.pi' self.named_objs_dict['foo']['period_min'] = '-np.pi' - @pytest.mark.parametrize('inline', ['external', 'external']) + @pytest.mark.parametrize('inline', ['external', 'inline']) @pytest.mark.parametrize('periodic', ['periodic', 'nonperiodic']) def test_build_cv_volume(self, inline, periodic): self.set_periodic(periodic) @@ -47,14 +49,29 @@ def test_build_cv_volume(self, inline, periodic): mock_cv = CoordinateFunctionCV(lambda s: s.xyz[0][0], period_min=period_min, period_max=period_max).named('foo') + + patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' + if inline =='external': - patch_loc = 'paths_cli.compiling.root_compiler._COMPILERS' compilers = { 'cv': mock_compiler('cv', named_objs={'foo': mock_cv}) } - with mock.patch.dict(patch_loc, compilers): - vol = build_cv_volume(dct) - elif inline == 'internal': + elif inline == 'inline': + fake_plugin = CVCompilerPlugin( + name="fake_type", + parameters=[Parameter('input_data', str)], + builder=lambda input_data: mock_cv + ) + compilers = { + 'cv': mock_compiler( + 'cv', + type_dispatch={'fake_type': fake_plugin} + ) + } + else: # -no-cov- + raise RuntimeError("Should never get here") + + with mock.patch.dict(patch_loc, compilers): vol = build_cv_volume(dct) in_state = make_1d_traj([0.5])[0] From d841a74cbeb13ac9c044495b3f1b5a88adc7b2d9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 9 Dec 2021 17:00:41 -0500 Subject: [PATCH 224/251] _wrap was not needed (happens internally) --- paths_cli/tests/wizard/test_volumes.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/paths_cli/tests/wizard/test_volumes.py b/paths_cli/tests/wizard/test_volumes.py index df499d80..52a6937d 100644 --- a/paths_cli/tests/wizard/test_volumes.py +++ b/paths_cli/tests/wizard/test_volumes.py @@ -16,15 +16,6 @@ from openpathsampling.tests.test_helpers import make_1d_traj -def _wrap(x, period_min, period_max): - # used in testing periodic CVs - while x >= period_max: - x -= period_max - period_min - while x < period_min: - x += period_max - period-min - return x - - @pytest.fixture def volume_setup(): cv = CoordinateFunctionCV(lambda snap: snap.xyz[0][0]).named('x') @@ -120,8 +111,7 @@ def test_cv_defined_volume(periodic): min_ = 0.0 max_ = 1.0 cv = CoordinateFunctionCV( - lambda snap: _wrap(snap.xyz[0][0], period_min=min_, - period_max=max_), + lambda snap: snap.xyz[0][0], period_min=min_, period_max=max_ ).named('x') inputs = ['x', '0.75', '1.25'] From 059f816cb96acd6f9b02df927e205be787b51696 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 9 Dec 2021 17:09:03 -0500 Subject: [PATCH 225/251] Cleanup more unused things in test coverage --- paths_cli/tests/wizard/test_load_from_ops.py | 16 +++++++++------- paths_cli/tests/wizard/test_parameters.py | 6 +----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/paths_cli/tests/wizard/test_load_from_ops.py b/paths_cli/tests/wizard/test_load_from_ops.py index c9229ad8..7b32d8d9 100644 --- a/paths_cli/tests/wizard/test_load_from_ops.py +++ b/paths_cli/tests/wizard/test_load_from_ops.py @@ -20,13 +20,15 @@ def __init__(self, objects): self._objects = objects self._named_objects = {obj.name: obj for obj in objects} - def __getitem__(self, key): - if isinstance(key, int): - return self._objects[key] - elif isinstance(key, str): - return self._named_objects[key] - else: # -nocov- - raise TypeError("Huh?") + # leaving this commented out... it doesn't seem to be used currently, + # but if it is needed in the future, this should be the implementation + # def __getitem__(self, key): + # if isinstance(key, int): + # return self._objects[key] + # elif isinstance(key, str): + # return self._named_objects[key] + # else: # -no-cov- + # raise TypeError("Huh?") def __iter__(self): return iter(self._objects) diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py index 9cdf3ff6..f4774e08 100644 --- a/paths_cli/tests/wizard/test_parameters.py +++ b/paths_cli/tests/wizard/test_parameters.py @@ -15,16 +15,12 @@ class TestWizardParameter: def _reverse(string): return "".join(reversed(string)) - @staticmethod - def _summarize(string): - return f"Here's a summary: we made {string}" - def setup(self): self.parameter = WizardParameter( name='foo', ask="How should I {do_what}?", loader=self._reverse, - summarize=self._summarize, + summarize=lambda string: f"Should be unused. Input: {string}", ) self.wizard = mock_wizard(["bar"]) self.compiler_plugin = compiling.InstanceCompilerPlugin( From f0d946b2f0d2f9902b6128e6c9087a16f9519513 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Fri, 10 Dec 2021 09:57:12 -0500 Subject: [PATCH 226/251] Fix remaining missing lines from tests --- paths_cli/tests/test_parameters.py | 2 +- paths_cli/tests/wizard/test_wizard.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/paths_cli/tests/test_parameters.py b/paths_cli/tests/test_parameters.py index 4bd2651f..e495ad8a 100644 --- a/paths_cli/tests/test_parameters.py +++ b/paths_cli/tests/test_parameters.py @@ -103,7 +103,7 @@ def _filename(self, getter): def create_file(self, getter): filename = self._filename(getter) - if getter == "named": + if getter == "name": self.other_scheme = self.other_scheme.named("other") self.other_engine = self.other_engine.named("other") diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index c8d50671..b377c568 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -309,6 +309,7 @@ def test_save_to_file(self, toy_engine): assert len(storage.networks) == len(storage.schemes) == 0 assert len(storage.engines) == 1 assert storage.engines[toy_engine.name] == toy_engine + assert storage.engines[0] == toy_engine assert "Everything has been stored" in self.wizard.console.log_text @pytest.mark.parametrize('req,count,expected', [ @@ -382,6 +383,7 @@ def test_run_wizard(self, toy_engine): assert len(storage.networks) == len(storage.schemes) == 0 assert len(storage.engines) == 1 assert storage.engines[toy_engine.name] == toy_engine + assert storage.engines[0] == toy_engine def test_run_wizard_quit(self): console = MockConsole() From 52ef98d43aab81916adc331cd94bcb323c93e011 Mon Sep 17 00:00:00 2001 From: sroet Date: Mon, 3 Jan 2022 12:38:51 +0100 Subject: [PATCH 227/251] update copyright to 2022 --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index a5bc8235..319b862d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ import packaging.version project = 'OpenPathSampling CLI' -copyright = '2019-2020, David W.H. Swenson' +copyright = '2019-2022, David W.H. Swenson' author = 'David W.H. Swenson' # The full version, including alpha/beta/rc tags From 4fc7e5a622b6a2d928c4ff5fc329aa73b0508599 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 3 Jan 2022 19:41:51 +0100 Subject: [PATCH 228/251] update copyright in license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index dfb8595c..d39abc8b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 David W.H. Swenson +Copyright (c) 2019-2022 David W.H. Swenson and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal From cbb4a65848ef5f17ebd990c3f734df3448a95cf9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 3 Jan 2022 14:28:55 -0500 Subject: [PATCH 229/251] Update docs/conf.py --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 319b862d..866f3013 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -25,7 +25,7 @@ import packaging.version project = 'OpenPathSampling CLI' -copyright = '2019-2022, David W.H. Swenson' +copyright = '2019-2022, David W.H. Swenson and contributors' author = 'David W.H. Swenson' # The full version, including alpha/beta/rc tags From dcda827198cac0933465d95f11e49304f046dae9 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 5 Sep 2022 20:14:22 -0500 Subject: [PATCH 230/251] Fix error in output for contents This is a better solution for #71 (should replace #72). We really don't want all that junk in the `contents` output anyway. The purpose of that line to give the filename as part of the output, so let's just give the filename. --- paths_cli/commands/contents.py | 7 ++++++- paths_cli/tests/commands/test_contents.py | 8 ++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index 5d3fbca7..13be1dce 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -37,7 +37,12 @@ def contents(input_file, table): command (i.e., to identify exactly how a state or engine is named.) """ storage = INPUT_FILE.get(input_file) - print(storage) + try: + print(storage.filename) + except AttributeError: + # TODO: this should be removed once SimStore has a `filename` + # attribute + print(storage) if table is None: report_all_tables(storage) else: diff --git a/paths_cli/tests/commands/test_contents.py b/paths_cli/tests/commands/test_contents.py index 8bad0bec..04d79f9c 100644 --- a/paths_cli/tests/commands/test_contents.py +++ b/paths_cli/tests/commands/test_contents.py @@ -22,7 +22,7 @@ def test_contents(tps_fixture): results = runner.invoke(contents, ['setup.nc']) cwd = os.getcwd() expected = [ - f"Storage @ '{cwd}/setup.nc'", + f"{cwd}/setup.nc", "CVs: 1 item", "* x", "Volumes: 8 items", "* A", "* B", "* plus 6 unnamed items", "Engines: 2 items", "* flat", "* plus 1 unnamed item", @@ -56,17 +56,17 @@ def test_contents_table(tps_fixture, table): cwd = os.getcwd() expected = { 'volumes': [ - f"Storage @ '{cwd}/setup.nc'", + f"{cwd}/setup.nc", "volumes: 8 items", "* A", "* B", "* plus 6 unnamed items", "" ], 'trajectories': [ - f"Storage @ '{cwd}/setup.nc'", + f"{cwd}/setup.nc", "trajectories: 1 unnamed item", "" ], 'tags': [ - f"Storage @ '{cwd}/setup.nc'", + f"{cwd}/setup.nc", "tags: 1 item", "* initial_conditions", "" From d570f5b09d485c11feb759d77ab040be0f6d7c97 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 27 Nov 2022 16:35:52 -0600 Subject: [PATCH 231/251] no-cov on attribute error --- paths_cli/commands/contents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/commands/contents.py b/paths_cli/commands/contents.py index 13be1dce..5243aac7 100644 --- a/paths_cli/commands/contents.py +++ b/paths_cli/commands/contents.py @@ -39,7 +39,7 @@ def contents(input_file, table): storage = INPUT_FILE.get(input_file) try: print(storage.filename) - except AttributeError: + except AttributeError: # no-cov (temporary fix) # TODO: this should be removed once SimStore has a `filename` # attribute print(storage) From e0e4c2efd5baa94b0775601a2c88539b26ce2a95 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 23 Apr 2024 10:52:25 -0500 Subject: [PATCH 232/251] Bump Python versions for testing --- .github/workflows/test-suite.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 82e413a5..46fd0512 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -24,12 +24,12 @@ jobs: strategy: matrix: CONDA_PY: - - 3.9 - - 3.8 - - 3.7 + - "3.11" + - "3.10" + - "3.9" INTEGRATIONS: [""] include: - - CONDA_PY: 3.9 + - CONDA_PY: "3.11" INTEGRATIONS: 'all-optionals' steps: From 619a34e2baadabc7c5e74939b8e986281eba89f6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 23 Apr 2024 11:13:32 -0500 Subject: [PATCH 233/251] setup => setup_method; teardown => teardown_method --- paths_cli/tests/commands/test_md.py | 4 +- .../compiling/_gendocs/test_docs_generator.py | 2 +- paths_cli/tests/compiling/test_core.py | 6 +-- paths_cli/tests/compiling/test_cvs.py | 2 +- paths_cli/tests/compiling/test_engines.py | 4 +- paths_cli/tests/compiling/test_plugins.py | 2 +- .../tests/compiling/test_root_compiler.py | 2 +- paths_cli/tests/compiling/test_volumes.py | 4 +- paths_cli/tests/test_cli.py | 2 +- paths_cli/tests/test_file_copying.py | 6 +-- paths_cli/tests/test_parameters.py | 44 +++++++++---------- paths_cli/tests/test_plugin_management.py | 10 ++--- paths_cli/tests/test_utils.py | 2 +- paths_cli/tests/wizard/test_helper.py | 4 +- paths_cli/tests/wizard/test_parameters.py | 4 +- paths_cli/tests/wizard/test_plugin_classes.py | 8 ++-- paths_cli/tests/wizard/test_wizard.py | 2 +- 17 files changed, 54 insertions(+), 54 deletions(-) diff --git a/paths_cli/tests/commands/test_md.py b/paths_cli/tests/commands/test_md.py index b35df94b..3c881fdd 100644 --- a/paths_cli/tests/commands/test_md.py +++ b/paths_cli/tests/commands/test_md.py @@ -12,7 +12,7 @@ make_1d_traj, CalvinistDynamics class TestProgressReporter(object): - def setup(self): + def setup_method(self): self.progress = ProgressReporter(timestep=None, update_freq=5) @pytest.mark.parametrize('timestep', [None, 0.1]) @@ -38,7 +38,7 @@ def test_report_progress(self, n_steps, force, capsys): assert out == "" class TestEnsembleSatisfiedContinueConditions(object): - def setup(self): + def setup_method(self): cv = paths.CoordinateFunctionCV('x', lambda x: x.xyz[0][0]) vol_A = paths.CVDefinedVolume(cv, float("-inf"), 0.0) vol_B = paths.CVDefinedVolume(cv, 1.0, float("inf")) diff --git a/paths_cli/tests/compiling/_gendocs/test_docs_generator.py b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py index 2dc694f9..943a931f 100644 --- a/paths_cli/tests/compiling/_gendocs/test_docs_generator.py +++ b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py @@ -9,7 +9,7 @@ from paths_cli.compiling.core import CategoryCompiler class TestDocsGenerator: - def setup(self): + def setup_method(self): self.required_parameter = Parameter( name="req_param", loader=None, diff --git a/paths_cli/tests/compiling/test_core.py b/paths_cli/tests/compiling/test_core.py index 86fd95f5..caade1db 100644 --- a/paths_cli/tests/compiling/test_core.py +++ b/paths_cli/tests/compiling/test_core.py @@ -25,7 +25,7 @@ def mock_named_object_factory(dct): class TestParameter: - def setup(self): + def setup_method(self): self.loader = Mock( return_value='foo', json_type='string', @@ -131,7 +131,7 @@ class TestInstanceCompilerPlugin: def _builder(req_param, opt_default=10, opt_override=100): return f"{req_param}, {opt_default}, {opt_override}" - def setup(self): + def setup_method(self): identity = lambda x: x self.parameters = [ Parameter('req_param', identity, json_type="string"), @@ -227,7 +227,7 @@ def test_call(self): class TestCategoryCompiler: - def setup(self): + def setup_method(self): self.compiler = CategoryCompiler( {'foo': mock_named_object_factory}, 'foo_compiler' diff --git a/paths_cli/tests/compiling/test_cvs.py b/paths_cli/tests/compiling/test_cvs.py index 8b378965..41f53690 100644 --- a/paths_cli/tests/compiling/test_cvs.py +++ b/paths_cli/tests/compiling/test_cvs.py @@ -15,7 +15,7 @@ class TestMDTrajFunctionCV: - def setup(self): + def setup_method(self): self.ad_pdb = data_filename("ala_small_traj.pdb") self.yml = "\n".join([ "name: phi", "type: mdtraj", "topology: " + self.ad_pdb, diff --git a/paths_cli/tests/compiling/test_engines.py b/paths_cli/tests/compiling/test_engines.py index c1348dc9..515eb3f2 100644 --- a/paths_cli/tests/compiling/test_engines.py +++ b/paths_cli/tests/compiling/test_engines.py @@ -12,7 +12,7 @@ import mdtraj as md class TestOpenMMEngineBuilder(object): - def setup(self): + def setup_method(self): self.cwd = os.getcwd() self.yml = "\n".join([ "type: openmm", "name: engine", "system: system.xml", @@ -20,7 +20,7 @@ def setup(self): "n_steps_per_frame: 10", "n_frames_max: 10000" ]) - def teardown(self): + def teardown_method(self): os.chdir(self.cwd) def _create_files(self, tmpdir): diff --git a/paths_cli/tests/compiling/test_plugins.py b/paths_cli/tests/compiling/test_plugins.py index 79b698b4..b43f1920 100644 --- a/paths_cli/tests/compiling/test_plugins.py +++ b/paths_cli/tests/compiling/test_plugins.py @@ -3,7 +3,7 @@ from unittest.mock import Mock class TestCompilerPlugin: - def setup(self): + def setup_method(self): self.plugin_class = Mock(category='foo') self.plugin = CategoryPlugin(self.plugin_class) self.aliased_plugin = CategoryPlugin(self.plugin_class, diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 0397b382..645ca2ec 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -58,7 +58,7 @@ def test_canonical_name(input_name): class TestCategoryCompilerProxy: - def setup(self): + def setup_method(self): self.compiler = CategoryCompiler(None, "foo") self.compiler.named_objs['bar'] = 'baz' self.proxy = _CategoryCompilerProxy('foo') diff --git a/paths_cli/tests/compiling/test_volumes.py b/paths_cli/tests/compiling/test_volumes.py index cc3cc408..3613fd49 100644 --- a/paths_cli/tests/compiling/test_volumes.py +++ b/paths_cli/tests/compiling/test_volumes.py @@ -14,7 +14,7 @@ from paths_cli.compiling.volumes import * class TestBuildCVVolume: - def setup(self): + def setup_method(self): self.yml = "\n".join(["type: cv-volume", "cv: {func}", "lambda_min: 0", "lambda_max: 1"]) @@ -90,7 +90,7 @@ def test_build_cv_volume(self, inline, periodic): class TestBuildCombinationVolume: - def setup(self): + def setup_method(self): from openpathsampling.experimental.storage.collective_variables \ import CollectiveVariable self.cv = CollectiveVariable(lambda s: s.xyz[0][0]).named('foo') diff --git a/paths_cli/tests/test_cli.py b/paths_cli/tests/test_cli.py index 779cc4d6..5324ec88 100644 --- a/paths_cli/tests/test_cli.py +++ b/paths_cli/tests/test_cli.py @@ -7,7 +7,7 @@ class TestOpenPathSamplingCLI(object): - def setup(self): + def setup_method(self): def make_mock(name, helpless=False, return_val=None): if return_val is None: return_val = name diff --git a/paths_cli/tests/test_file_copying.py b/paths_cli/tests/test_file_copying.py index a32408ed..bc366491 100644 --- a/paths_cli/tests/test_file_copying.py +++ b/paths_cli/tests/test_file_copying.py @@ -11,7 +11,7 @@ from paths_cli.file_copying import * class Test_PRECOMPUTE_CVS(object): - def setup(self): + def setup_method(self): self.tmpdir = tempfile.mkdtemp() self.storage_filename = os.path.join(self.tmpdir, "test.nc") self.storage = paths.Storage(self.storage_filename, mode='w') @@ -21,7 +21,7 @@ def setup(self): self.cv_y = paths.CoordinateFunctionCV("y", lambda s: s.xyz[0][1]) self.storage.save([self.cv_x, self.cv_y]) - def teardown(self): + def teardown_method(self): self.storage.close() for filename in os.listdir(self.tmpdir): @@ -53,7 +53,7 @@ def test_make_blocks(blocksize): class TestPrecompute(object): - def setup(self): + def setup_method(self): class RunOnceFunction(object): def __init__(self): self.previously_seen = set([]) diff --git a/paths_cli/tests/test_parameters.py b/paths_cli/tests/test_parameters.py index e495ad8a..b748fbe6 100644 --- a/paths_cli/tests/test_parameters.py +++ b/paths_cli/tests/test_parameters.py @@ -62,7 +62,7 @@ class TestArgument(ParameterTest): # testing class ParamInstanceTest(object): - def setup(self): + def setup_method(self): pes = paths.engines.toy.Gaussian(1, [1.0, 1.0], [0.0, 0.0]) integ = paths.engines.toy.LangevinBAOABIntegrator(0.01, 0.1, 2.5) topology = paths.engines.toy.Topology(n_spatial=2, n_atoms=1, @@ -129,7 +129,7 @@ def _getter_test(self, getter): assert obj.__uuid__ == self.obj.__uuid__ assert obj == self.obj - def teardown(self): + def teardown_method(self): for temp_f in os.listdir(self.tempdir): os.remove(os.path.join(self.tempdir, temp_f)) os.rmdir(self.tempdir) @@ -137,8 +137,8 @@ def teardown(self): class TestENGINE(ParamInstanceTest): PARAMETER = ENGINE - def setup(self): - super(TestENGINE, self).setup() + def setup_method(self): + super(TestENGINE, self).setup_method() self.get_arg = {'name': 'engine', 'number': 0, 'only': None, 'only-named': None} self.obj = self.engine @@ -162,8 +162,8 @@ def test_cannot_guess(self): class TestSCHEME(ParamInstanceTest): PARAMETER = SCHEME - def setup(self): - super(TestSCHEME, self).setup() + def setup_method(self): + super(TestSCHEME, self).setup_method() self.get_arg = {'name': 'scheme', 'number': 0, 'only': None, 'only-named': None, 'bad-name': 'foo'} self.obj = self.scheme @@ -183,8 +183,8 @@ def test_bad_get(self): class TestINIT_CONDS(ParamInstanceTest): PARAMETER = INIT_CONDS - def setup(self): - super(TestINIT_CONDS, self).setup() + def setup_method(self): + super(TestINIT_CONDS, self).setup_method() self.traj = make_1d_traj([-0.1, 1.0, 4.4, 7.7, 10.01]) ensemble = self.scheme.network.sampling_ensembles[0] self.sample_set = paths.SampleSet([ @@ -300,8 +300,8 @@ def test_cannot_guess(self): class TestINIT_SNAP(ParamInstanceTest): PARAMETER = INIT_SNAP - def setup(self): - super(TestINIT_SNAP, self).setup() + def setup_method(self): + super(TestINIT_SNAP, self).setup_method() traj = make_1d_traj([1.0, 2.0]) self.other_snap = traj[0] self.init_snap = traj[1] @@ -360,8 +360,8 @@ def _getter_test(self, getter): class TestCVS(MultiParamInstanceTest): PARAMETER = CVS - def setup(self): - super(TestCVS, self).setup() + def setup_method(self): + super(TestCVS, self).setup_method() self.get_arg = {'name': ["x"], 'number': [0]} self.obj = self.cv @@ -372,8 +372,8 @@ def test_get(self, getter): class TestSTATES(MultiParamInstanceTest): PARAMETER = STATES - def setup(self): - super(TestSTATES, self).setup() + def setup_method(self): + super(TestSTATES, self).setup_method() self.get_arg = {'name': ["A"], 'number': [0]} self.obj = self.state_A @@ -411,32 +411,32 @@ class TestMULTI_VOLUME(TestSTATES, MULTITest): class TestMULTI_ENGINE(MULTITest): PARAMETER = MULTI_ENGINE - def setup(self): - super(TestMULTI_ENGINE, self).setup() + def setup_method(self): + super(TestMULTI_ENGINE, self).setup_method() self.get_arg = {'name': ["engine"], 'number': [0]} self.obj = self.engine class TestMulti_NETWORK(MULTITest): PARAMETER = MULTI_NETWORK - def setup(self): - super(TestMulti_NETWORK, self).setup() + def setup_method(self): + super(TestMulti_NETWORK, self).setup_method() self.get_arg = {'name': ['network'], 'number': [0]} self.obj = self.network class TestMULTI_SCHEME(MULTITest): PARAMETER = MULTI_SCHEME - def setup(self): - super(TestMULTI_SCHEME, self).setup() + def setup_method(self): + super(TestMULTI_SCHEME, self).setup_method() self.get_arg = {'name': ['scheme'], 'number': [0]} self.obj = self.scheme class TestMULTI_TAG(MULTITest): PARAMETER = MULTI_TAG - def setup(self): - super(TestMULTI_TAG, self).setup() + def setup_method(self): + super(TestMULTI_TAG, self).setup_method() self.obj = make_1d_traj([1.0, 2.0, 3.0]) self.get_arg = {'name': ['traj']} diff --git a/paths_cli/tests/test_plugin_management.py b/paths_cli/tests/test_plugin_management.py index 8715d24b..708e807e 100644 --- a/paths_cli/tests/test_plugin_management.py +++ b/paths_cli/tests/test_plugin_management.py @@ -17,7 +17,7 @@ def test_ops_plugin(): assert plugin.requires_lib == (1, 2) class PluginLoaderTest(object): - def setup(self): + def setup_method(self): self.expected_section = {'pathsampling': "Simulation", 'contents': "Miscellaneous"} @@ -51,8 +51,8 @@ def test_call(self, command): class TestFilePluginLoader(PluginLoaderTest): - def setup(self): - super().setup() + def setup_method(self): + super().setup_method() # use our own commands dir as a file-based plugin cmds_init = pathlib.Path(paths_cli.commands.__file__).resolve() self.commands_dir = cmds_init.parent @@ -64,8 +64,8 @@ def _make_candidate(self, command): class TestNamespacePluginLoader(PluginLoaderTest): - def setup(self): - super().setup() + def setup_method(self): + super().setup_method() self.namespace = "paths_cli.commands" self.loader = NamespacePluginLoader(self.namespace, OPSCommandPlugin) self.plugin_type = 'namespace' diff --git a/paths_cli/tests/test_utils.py b/paths_cli/tests/test_utils.py index 1d43c6ff..99924778 100644 --- a/paths_cli/tests/test_utils.py +++ b/paths_cli/tests/test_utils.py @@ -1,7 +1,7 @@ from paths_cli.utils import * class TestOrderedSet: - def setup(self): + def setup_method(self): self.set = OrderedSet(['a', 'b', 'a', 'c', 'd', 'c', 'd']) def test_len(self): diff --git a/paths_cli/tests/wizard/test_helper.py b/paths_cli/tests/wizard/test_helper.py index d31c63ce..099d7ec2 100644 --- a/paths_cli/tests/wizard/test_helper.py +++ b/paths_cli/tests/wizard/test_helper.py @@ -16,7 +16,7 @@ def test_force_exit(): force_exit("foo", None) class TestEvalHelperFunc: - def setup(self): + def setup_method(self): self.param_helper = { 'str': "help_string", 'method': lambda help_args, context: f"help_{help_args}" @@ -39,7 +39,7 @@ def test_call_eval(self, helper_type): assert help_func("eval") == _LONG_EVAL_HELP class TestHelper: - def setup(self): + def setup_method(self): self.helper = Helper(help_func=lambda s, ctx: s) def test_help_string(self): diff --git a/paths_cli/tests/wizard/test_parameters.py b/paths_cli/tests/wizard/test_parameters.py index f4774e08..473be90e 100644 --- a/paths_cli/tests/wizard/test_parameters.py +++ b/paths_cli/tests/wizard/test_parameters.py @@ -15,7 +15,7 @@ class TestWizardParameter: def _reverse(string): return "".join(reversed(string)) - def setup(self): + def setup_method(self): self.parameter = WizardParameter( name='foo', ask="How should I {do_what}?", @@ -79,7 +79,7 @@ def test_from_proxy_call_existing(self): class TestFromWizardPrerequisite: - def setup(self): + def setup_method(self): # For this model, user input should be a string that represents an # integer. The self._create method repeats the input string, e.g., # "1" => "11", and wraps the result in self.Wrapper. This is the diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index 7c333e98..cb063f56 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -80,7 +80,7 @@ def method(wizard, context, *args, **kwargs): class TestWizardObjectPlugin: - def setup(self): + def setup_method(self): self.plugin = WizardObjectPlugin( name="foo", category="foo_category", @@ -133,7 +133,7 @@ def __init__(self, foo, bar): self.foo = foo self.bar = bar - def setup(self): + def setup_method(self): self.parameters = [ WizardParameter(name="foo", ask="Gimme a foo!", @@ -191,7 +191,7 @@ def test_from_proxies(self): class TestCategoryHelpFunc: - def setup(self): + def setup_method(self): self.category = WrapCategory("foo", "ask foo") self.plugin = WizardObjectPlugin( name="bar", @@ -247,7 +247,7 @@ def test_bad_arg(self, input_type): class TestWrapCategory: - def setup(self): + def setup_method(self): self.wrapper = WrapCategory("foo", "ask foo", intro="intro_foo") self.plugin_no_format = WizardObjectPlugin( name="bar", diff --git a/paths_cli/tests/wizard/test_wizard.py b/paths_cli/tests/wizard/test_wizard.py index b377c568..18ee6113 100644 --- a/paths_cli/tests/wizard/test_wizard.py +++ b/paths_cli/tests/wizard/test_wizard.py @@ -57,7 +57,7 @@ def save(self, obj): class TestWizard: - def setup(self): + def setup_method(self): self.wizard = Wizard([]) def test_initialization(self): From e7f41495b37c8831b04e7e102c880edbd485683e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 23 Apr 2024 11:32:12 -0500 Subject: [PATCH 234/251] use codecov v4; add token --- .github/workflows/test-suite.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 46fd0512..b0091fec 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -70,6 +70,8 @@ jobs: run: | python -c "import paths_cli" py.test -vv --cov --cov-report xml:cov.xml - - uses: codecov/codecov-action@v2 + - uses: codecov/codecov-action@v4 with: files: ./cov.xml + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} From 4c7fada54065913c39f37f5ba810fda369d29f3e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 23 Apr 2024 17:59:20 -0500 Subject: [PATCH 235/251] Update badge; LICENSE --- LICENSE | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/LICENSE b/LICENSE index d39abc8b..2afa3640 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019-2022 David W.H. Swenson and contributors +Copyright (c) 2019-2024 David W.H. Swenson and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c3369ec9..0bee666f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Tests](https://github.com/openpathsampling/openpathsampling-cli/workflows/Tests/badge.svg)](https://github.com/openpathsampling/openpathsampling-cli/actions?query=workflow%3ATests) +[![Tests](https://github.com/openpathsampling/openpathsampling-cli/actions/workflows/test-suite.yml/badge.svg)](https://github.com/openpathsampling/openpathsampling-cli/actions/workflows/test-suite.yml) [![Documentation Status](https://readthedocs.org/projects/openpathsampling-cli/badge/?version=latest)](https://openpathsampling-cli.readthedocs.io/en/latest/?badge=latest) [![Coverage Status](https://codecov.io/gh/openpathsampling/openpathsampling-cli/branch/master/graph/badge.svg)](https://codecov.io/gh/openpathsampling/openpathsampling-cli) [![Maintainability](https://api.codeclimate.com/v1/badges/0d1ee29e1a05cfcdc01a/maintainability)](https://codeclimate.com/github/openpathsampling/openpathsampling-cli/maintainability) From ffbb30616d616c24370bfd351bc4902c0b44ddb0 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sat, 4 May 2024 20:18:53 -0500 Subject: [PATCH 236/251] Add help to compile; make it visible in CLI help --- paths_cli/commands/compile.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index a1e0253b..718a8a03 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -68,10 +68,18 @@ def register_installed_plugins(): @click.command( 'compile', + short_help="compile a description of OPS objects into a database", ) @click.argument('input_file') @OUTPUT_FILE.clicked(required=True) def compile_(input_file, output_file): + """Compile JSON or YAML description of OPS objects into a database. + + INPUT_FILE is a JSON or YAML file that describes OPS simulation + objects (e.g., MD engines, state volumes, etc.). The output will be an + OPS database containing those objects, which can be used as the input to + many other CLI subcommands. + """ loader = select_loader(input_file) with open(input_file, mode='r') as f: dct = loader(f) @@ -87,7 +95,7 @@ def compile_(input_file, output_file): PLUGIN = OPSCommandPlugin( command=compile_, - section="Debug", + section="Miscellaneous", requires_ops=(1, 0), requires_cli=(0, 3) ) From edc55ad18c974c658c3a2f10e04c078e9b0cc800 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 5 May 2024 00:09:59 -0500 Subject: [PATCH 237/251] Add "Simulation Setup" section --- paths_cli/cli.py | 3 ++- paths_cli/commands/compile.py | 2 +- paths_cli/compiling/_gendocs/docs_generator.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/paths_cli/cli.py b/paths_cli/cli.py index cb9dac2c..34268785 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -61,7 +61,8 @@ def get_command(self, ctx, name): return self._get_command.get(name) def format_commands(self, ctx, formatter): - sec_order = ['Simulation', 'Analysis', 'Miscellaneous', 'Workflow'] + sec_order = ["Simulation Setup", 'Simulation', 'Analysis', + 'Miscellaneous', 'Workflow'] for sec in sec_order: cmds = self._sections.get(sec, []) rows = [] diff --git a/paths_cli/commands/compile.py b/paths_cli/commands/compile.py index 718a8a03..f748aeb9 100644 --- a/paths_cli/commands/compile.py +++ b/paths_cli/commands/compile.py @@ -95,7 +95,7 @@ def compile_(input_file, output_file): PLUGIN = OPSCommandPlugin( command=compile_, - section="Miscellaneous", + section="Simulation Setup", requires_ops=(1, 0), requires_cli=(0, 3) ) diff --git a/paths_cli/compiling/_gendocs/docs_generator.py b/paths_cli/compiling/_gendocs/docs_generator.py index 2ec89b3b..a80bb813 100644 --- a/paths_cli/compiling/_gendocs/docs_generator.py +++ b/paths_cli/compiling/_gendocs/docs_generator.py @@ -54,7 +54,7 @@ def generate_category_rst(self, category_plugin): rst = f".. _compiling--{category_plugin.label}:\n\n" rst += f"{cat_info.header}\n{'=' * len(str(cat_info.header))}\n\n" if cat_info.description: - rst += cat_info.description + "\n\n" + rst += cat_info.description + "The following types are available:\n\n" rst += ".. contents:: :local:\n\n" for obj in category_plugin.type_dispatch.values(): rst += self.generate_plugin_rst( From 78f967b36cf800775be03c4ebfc0d5e4b318a47c Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Thu, 9 May 2024 01:05:54 -0500 Subject: [PATCH 238/251] fix tests --- paths_cli/tests/compiling/_gendocs/test_docs_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paths_cli/tests/compiling/_gendocs/test_docs_generator.py b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py index 943a931f..21c8b168 100644 --- a/paths_cli/tests/compiling/_gendocs/test_docs_generator.py +++ b/paths_cli/tests/compiling/_gendocs/test_docs_generator.py @@ -50,7 +50,7 @@ def setup_method(self): ".. _compiling--category:", "Header\n======\n", ".. contents:: :local:", - "\ncategory_desc\n", + "\ncategory_descThe following types are available:\n", ] @pytest.mark.parametrize('param_type', ['req', 'opt']) From 3867c5b12ac2529f7024d2bc3e79935118ed697e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 9 Jun 2024 18:40:23 -0500 Subject: [PATCH 239/251] Silence annoying pymbar warnings --- paths_cli/cli.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/paths_cli/cli.py b/paths_cli/cli.py index cb9dac2c..1e646863 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -94,6 +94,12 @@ def main(log): logging.config.fileConfig(log, disable_existing_loggers=False) # TODO: if log not given, check for logging.conf in .openpathsampling/ + silence_warnings = ['pymbar.mbar_solvers', 'pymbar.timeseries'] + for lname in silence_warnings: + logger = logging.getLogger(lname) + logger.setLevel(logging.CRITICAL) + + logger = logging.getLogger(__name__) logger.debug("About to run command") # TODO: maybe log invocation? From 23f8ff83b49fa8daeb4beef99682dbd21df2d68a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 9 Jun 2024 18:52:20 -0500 Subject: [PATCH 240/251] SPEC0/NEP29: Add Python 3.12, drop Python 3.9 --- .github/workflows/test-suite.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index b0091fec..f9c43b04 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -24,12 +24,12 @@ jobs: strategy: matrix: CONDA_PY: + - "3.12" - "3.11" - "3.10" - - "3.9" INTEGRATIONS: [""] include: - - CONDA_PY: "3.11" + - CONDA_PY: "3.12" INTEGRATIONS: 'all-optionals' steps: From 7ec200a250df548f2a8579cb4c8aaa2d57440f4e Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Sun, 9 Jun 2024 18:59:19 -0500 Subject: [PATCH 241/251] called_once_with => assert_called_once_with --- paths_cli/tests/compiling/test_root_compiler.py | 4 ++-- paths_cli/tests/wizard/test_plugin_classes.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/paths_cli/tests/compiling/test_root_compiler.py b/paths_cli/tests/compiling/test_root_compiler.py index 645ca2ec..ba068d4c 100644 --- a/paths_cli/tests/compiling/test_root_compiler.py +++ b/paths_cli/tests/compiling/test_root_compiler.py @@ -247,8 +247,8 @@ def test_register_plugins_unit(foo_compiler_plugin, foo_baz_builder_plugin): with patch(BASE + "_register_builder_plugin", Mock()) as builder, \ patch(BASE + "_register_compiler_plugin", Mock()) as compiler: register_plugins([foo_baz_builder_plugin, foo_compiler_plugin]) - assert builder.called_once_with(foo_baz_builder_plugin) - assert compiler.called_once_with(foo_compiler_plugin) + builder.assert_called_once_with(foo_baz_builder_plugin) + compiler.assert_called_once_with(foo_compiler_plugin) def test_register_plugins_integration(foo_compiler_plugin, diff --git a/paths_cli/tests/wizard/test_plugin_classes.py b/paths_cli/tests/wizard/test_plugin_classes.py index cb063f56..27f4ab2b 100644 --- a/paths_cli/tests/wizard/test_plugin_classes.py +++ b/paths_cli/tests/wizard/test_plugin_classes.py @@ -38,7 +38,7 @@ def test_call(self): with mock.patch(patch_loc, mock_load): result = loader(wiz) - assert mock_load.called_once_with(wiz, "foos", "foo") + mock_load.assert_called_once_with(wiz, "foos", "foo") assert result == "some_object" From 1a9c964b81fa581ffc9b114d283604e245b93451 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 20:47:39 -0500 Subject: [PATCH 242/251] add TODO to remove when warnings not triggered --- paths_cli/cli.py | 1 + 1 file changed, 1 insertion(+) diff --git a/paths_cli/cli.py b/paths_cli/cli.py index 1e646863..c9c4d2ae 100644 --- a/paths_cli/cli.py +++ b/paths_cli/cli.py @@ -94,6 +94,7 @@ def main(log): logging.config.fileConfig(log, disable_existing_loggers=False) # TODO: if log not given, check for logging.conf in .openpathsampling/ + # TODO: remove when openmmtools doesn't trigger these warnings silence_warnings = ['pymbar.mbar_solvers', 'pymbar.timeseries'] for lname in silence_warnings: logger = logging.getLogger(lname) From 315128c1b4c3bf3988a035b5d6716c1296610208 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:30:44 -0500 Subject: [PATCH 243/251] Release 0.3 --- setup.cfg | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0b67e88d..6737f0f4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = openpathsampling-cli -version = 0.2.2.dev0 +version = 0.3 # version should end in .dev0 if this isn't to be released description = Command line tool for OpenPathSampling long_description = file: README.md @@ -18,16 +18,13 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >= 3.7 +python_requires = >= 3.9 install_requires = click tqdm - openpathsampling >= 1.2 + openpathsampling >= 1.6 packages = find: [options.entry_points] console_scripts = openpathsampling = paths_cli.cli:main - -[bdist_wheel] -universal=1 From f514224081d5ee1a268976a39ae61efcc1c8241a Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:36:52 -0500 Subject: [PATCH 244/251] update autorelease --- .github/workflows/autorelease-default-env.sh | 4 ++- .github/workflows/autorelease-deploy.yml | 11 ++++++-- .github/workflows/autorelease-gh-rel.yml | 13 +++++++-- .github/workflows/autorelease-prep.yml | 29 ++++++++++++++++---- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/.github/workflows/autorelease-default-env.sh b/.github/workflows/autorelease-default-env.sh index f1da159b..70cf6fb9 100644 --- a/.github/workflows/autorelease-default-env.sh +++ b/.github/workflows/autorelease-default-env.sh @@ -1,4 +1,6 @@ -INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.6" +# Vendored from Autorelease 0.5.1 +# Update by updating Autorelease and running `autorelease vendor actions` +INSTALL_AUTORELEASE="python -m pip install autorelease==0.5.1" if [ -f autorelease-env.sh ]; then source autorelease-env.sh fi diff --git a/.github/workflows/autorelease-deploy.yml b/.github/workflows/autorelease-deploy.yml index 9a8f03bc..491d871f 100644 --- a/.github/workflows/autorelease-deploy.yml +++ b/.github/workflows/autorelease-deploy.yml @@ -1,10 +1,13 @@ -name: Autorelease +# Vendored from Autorelease 0.5.1 +# Update by updating Autorelease and running `autorelease vendor actions` +name: "Autorelease Deploy" on: release: types: [published] jobs: deploy_pypi: + if: ${{ github.repository == 'openpathsampling/openpathsampling-cli' }} runs-on: ubuntu-latest name: "Deploy to PyPI" steps: @@ -17,7 +20,11 @@ jobs: if [ -f "autorelease-env.sh" ]; then cat autorelease-env.sh >> $GITHUB_ENV fi - eval $INSTALL_AUTORELEASE + if [ -f "./.autorelease/install-autorelease" ]; then + source ./.autorelease/install-autorelease + else + eval $INSTALL_AUTORELEASE + fi name: "Install autorelease" - run: | python -m pip install twine wheel diff --git a/.github/workflows/autorelease-gh-rel.yml b/.github/workflows/autorelease-gh-rel.yml index bb5cd276..99f3728f 100644 --- a/.github/workflows/autorelease-gh-rel.yml +++ b/.github/workflows/autorelease-gh-rel.yml @@ -1,11 +1,15 @@ -name: Autorelease +# Vendored from Autorelease 0.5.1 +# Update by updating Autorelease and running `autorelease vendor actions` +name: "Autorelease Release" on: push: branches: + # TODO: this should come from yaml conf - stable jobs: release-gh: + if: ${{ github.repository == 'openpathsampling/openpathsampling-cli' }} runs-on: ubuntu-latest name: "Cut release" steps: @@ -18,7 +22,11 @@ jobs: if [ -f "autorelease-env.sh" ]; then cat autorelease-env.sh >> $GITHUB_ENV fi - eval $INSTALL_AUTORELEASE + if [ -f "./.autorelease/install-autorelease" ]; then + source ./.autorelease/install-autorelease + else + eval $INSTALL_AUTORELEASE + fi name: "Install autorelease" - run: | VERSION=`python setup.py --version` @@ -27,3 +35,4 @@ jobs: autorelease-release --project $PROJECT --version $VERSION --token $AUTORELEASE_TOKEN env: AUTORELEASE_TOKEN: ${{ secrets.AUTORELEASE_TOKEN }} + name: "Cut release" diff --git a/.github/workflows/autorelease-prep.yml b/.github/workflows/autorelease-prep.yml index 49674133..4c5f21a9 100644 --- a/.github/workflows/autorelease-prep.yml +++ b/.github/workflows/autorelease-prep.yml @@ -1,7 +1,10 @@ -name: "Autorelease" +# Vendored from Autorelease 0.5.1 +# Update by updating Autorelease and running `autorelease vendor actions` +name: "Autorelease testpypi" on: pull_request: branches: + # TODO: this should come from yaml conf - stable defaults: @@ -10,6 +13,7 @@ defaults: jobs: deploy_testpypi: + if: ${{ github.repository == 'openpathsampling/openpathsampling-cli' }} runs-on: ubuntu-latest name: "Deployment test" steps: @@ -22,7 +26,11 @@ jobs: if [ -f "autorelease-env.sh" ]; then cat autorelease-env.sh >> $GITHUB_ENV fi - eval $INSTALL_AUTORELEASE + if [ -f "./.autorelease/install-autorelease" ]; then + source ./.autorelease/install-autorelease + else + eval $INSTALL_AUTORELEASE + fi name: "Install autorelease" - run: | python -m pip install twine wheel @@ -41,6 +49,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ name: "Deploy to testpypi" test_testpypi: + if: ${{ github.repository == 'openpathsampling/openpathsampling-cli' }} runs-on: ubuntu-latest name: "Test deployed" needs: deploy_testpypi @@ -54,7 +63,17 @@ jobs: if [ -f "autorelease-env.sh" ]; then cat autorelease-env.sh >> $GITHUB_ENV fi - eval $INSTALL_AUTORELEASE + if [ -f "./.autorelease/install-autorelease" ]; then + source ./.autorelease/install-autorelease + else + eval $INSTALL_AUTORELEASE + fi name: "Install autorelease" - - run: test-testpypi - + - name: "Install testpypi version" + run: install-testpypi + - name: "Test testpypi version" + run: | + if [ -f "autorelease-env.sh" ]; then + cat autorelease-env.sh >> $GITHUB_ENV + fi + test-testpypi From 025eb906ffdaf30155ef109632ce6a2e1dd9be4b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:37:06 -0500 Subject: [PATCH 245/251] update version to 0.3.0 (need the patch) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 6737f0f4..c9e269dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = openpathsampling-cli -version = 0.3 +version = 0.3.0 # version should end in .dev0 if this isn't to be released description = Command line tool for OpenPathSampling long_description = file: README.md From f75dc5e760b65382d91b019ded85e9869d6ce7fa Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:41:06 -0500 Subject: [PATCH 246/251] hack to add setuptools --- .github/workflows/autorelease-prep.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/autorelease-prep.yml b/.github/workflows/autorelease-prep.yml index 4c5f21a9..c98e09a5 100644 --- a/.github/workflows/autorelease-prep.yml +++ b/.github/workflows/autorelease-prep.yml @@ -33,7 +33,7 @@ jobs: fi name: "Install autorelease" - run: | - python -m pip install twine wheel + python -m pip install twine wheel setuptools name: "Install release tools" - run: | bump-dev-version From c919c3bd3b39803f5ab75293674d851a2da87af3 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:43:58 -0500 Subject: [PATCH 247/251] update the autorelease version here, too --- autorelease-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autorelease-env.sh b/autorelease-env.sh index 9074cb38..f8c51825 100644 --- a/autorelease-env.sh +++ b/autorelease-env.sh @@ -1,2 +1,2 @@ -INSTALL_AUTORELEASE="python -m pip install autorelease==0.2.3 nose sqlalchemy dill" +INSTALL_AUTORELEASE="python -m pip install autorelease==0.5.1 nose sqlalchemy dill" PACKAGE_IMPORT_NAME=paths_cli From 24df36f1cd79ba5b040c03397e99484112ee573b Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:44:25 -0500 Subject: [PATCH 248/251] switch to a version that exists --- autorelease-env.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/autorelease-env.sh b/autorelease-env.sh index f8c51825..63249f80 100644 --- a/autorelease-env.sh +++ b/autorelease-env.sh @@ -1,2 +1,2 @@ -INSTALL_AUTORELEASE="python -m pip install autorelease==0.5.1 nose sqlalchemy dill" +INSTALL_AUTORELEASE="python -m pip install autorelease==0.5 nose sqlalchemy dill" PACKAGE_IMPORT_NAME=paths_cli From 2f83dffde53bc42f924d9fef1140638cd433bfe2 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 21:54:42 -0500 Subject: [PATCH 249/251] I figured we got numpy as part of OPS? maybe not? --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index c9e269dc..f0de3480 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,6 +22,7 @@ python_requires = >= 3.9 install_requires = click tqdm + numpy openpathsampling >= 1.6 packages = find: From 46afbf59e98d495f406230bda8a4e553f8b50db6 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Mon, 10 Jun 2024 22:01:31 -0500 Subject: [PATCH 250/251] look at the error that caused the error. revert this was not needed --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index f0de3480..c9e269dc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,7 +22,6 @@ python_requires = >= 3.9 install_requires = click tqdm - numpy openpathsampling >= 1.6 packages = find: From 5b341e574441b17f8068fdbcc7b6173606496667 Mon Sep 17 00:00:00 2001 From: "David W.H. Swenson" Date: Tue, 11 Jun 2024 21:03:01 -0500 Subject: [PATCH 251/251] Update setup.cfg Co-authored-by: Sander Roet --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index c9e269dc..156d8c7d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >= 3.9 +python_requires = >= 3.10 install_requires = click tqdm