diff --git a/src/pydrex/io.py b/src/pydrex/io.py index b53d36bf..580c2f29 100644 --- a/src/pydrex/io.py +++ b/src/pydrex/io.py @@ -13,6 +13,7 @@ """ +import contextlib as cl import collections as c import csv import functools as ft @@ -22,6 +23,7 @@ import pathlib import re import sys +import logging if sys.version_info >= (3, 11): import tomllib @@ -38,6 +40,7 @@ from pydrex import core as _core from pydrex import exceptions as _err +from pydrex import utils as _utils from pydrex import logger as _log from pydrex import velocity as _velocity @@ -156,6 +159,7 @@ def extract_h5part(file, phase, fabric, n_grains, output): ) +@_utils.defined_if(sys.version_info >= (3, 12)) def parse_scsv_schema(terse_schema): """Parse terse scsv schema representation and return the expanded schema. @@ -172,6 +176,9 @@ def parse_scsv_schema(terse_schema): (which must be valid Python identifiers) and their (optional) data type, missing data fill value, and unit/comment. + .. note:: This function is only defined if the version of your Python interpreter is + greater than 3.11.x. + >>> # delimiter >>> # | missing data encoding column specifications >>> # | | ______________________|______________________________ @@ -758,3 +765,19 @@ def data(directory): return resolve_path(resources / directory) else: raise NotADirectoryError(f"{resources / directory} is not a directory") + + +@cl.contextmanager +def logfile_enable(path, level=logging.DEBUG, mode="w"): + """Enable logging to a file at `path` with given `level`.""" + logger_file = logging.FileHandler(resolve_path(path), mode=mode) + logger_file.setFormatter( + logging.Formatter( + "%(levelname)s [%(asctime)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger_file.setLevel(level) + _log.LOGGER.addHandler(logger_file) + yield + logger_file.close() diff --git a/src/pydrex/logger.py b/src/pydrex/logger.py index d1b014c2..fbd9fa7e 100644 --- a/src/pydrex/logger.py +++ b/src/pydrex/logger.py @@ -33,13 +33,14 @@ _log.info("this message will be printed to the console") ``` -To save debug logs to a file, the `logfile_enable` context manager is recommended. +To save logs to a file, the `pydrex.io.logfile_enable` context manager is recommended. Always use the old printf style formatting for log messages, not fstrings, otherwise compute time will be wasted on string conversions when logging is disabled: ```python +from pydrex import io as _io _log.quiet_aliens() # Suppress third-party log messages except CRITICAL from Numba. -with _log.logfile_enable("my_log_file.log"): # Overwrite existing file unless mode="a". +with _io.logfile_enable("my_log_file.log"): # Overwrite existing file unless mode="a". value = 42 _log.critical("critical error with value: %s", value) _log.error("runtime error with value: %s", value) @@ -59,7 +60,7 @@ import numpy as np -from pydrex import io as _io +# NOTE: Do NOT import any pydrex submodules here to avoid cyclical imports. np.set_printoptions( formatter={"float_kind": np.format_float_scientific}, @@ -133,22 +134,6 @@ def handler_level(level, handler=CONSOLE_LOGGER): handler.setLevel(default_level) -@cl.contextmanager -def logfile_enable(path, level=logging.DEBUG, mode="w"): - """Enable logging to a file at `path` with given `level`.""" - logger_file = logging.FileHandler(_io.resolve_path(path), mode=mode) - logger_file.setFormatter( - logging.Formatter( - "%(levelname)s [%(asctime)s] %(name)s: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - ) - logger_file.setLevel(level) - LOGGER.addHandler(logger_file) - yield - logger_file.close() - - def critical(msg, *args, **kwargs): """Log a CRITICAL message in PyDRex.""" LOGGER.critical(msg, *args, **kwargs) diff --git a/src/pydrex/utils.py b/src/pydrex/utils.py index 0ccbe1ad..1bc8ea07 100644 --- a/src/pydrex/utils.py +++ b/src/pydrex/utils.py @@ -48,6 +48,7 @@ class SerializedCallable: function), use the `serializable` decorator. """ + def __init__(self, f): self._f = dill.dumps(f, protocol=5, byref=True) @@ -56,10 +57,29 @@ def __call__(self, *args, **kwargs): def serializable(f): - """Make wrapped function serializable.""" + """Make decorated function serializable.""" return SerializedCallable(f) +def defined_if(cond): + """Only define decorated function if `cond` is `True`.""" + + def _defined_if(f): + def not_f(*args, **kwargs): + # Throw the same as we would get from `type(undefined_symbol)`. + raise NameError(f"name '{f.__name__}' is not defined") + + @wraps(f) + def wrapper(*args, **kwargs): + if cond: + return f(*args, **kwargs) + return not_f(*args, **kwargs) + + return wrapper + + return _defined_if + + @nb.njit(fastmath=True) def strain_increment(dt, velocity_gradient): """Calculate strain increment for a given time increment and velocity gradient. diff --git a/tests/README.md b/tests/README.md index cb49f8b0..b07cdfbc 100644 --- a/tests/README.md +++ b/tests/README.md @@ -51,7 +51,7 @@ it should accept the `outdir` positional argument, and check if its value is not `None`. If `outdir is None` then no persistent output should be produced. If `outdir` is a directory path (string): -- logs can be saved by using the `pydrex.logger.logfile_enable` context manager, +- logs can be saved by using the `pydrex.io.logfile_enable` context manager, which accepts a path name and an optional logging level as per Python's `logging` module (the default is `logging.DEBUG` which implies the most verbose output), - figures can be saved by (implementing and) calling a helper from `pydrex.visualisation`, and diff --git a/tests/test_core.py b/tests/test_core.py index 1c6a635a..f4726eac 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -29,7 +29,7 @@ def test_shear_dudz(self, outdir): test_id = "dudz" optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) with optional_logging: @@ -66,7 +66,7 @@ def test_shear_dvdx(self, outdir): test_id = "dvdx" optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) with optional_logging: @@ -128,7 +128,7 @@ def test_shear_dvdx_slip_010_100(self, outdir): optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) initial_angles = [] @@ -245,7 +245,7 @@ def test_shear_dudz_slip_001_100(self, outdir): optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) initial_angles = [] @@ -363,7 +363,7 @@ def test_shear_dwdx_slip_001_100(self, outdir): optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) initial_angles = [] @@ -481,7 +481,7 @@ def test_shear_dvdz_slip_010_001(self, outdir): optional_logging = cl.nullcontext() if outdir is not None: - optional_logging = _log.logfile_enable( + optional_logging = _io.logfile_enable( f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}.log" ) initial_angles = [] @@ -608,7 +608,7 @@ def test_shear_dvdx_circle_inplane(self, outdir): cos2θ = np.cos(2 * initial_angles) if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") with optional_logging: initial_orientations = Rotation.from_rotvec( @@ -705,7 +705,7 @@ def test_shear_dvdx_circle_shearplane(self, outdir): initial_angles = np.mgrid[0 : 2 * np.pi : 360000j] if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") with optional_logging: initial_orientations = Rotation.from_euler( diff --git a/tests/test_corner_flow_2d.py b/tests/test_corner_flow_2d.py index 9b8df9c6..02415e07 100644 --- a/tests/test_corner_flow_2d.py +++ b/tests/test_corner_flow_2d.py @@ -118,7 +118,7 @@ def test_steady4(self, outdir, seed, ncpus): optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_prescribed" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") npzpath = pl.Path(f"{out_basepath}.npz") labels = [] angles = [] diff --git a/tests/test_simple_shear_2d.py b/tests/test_simple_shear_2d.py index 33a92022..005a18bd 100644 --- a/tests/test_simple_shear_2d.py +++ b/tests/test_simple_shear_2d.py @@ -331,7 +331,7 @@ def test_dvdx_ensemble( optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_dvdx_ensemble_{_id}" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") labels = [] with optional_logging: @@ -467,7 +467,7 @@ def test_dvdx_GBM(self, outdir, seeds_nearX45, ncpus): optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_mobility" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") labels = [] with optional_logging: @@ -645,7 +645,7 @@ def test_GBM_calibration(self, outdir, seeds, ncpus): optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_calibration" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") labels = [] with optional_logging: @@ -792,7 +792,7 @@ def test_dudz_pathline(self, outdir, seed): optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_{test_id}" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") with optional_logging: shear_direction = Ŋ([1, 0, 0], dtype=np.float64) diff --git a/tests/test_simple_shear_3d.py b/tests/test_simple_shear_3d.py index e5e4cec3..ddd32f67 100644 --- a/tests/test_simple_shear_3d.py +++ b/tests/test_simple_shear_3d.py @@ -153,7 +153,7 @@ def test_direction_change( optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_direction_change_{_id}" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") with optional_logging: clock_start = process_time() diff --git a/tests/test_vortex_2d.py b/tests/test_vortex_2d.py index 61d472da..2cf4536f 100644 --- a/tests/test_vortex_2d.py +++ b/tests/test_vortex_2d.py @@ -437,7 +437,7 @@ def _run(assert_each, orientations_init): optional_logging = cl.nullcontext() if outdir is not None: out_basepath = f"{outdir}/{SUBDIR}/{self.class_id}_olA" - optional_logging = _log.logfile_enable(f"{out_basepath}.log") + optional_logging = _io.logfile_enable(f"{out_basepath}.log") assert_each_list = [ get_assert_each(i) for i, _ in enumerate(orientations_init_y)