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

Feature: Add LogOperations to execution hooks #661

Open
wants to merge 48 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
f4f5cb7
Added log operations.
klywang Aug 2, 2022
3999fe3
Added LogOperations to __init__
klywang Aug 2, 2022
8e4d989
Added tests for LogOperations.
klywang Aug 2, 2022
dfcf5ab
Added project definitions for log hook tests.
klywang Aug 2, 2022
c312cdb
Merge branch 'master' into feature/hooks-LogOperation
klywang Sep 21, 2022
5f12826
refactor: LogHook rework the getting/setting up of loggers
b-butler Oct 7, 2022
477ff48
Merge branch 'master' into feature/hooks-LogOperation
b-butler Oct 7, 2022
3951e74
doc: Fix pre-commit failure.
b-butler Oct 7, 2022
b360a2f
test: TestHooksInvalidOption.call_subcmd outputs error every time.
b-butler Oct 10, 2022
ee84251
fix: stringify job before passing to logging.getLogger()
b-butler Oct 10, 2022
f990244
refactor: Modify names of log functions for Log hook
b-butler Oct 10, 2022
d52d350
doc: Improve documentation of LogOperations hook
b-butler Oct 10, 2022
7826466
feat: Add helper method for operation level hooks
b-butler Oct 10, 2022
5fca1ac
refactor: Modify LogOperations base log filename
b-butler Oct 10, 2022
1e97e2b
refactor: Remove __call__ method of LogOperation
b-butler Oct 10, 2022
543d373
Merge branch 'master' into feature/hooks-LogOperation
b-butler Oct 10, 2022
8b619de
fix: LogOperation.install_operation_hooks
b-butler Oct 10, 2022
66d2e6c
test: Update tests to recent LogOperation updates
b-butler Oct 10, 2022
79b34e0
test: Fix faulty test's assumption on underlying project
b-butler Oct 11, 2022
26a63e2
ci: Update CircleCI
b-butler Oct 11, 2022
d5b81dd
Merge branch 'master' into feature/hooks-LogOperation
b-butler Oct 13, 2022
740b785
Merge branch 'master' into feature/hooks-LogOperation
bdice Jan 17, 2023
40f6dad
Added more detailed docstring.
klywang Jan 24, 2023
7eb6de9
Merge branch 'master' into feature/hooks-LogOperation
klywang Jan 24, 2023
1291617
Updated hooks test setup with new signac-flow API
klywang Jan 24, 2023
7e306cc
Update flow/hooks/log_operations.py
klywang Jan 27, 2023
44d9f2c
Merge branch 'master' into feature/hooks-LogOperation
klywang Feb 14, 2023
3633ffa
Changed scamples to be consistent with new decorator evalutaion order.
klywang Feb 14, 2023
1646cdc
Finished docstring in example.
klywang Feb 14, 2023
467668e
Fixed example in LogOperations.
klywang Feb 14, 2023
1f27e50
Updated examples.
klywang Feb 26, 2023
294ea87
Merge branch 'master' into feature/hooks-LogOperation
klywang Feb 26, 2023
7f0ddad
Updated example so tha trun opertions was not included. Did not work …
klywang Feb 26, 2023
105099a
Merge branch 'master' into feature/hooks-LogOperation
klywang Mar 1, 2023
3e69beb
Merge branch 'main' into feature/hooks-LogOperation
b-butler Apr 3, 2023
c329258
Merge branch 'main' into feature/hooks-LogOperation
klywang Jun 19, 2023
0452d88
Apply suggestions from code review
klywang Jul 7, 2023
fb3bb26
Build(deps-dev): Bump pre-commit from 3.3.2 to 3.3.3 (#756)
dependabot[bot] Jul 1, 2023
ea627fe
Build(deps): Bump ruamel-yaml from 0.17.31 to 0.17.32 (#755)
dependabot[bot] Jul 3, 2023
670dd25
Build(deps): Bump pytest from 7.3.1 to 7.4.0 (#754)
dependabot[bot] Jul 3, 2023
e19256b
[pre-commit.ci] pre-commit autoupdate (#757)
pre-commit-ci[bot] Jul 4, 2023
2a4c110
Merge branch 'main' into feature/hooks-LogOperation
klywang Jul 7, 2023
1debcb9
Modified log strings for consistency.
klywang Jul 7, 2023
7b91827
Comment clarity and shorter default log name.
klywang Jul 7, 2023
8b448fc
Update job workspace to job directory.
klywang Jul 7, 2023
1f3229f
Updated changelog.
klywang Jul 7, 2023
a6579e2
Merge branch 'main' into feature/hooks-LogOperation
b-butler Oct 13, 2023
672726b
test: Refactor hook test into single template
b-butler Oct 13, 2023
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
3 changes: 2 additions & 1 deletion flow/hooks/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
# This software is licensed under the BSD 3-Clause License.
"""Operation hooks."""
from .hooks import _Hooks
from .log_operations import LogOperations

__all__ = ["_Hooks"]
__all__ = ["_Hooks", "LogOperations"]
2 changes: 1 addition & 1 deletion flow/hooks/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class _Hooks:
starts, succeeds, fails, or finishes (regardless of whether the operation
executed successfully or failed).

Hooks can be installed at the operation level as decorators, or on an
Hooks can be installed at the operation level as decorators, or on
klywang marked this conversation as resolved.
Show resolved Hide resolved
instance of :class:`~.FlowProject` through
:meth:`~.FlowProject.project_hooks`.

Expand Down
100 changes: 100 additions & 0 deletions flow/hooks/log_operations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Copyright (c) 2018 The Regents of the University of Michigan
klywang marked this conversation as resolved.
Show resolved Hide resolved
# All rights reserved.
# This software is licensed under the BSD 3-Clause License.
"""Builtin execution hook for basic logging."""
import logging


class LogOperations:
""":class:`~.LogOperations` logs information about the execution of operations to a log file.

This hooks provides information, optionally, on the start, successful completion, and/or
klywang marked this conversation as resolved.
Show resolved Hide resolved
erroring of one or more operations in a `flow.FlowProject` instance. The logs are stored in a
file given by the parameter ``fn_logfile``. This file will be appended to if it already exists.

The default formating for the log provides the time, job id, log level, and log message.

Examples
--------
The code block below provides an example of how install :class:`~.LogOperations` to a
klywang marked this conversation as resolved.
Show resolved Hide resolved
instance of :class:`~.FlowProject`

.. code-block:: python
from flow import FLowProject
from flow.hooks import LogOperations # Import build
klywang marked this conversation as resolved.
Show resolved Hide resolved
b-butler marked this conversation as resolved.
Show resolved Hide resolved


Parameters
----------
fn_logfile: log filename
klywang marked this conversation as resolved.
Show resolved Hide resolved
The name of the log file in the job workspace. Default is "execution-record.log".
klywang marked this conversation as resolved.
Show resolved Hide resolved
klywang marked this conversation as resolved.
Show resolved Hide resolved
"""

def __init__(self, fn_logfile="execution-record.log"):
self._fn_logfile = fn_logfile
# getLogger keep its own cache. This just serves to reduce the time spent setting up loggers
# by only doing it once.
klywang marked this conversation as resolved.
Show resolved Hide resolved
self._loggers = dict()
klywang marked this conversation as resolved.
Show resolved Hide resolved

def on_start(self, operation, job):
"""Log the start of execution of a given job(s) operation pair."""
klywang marked this conversation as resolved.
Show resolved Hide resolved
self._get_logger(job).info(f"Starting execution of operation '{operation}'.")

def on_success(self, operation, job):
"""Log the successful completion of a given job(s) operation pair."""
self._get_logger(job).info(
f"Successfully finished execution of operation '{operation}'."
)

def on_exception(self, operation, error, job):
"""Log the raising of an error in the execution of a given job(s) operation pair."""
self._get_logger(job).info(
b-butler marked this conversation as resolved.
Show resolved Hide resolved
f"Execution of operation '{operation}' failed with error '{error}'."
)

def _get_logger(self, job):
if job not in self._loggers:
self._loggers[job] = self._setup_logger(job)
return self._loggers[job]
klywang marked this conversation as resolved.
Show resolved Hide resolved

def _setup_logger(self, job):
logger = logging.getLogger(str(job))
fh = logging.FileHandler(job.fn(self._fn_logfile))
fh.setLevel(logging.DEBUG)
formatter = logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
fh.setFormatter(formatter)
logger.addHandler(fh)
return logger

def install_operation_hooks(self, op, project_cls=None):
"""Decorate operation to install log operation to one operation in a signac-flow project.
klywang marked this conversation as resolved.
Show resolved Hide resolved

Parameters
----------
op : function or type
An operation function to log or a subclass of `flow.FlowProject` if ``project_cls`` is
``None``.
project_cls : type
A subclass of `flow.FlowProject`.
"""
if project_cls is None:
return lambda func: self.install_operation_hooks(func, op)
project_cls.operation_hooks.on_start(self.on_start)(op)
project_cls.operation_hooks.on_success(self.on_success)(op)
project_cls.operation_hooks.on_exception(self.on_exception)(op)
return op

def install_project_hooks(self, project):
"""Install log operation to all operations in a signac-flow project.

Parameters
----------
project : flow.FlowProject
The project to install project wide hooks on.
klywang marked this conversation as resolved.
Show resolved Hide resolved
"""
project.project_hooks.on_start.append(self.on_start)
project.project_hooks.on_success.append(self.on_success)
project.project_hooks.on_exception.append(self.on_exception)
return project
36 changes: 36 additions & 0 deletions tests/define_hooks_logging_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from define_hooks_logging_project import (
HOOKS_ERROR_MESSAGE,
HOOKS_RUNTIME_MESSAGE,
set_logger,
)

import flow
from flow.hooks import LogOperations


class _HooksLogOperationsProject(flow.FlowProject):
pass


@_HooksLogOperationsProject.operation
def base(job):
logger = set_logger(job)

logger.info(HOOKS_RUNTIME_MESSAGE)

if job.sp.raise_exception:
raise RuntimeError(HOOKS_ERROR_MESSAGE)


@_HooksLogOperationsProject.operation(cmd=True)
def base_cmd(job):
if job.sp.raise_exception:
return "exit 42"
else:
return "touch base_cmd.txt"


if __name__ == "__main__":
LogOperations("operations.log").install_project_hooks(
_HooksLogOperationsProject()
).main()
48 changes: 48 additions & 0 deletions tests/define_hooks_logging_project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import logging

from define_hooks_test_project import HOOKS_ERROR_MESSAGE

from flow import FlowProject
from flow.hooks import LogOperations

HOOKS_RUNTIME_MESSAGE = "Runtime message."


class _HooksLogOperationsProject(FlowProject):
pass


operation_log = LogOperations("operations.log")


def set_logger(job):
logger = logging.getLogger(str(job))
sh = logging.StreamHandler()
logger.addHandler(sh)
return logger


@operation_log.install_operation_hooks(_HooksLogOperationsProject)
@_HooksLogOperationsProject.operation
def base(job):
logger = set_logger(job)

logger.info(HOOKS_RUNTIME_MESSAGE)

if job.sp.raise_exception:
raise RuntimeError(HOOKS_ERROR_MESSAGE)


@_HooksLogOperationsProject.operation_hooks.on_start(operation_log.on_start)
@_HooksLogOperationsProject.operation_hooks.on_success(operation_log.on_success)
@_HooksLogOperationsProject.operation_hooks.on_exception(operation_log.on_exception)
@_HooksLogOperationsProject.operation(cmd=True)
def base_cmd(job):
if job.sp.raise_exception:
return "exit 42"
else:
return "touch base_cmd.txt"


if __name__ == "__main__":
_HooksLogOperationsProject().main()
139 changes: 133 additions & 6 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from io import StringIO
from itertools import groupby

import define_hooks_logging_project
import define_hooks_test_project
import pytest
import signac
Expand Down Expand Up @@ -2233,7 +2234,7 @@ def mock_project(self):
project._entrypoint = self.entrypoint
return project

def call_subcmd(self, subcmd, stderr=subprocess.DEVNULL):
def call_subcmd(self, subcmd, stderr=subprocess.PIPE):
# Bypass raising the error/checking output since it interferes with hook.on_exception
fn_script = self.entrypoint["path"]
_cmd = f"python {fn_script} {subcmd} --debug"
Expand All @@ -2242,8 +2243,8 @@ def call_subcmd(self, subcmd, stderr=subprocess.DEVNULL):
with _switch_to_directory(self.project.path):
return subprocess.check_output(_cmd.split(), stderr=stderr)
except subprocess.CalledProcessError as error:
print(error, file=sys.stderr)
print(error.output, file=sys.stderr)
print("STDOUT:", error.stdout, sep="\n", file=sys.stderr)
print("STDERR:", error.stderr, sep="\n", file=sys.stderr)
raise

@pytest.fixture(scope="function")
Expand Down Expand Up @@ -2388,16 +2389,25 @@ def test_no_decorator_keys(self, operation_name, job):


class TestHooksInvalidOption(TestHooksSetUp):
def call_subcmd(self, subcmd, stderr=subprocess.STDOUT):
# Return error as output instead of raising error
def call_subcmd(self, subcmd, stderr=subprocess.PIPE):
"""Call a flow command through the CLI.

Return error as output instead of raising error. By default we also print out the stdout and
stderr of the `CalledProcessError` since pytest will capture it by default anyways. This
aids the debugging of tests by providing a traceback of the internal error.
"""
fn_script = self.entrypoint["path"]
_cmd = f"python {fn_script} {subcmd} --debug"
with _add_path_to_environment_pythonpath(os.path.abspath(self.cwd)):
try:
with _switch_to_directory(self.project.path):
return subprocess.check_output(_cmd.split(), stderr=stderr)
except subprocess.CalledProcessError as error:
return str(error.output)
if error.stderr:
print("STDERR:", error.stderr, sep="\n")
if error.stdout:
print("STDOUT:", error.stderr, sep="\n")
return str(error.stdout) + str(error.stderr)

def test_invalid_hook(self):
class A(FlowProject):
Expand Down Expand Up @@ -2437,6 +2447,123 @@ def test_raise_exception_in_hook_cmd(self):
assert "RuntimeError" in error_output


class TestHooksLogOperationSetUp(TestHooksSetUp):
project_class = define_hooks_logging_project._HooksLogOperationsProject
entrypoint = dict(
path=os.path.realpath(
os.path.join(os.path.dirname(__file__), "define_hooks_logging_project.py")
)
)

ON_START_MSG = "Starting execution of operation '{}'."
SUCCESS_MSG = "Successfully finished execution of operation '{}'."
EXCEPTION_MSG = "Execution of operation '{}' failed with error"
klywang marked this conversation as resolved.
Show resolved Hide resolved

@staticmethod
def get_log_filename():
return "operations.log"

@staticmethod
def get_log_output(job, log_filename):
return "".join(line for line in open(job.fn(log_filename)))


class TestHooksLogCmd(TestHooksLogOperationSetUp):
error_message = "42"
runtime_message = ""

@pytest.fixture(params=["base_cmd"])
def operation_name(self, request):
return request.param

def test_logging(self, project, job, operation_name):
log_fn = self.get_log_filename()
assert not job.isfile(log_fn)

if job.sp.raise_exception:
with pytest.raises(subprocess.CalledProcessError):
self.call_subcmd(f"run -o {operation_name} -j {job.id}")
else:
self.call_subcmd(f"run -o {operation_name} -j {job.id}")

assert job.isfile(log_fn)
log_output = self.get_log_output(job, log_fn)
assert self.ON_START_MSG.format(operation_name) in log_output
if job.sp.raise_exception:
assert self.error_message in log_output
assert self.EXCEPTION_MSG.format(operation_name)
else:
assert self.runtime_message in log_output
assert self.SUCCESS_MSG.format(operation_name) in log_output


class TestHooksLogBase(TestHooksLogCmd):
error_message = define_hooks_logging_project.HOOKS_ERROR_MESSAGE

@pytest.fixture(params=["base"])
def operation_name(self, request):
return request.param

def test_start(self, project, job, operation_name):
log_fn = self.get_log_filename()

assert not job.isfile(log_fn)

if job.sp.raise_exception:
with pytest.raises(subprocess.CalledProcessError):
self.call_subcmd(f"run -o {operation_name} -j {job.id}")
else:
self.call_subcmd(f"run -o {operation_name} -j {job.id}")

assert job.isfile(log_fn)

log_output = self.get_log_output(job, log_fn)

assert self.runtime_message in log_output

assert self.ON_START_MSG.format(operation_name) in log_output
if job.sp.raise_exception:
assert self.error_message in log_output
assert self.EXCEPTION_MSG.format(operation_name)
else:
assert self.error_message not in log_output
assert self.SUCCESS_MSG.format(operation_name) in log_output


class TestHooksLogInstall(TestHooksLogOperationSetUp):
entrypoint = dict(
path=os.path.realpath(
os.path.join(os.path.dirname(__file__), "define_hooks_logging_install.py")
)
)

def test_install(self, project, job):
log_fn = job.fn("operations.log")

assert not job.isfile(log_fn)

if job.sp.raise_exception:
with pytest.raises(subprocess.CalledProcessError):
self.call_subcmd(f"run -j {job.id} -o base")
with pytest.raises(subprocess.CalledProcessError):
self.call_subcmd(f"run -j {job.id} -o base_cmd")
else:
self.call_subcmd(f"run -j {job.id} -o base")
self.call_subcmd(f"run -j {job.id} -o base_cmd")

assert job.isfile(log_fn)
log_output = self.get_log_output(job, log_fn)

if job.sp.raise_exception:
assert "42" in log_output
assert define_hooks_logging_project.HOOKS_ERROR_MESSAGE in log_output
assert self.EXCEPTION_MSG.format("base") in log_output
assert self.EXCEPTION_MSG.format("base_cmd") in log_output
else:
assert self.SUCCESS_MSG.format("base") in log_output
assert self.SUCCESS_MSG.format("base_cmd") in log_output


class TestIgnoreConditions:
def test_str(self):
expected_results = {
Expand Down