Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix pre- and post-hooks #133

Merged
merged 20 commits into from
Mar 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
- macos: py311-xdist
- linux: py311-cov-xdist
coverage: 'codecov'
- linux: py311-downstreamdeps-cov-xdist
coverage: 'codecov'
- linux: py312-xdist-nolegacypath
test_downstream:
uses: OpenAstronomy/github-actions-workflows/.github/workflows/tox.yml@v1
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
``get_crds_parameters`` [#142]
- Fix bug in handling of ``pathlib.Path`` objects as ``Step`` inputs [#143]
- Log readable Step parameters [#140]
- Fix handling of functions and subprocesses as Step pre- and post-hooks. Add
ability to pass imported python functions and ``Step`` subclasses directly as
hooks. And allow ``Step`` subclass instances with set parameters as hooks. [#133]

0.5.1 (2023-10-02)
==================
Expand Down
2 changes: 1 addition & 1 deletion src/stpipe/config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ def _output_file_check(path):


def _is_datamodel(value):
"""Verify that value is either is a DataModel."""
"""Verify that value is a DataModel."""
if isinstance(value, AbstractDataModel):
return value

Expand Down
8 changes: 6 additions & 2 deletions src/stpipe/function_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,12 @@ class FunctionWrapper(Step):
This Step wraps an ordinary Python function.
"""

def __init__(self, func, *args):
Step.__init__(self, func.__name__, *args)
spec = """
output_ext = string(default="fits")
"""

def __init__(self, func, *args, **kwargs):
Step.__init__(self, func.__name__, *args, **kwargs)

self._func = func

Expand Down
110 changes: 89 additions & 21 deletions src/stpipe/hooks.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,108 @@
"""
Pre- and post-hooks
"""
import contextlib
import types
import ast
import inspect

from . import utilities
from . import function_wrapper, utilities
from .step import Step


def hook_from_string(step, type, num, command): # noqa: A002
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be deprecated instead of removed? It looks like it's been available for a long time.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not removed, I've renamed the hook_from_string() function to hook_from_string_or_class() to better describe what it actually does. Happy to change the name back if you think this will affect the public API. Since this is all internal workings, I would rather make all this private, including the whole hooks.py module, but happy to maintain backwards compat.

If not maintaining backwards compat, maybe "hook_from_object()` would be a better function name.

stpipe suffers from a lot of its internal workings, such as Step.run(), being public functions.

As far as I can tell, a search of Github doesn't turn up any uses of stpipe.hooks outside of stpipe.

Copy link
Contributor Author

@jdavies-st jdavies-st Mar 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone ahead and changed the function name back to its original name, and just clarified in the docstring what it does. We can revisit moving public functions and methods to be private in a future refactor.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! And I agree it's unlikely anyone uses it.

name = f"{type}_hook{num:d}"
def hook_from_string(step, hooktype, num, command):
"""
Generate hook from string, function, Step or Step instance

step_class = None
with contextlib.suppress(Exception):
step_class = utilities.import_class(command, Step, step.config_file)
Parameters
----------
step : `stpipe.step.Step`
Parent step instance to which this hook is attached, i.e. "self"
hooktype : str, "pre" or "post"
Type of hook pre-hook , or post-hook
num : int
The number, in order, of the pre- or post-hook in the list
command : str or `stpipe.step.Step` instance

if step_class is not None:
return step_class(name, parent=step, config_file=step.config_file)
Returns
-------
`stpipe.step.Step`
"""
name = f"{hooktype}_hook{num:d}"

step_class = None
step_func = None
with contextlib.suppress(Exception):
step_func = utilities.import_class(
command, types.FunctionType, step.config_file
)

if step_func is not None:
from . import function_wrapper
# hook is a string of the fully-qualified name of a class or function
if isinstance(command, str):
try:
# String points to a Step subclass
step_class = utilities.import_class(
command, subclassof=Step, config_file=step.config_file
)
except ImportError:
# String is possibly a subproc, so handle this later
pass
except AttributeError:
# String points to an instance of a Step
# So import the class
class_string, _, params = command.partition("(")
step_class = utilities.import_class(
class_string, subclassof=Step, config_file=step.config_file
)
# Then convert rest of string to args and instantiate the class
kwargs_string = params.strip(")")
expr = ast.parse(f"dict({kwargs_string}\n)", mode="eval")
kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in expr.body.keywords}
return step_class(**kwargs)
except TypeError:
# String points to a function
step_func = utilities.import_func(command)
else:
if step_class.class_alias is not None:
name = step_class.class_alias
return step_class(name, parent=step, config_file=step.config_file)

# hook is a string of the fully-qualified name of a function
if step_func is not None:
return function_wrapper.FunctionWrapper(
step_func, parent=step, config_file=step.config_file
)

return function_wrapper.FunctionWrapper(
step_func, parent=step, config_file=step.config_file
)
# hook is an already-imported Step subclass
if inspect.isclass(command) and issubclass(command, Step):
step_class = command
if step_class.class_alias is not None:
name = step_class.class_alias
return step_class(name, parent=step, config_file=step.config_file)

# hook is an instance of a Step subclass
if isinstance(command, Step):
if command.class_alias is not None:
command.name = command.class_alias
else:
command.name = name

Check warning on line 82 in src/stpipe/hooks.py

View check run for this annotation

Codecov / codecov/patch

src/stpipe/hooks.py#L82

Added line #L82 was not covered by tests
return command

# hook is a command-line script or system call
from .subproc import SystemCall

return SystemCall(name, parent=step, command=command)


def get_hook_objects(step, type_, hooks):
return [hook_from_string(step, type_, i, hook) for i, hook in enumerate(hooks)]
def get_hook_objects(step, hooktype, hooks):
"""
Get list of pre- or post-hooks for a step

Parameters
----------
step : `stpipe.step.Step`
instance to which this is a hook
hooktype : str, "pre" or "post"
strings, to indicate whether it is a pre- or post-hook
hooks : str or class
path to executable script, or Step class to run as hook

Returns
-------
list of callables that can be run as a hook
"""
return [hook_from_string(step, hooktype, i, hook) for i, hook in enumerate(hooks)]
4 changes: 2 additions & 2 deletions src/stpipe/step.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class Step:
"""

spec = """
pre_hooks = string_list(default=list())
post_hooks = string_list(default=list())
pre_hooks = list(default=list()) # List of Step classes to run before step
post_hooks = list(default=list()) # List of Step classes to run after step
output_file = output_file(default=None) # File to save output to.
output_dir = string(default=None) # Directory path for output files
output_ext = string() # Default type of output
Expand Down
18 changes: 8 additions & 10 deletions src/stpipe/subproc.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import os
import subprocess

from .datamodel import AbstractDataModel
from .step import Step


Expand All @@ -11,30 +12,24 @@ class SystemCall(Step):

spec = """
# SystemCall is a step to run external processes as Steps.

# The command can pass along arguments that were passed to the step.
# To refer to positional arguments, use {0}, {1}, {2}, etc.
# To refer to keyword arguments, use {keyword}.
command = string() # The command to execute

command = string() # The command to execute
env = string_list(default=list()) # Environment variables to define

log_stdout = boolean(default=True) # Do we want to log STDOUT?

log_stderr = boolean(default=True) # Do we want to log STDERR?

exitcode_as_exception = boolean(default=True) # Should a non-zero exit code be converted into an exception?

failure_as_exception = boolean(default=True) # If subprocess fails to run at all, should that be an exception?
output_ext = string(default="fits")
""" # noqa: E501

def process(self, *args):
from .. import datamodels # noqa: TID252

newargs = []
for i, arg in enumerate(args):
if isinstance(arg, datamodels.DataModel):
filename = f"{self.qualified_name}.{i:04d}.{arg.getfileext()}"
if isinstance(arg, AbstractDataModel):
filename = f"{self.qualified_name}.{i:04d}.{self.output_ext}"
arg.save(filename)
newargs.append(filename)
else:
Expand Down Expand Up @@ -75,3 +70,6 @@ def process(self, *args):

if self.exitcode_as_exception and err != 0:
raise OSError(f"{cmd_str!r} returned error code {err}")
finally:
p.stdout.close()
p.stderr.close()
36 changes: 35 additions & 1 deletion src/stpipe/utilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import inspect
import os
import sys
import types

from . import entry_points

Expand Down Expand Up @@ -54,7 +55,7 @@ def import_class(full_name, subclassof=object, config_file=None):

try:
full_name = full_name.strip()
package_name, sep, class_name = full_name.rpartition(".")
package_name, _, class_name = full_name.rpartition(".")
if not package_name:
raise ImportError(f"{full_name} is not a Python class")
imported = __import__(
Expand Down Expand Up @@ -86,6 +87,39 @@ def import_class(full_name, subclassof=object, config_file=None):
return step_class


def import_func(full_name):
"""
Import the Python class `full_name` given in full Python package format,
e.g.::

package.subpackage.subsubpackage.function_name

Return the imported function.
"""
full_name = full_name.strip()
package_name, _, func_name = full_name.rpartition(".")
if not package_name:
raise ImportError(f"{full_name} is not a fully qualified path to function")
imported = __import__(
package_name,
globals(),
locals(),
[
func_name,
],
level=0,
)

step_func = getattr(imported, func_name)

if not isinstance(step_func, types.FunctionType):
raise TypeError(
f"Object {func_name} from package {package_name} is not a function"
)

return step_func


def get_spec_file_path(step_class):
"""
Given a Step (sub)class, divine and return the full path to the
Expand Down
15 changes: 15 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import os
from pathlib import Path

import pytest


@pytest.fixture()
def tmp_cwd(tmp_path):
"""Perform test in a pristine temporary working directory."""
old_dir = Path.cwd()
os.chdir(tmp_path)
try:
yield tmp_path
finally:
os.chdir(old_dir)
6 changes: 3 additions & 3 deletions tests/test_abstract_datamodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@

def test_roman_datamodel():
roman_datamodels = pytest.importorskip("roman_datamodels.datamodels")
import roman_datamodels.tests.util as rutil
from roman_datamodels.maker_utils import mk_level2_image

roman_image_tree = rutil.mk_level2_image()
roman_image_tree = mk_level2_image()
image_model = roman_datamodels.ImageModel(roman_image_tree)
assert isinstance(image_model, AbstractDataModel)


def test_jwst_datamodel():
jwst_datamodel = pytest.importorskip("jwst.datamodels")
jwst_datamodel = pytest.importorskip("stdatamodels.jwst.datamodels")
image_model = jwst_datamodel.ImageModel()
assert isinstance(image_model, AbstractDataModel)

Expand Down
Loading