First, the context:
I'm working with a student to build a web frontend for a star cluster simulation package called starlab. Typical starlab
usage involves chaining sequences of commands together with Unix pipes. We want to store both the output and the set of commands (and arguments) used to generate the output in a database.
The specific piece of the puzzle I'm asking about here is representing the commands. Each command has a different set of arguments, some of which take values and others don't. Additionally, some of the commands have arguments for which defaults aren't supplied, so they need to be included.
Since the commands form a predefined, discrete set, it seemed appropriate to use an Enum. Currently, I keep the commands and their arguments separate (stored in the Run object as tuples). Typical usage would look something like:
foo = Run()
foo.set_creation_command(StarlabCreationCommand.makeking, **makeking_args)
foo.set_transform_command(StarlabTransformationCommand.scale, **scale_args)
...
commands = foo.generate_command_list()
My questions:
- Does this seem like a reasonable approach to this problem?
- Should I roll the argument handling into the
StarlabCommand
Enum class, rather than storing the arguments in a tuple/dictionary? - Is there anything here that could be improved?
The code:
from enum import Enum
class StarlabCommand(Enum):
"""Enumerator class for starlab commands.
Each member takes three parameters:
1. A dictionary of required arguments (with default values),
2. A list of optional arguments that take a value, and
3. A list of optional arguments that don't take a value.
If there are parameters which are not, strictly speaking, required
(i.e., the underlying starlab command will execute without them being
supplied) but I want to make sure they get into the database, I will
include them in the required list. The most common example of this is
random seed for those commands that use one.
"""
def __init__(self, required, with_value, without_value):
"""Initialize."""
self.required = required
self.with_value = with_value
self.without_value = without_value
def build_command(self, **cmd_args):
"""Build a command list suitable for passing to subprocess.Run()"""
command_list = [self.name]
for arg, default in self.required.items():
val = cmd_args.get(arg, default)
command_list.extend(['-'+arg, val])
for arg in self.with_value:
val = cmd_args.get(arg, None)
if val is not None:
command_list.extend(['-'+arg, val])
for arg in self.without_value:
val = cmd_args.get(arg, False)
if val:
command_list.append('-'+arg)
return command_list
class StarlabCreationCommand(StarlabCommand):
"""Starlab cluster creation commands.
The required args dictionaries here don't include the number of stars or the
random seed, which are required of all these commands and are passed in the
same way in all cases.
"""
makesphere = ({}, ['R'], list('ilouU'))
makecube = ({}, ['L'], list('ilou'))
makeplummer = ({}, ['m', 'r'], list('iRou'))
makeking = ({'w':5.0}, ['b'], list('iou'))
def __init__(self, required, with_value, without_value):
"""Initialize.
All creation methods require a number of stars, and I'm adding
random seed to the required list.
"""
super().__init__(required, with_value, without_value)
self.required['n'] = 500
self.required['s'] = 123456789
class StarlabTransformationCommand(StarlabCommand):
"""Starlab cluster transformation commands.
There are more than these, but these are the ones we're supporting for now.
Unlike the case of creation commands, not all of these require a random seed
(and in fact, scale uses the s flag for something else) so I can't just put
that into an __init__() method.
"""
makemass = ({'e':-2.35, 'f':1, 's':123456789}, list('hlu'), ['i', 'm'])
makesecondary = ({'s':123456789}, list('flmMu'), list('iIqS'))
scale = ({}, list('eEmqr'), ['c', 's'])
makebinary = ({'s':123456789}, list('felou'), [])
class StarlabIntegrationCommand(StarlabCommand):
"""Time integration"""
kira = ({'d':1, 's':123456789, 't':10},
list('bDefFgGhIkKlLnNqRTWXyzZ'),
list('aABEioOrSuUvx'))
class ArchivedRun(Base):
"""Class for archiving Run objects via SQLAlchemy."""
__tablename__ = "runs"
run_id = Column(Integer, primary_key=True)
creation_command_string = Column(String, length=80)
creation_command = Column(Enum(StarlabCreationCommand))
creation_command_args = Column(String, length=80)
random_seed = Column(Integer)
n_stars = Column(Integer)
creation_scale_1 = Column(Float)
creation_scale_2 = Column(Float)
def __repr__(self):
return "<Run %d>" % self.run_id
class Run(object):
"""Metadata for a cluster simulation."""
def __init__(self, random_seed=None, nstars=500):
"""Initialize."""
self.creation_command = (None, None)
self.transform_commands = []
self.integration_command = (None, None)
if random_seed is None:
self.random_seed = uuid.uuid4().time_low
else:
self.random_seed = random_seed
self.nstars = nstars
def set_creation_command(self, creation_command, **args):
"""Set the creation command."""
self.creation_command = (creation_command, args)
self.nstars = args.get('n', self.nstars)
self.random_seed = args.get('s', self.random_seed)
self.creation_command[0].required['n'] = self.nstars
self.creation_command[0].required['s'] = self.random_seed
def add_transform_command(self, transform_command, **args):
"""Add a transformation command.
There are some special caveats here:
1. If we're making a cluster that includes binaries, both makesecondary
and makebinary are required, in that order.
2. If we're additionally using scale, it should happen between the
makesecondary and makebinary commands
"""
if 's' in transform_command.required.keys():
transform_command.required['s'] = args.get('s', self.random_seed)
self.transform_commands.append((transform_command, args))
def set_integration_command(self, integration_command, **args):
"""Set the integration command and its arguments."""
self.integration_command = (integration_command, args)
def generate_command_list(self):
"""Build the list of commands for execution by Popen."""
all_commands = [self.creation_command]
all_commands.extend(self.transform_commands)
all_commands.append(self.integration_command)
command_list = [cmd[0].build_command(cmd[1]) for cmd in all_commands]
return command_list