Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 1 addition & 1 deletion .github/workflows/readthedocs-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# This does NOT trigger a build of the documentation, this is handled through webhooks.
name: Read the Docs PR Preview
on:
pull_request:
pull_request_target:
types:
- opened
- synchronize
Expand Down
6 changes: 6 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ build:
os: "ubuntu-20.04"
tools:
python: "mambaforge-4.10"
jobs:
# Install the checked-out source (e.g. the current PR) into the conda
# environment, so the docs are built against this code. Dependencies are
# already provided by docs/environment.yml, hence --no-deps.
post_install:
- pip install --no-deps .
2 changes: 1 addition & 1 deletion docs/_ext/ecflow_lexers.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class EcflowDefLexer(RegexLexer):
bygroups(Keyword, Name.Constant),
),
(
r"(repeat)(\s+(?:date(?:list)?|day|month|year|integer|enumerated|string))(\s+(?:.+?))(\s(?:.*))",
r"(repeat)(\s+(?:date(?:time)?(?:list)?|day|month|year|integer|enumerated|string))(\s+(?:.+?))(\s(?:.*))", # noqa: E501
bygroups(Keyword, Name.Other, Name.Variable, Literal.Date),
),
# Required
Expand Down
8 changes: 8 additions & 0 deletions docs/content/api-reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ Repeat

.. autoclass:: pyflow.attributes.RepeatDateList

.. _RepeatDateTime:

.. autoclass:: pyflow.attributes.RepeatDateTime

.. _RepeatDateTimeList:

.. autoclass:: pyflow.attributes.RepeatDateTimeList

.. _RepeatDay:

.. autoclass:: pyflow.attributes.RepeatDay
Expand Down
1 change: 0 additions & 1 deletion docs/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ dependencies:
- sphinx-rtd-theme
- sphinx-copybutton
- sphinx-tabs
- git+https://github.com/ecmwf/pyflow.git

variables:
QT_MAC_WANTS_LAYER: 1
4 changes: 2 additions & 2 deletions docs/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ipykernel
nbsphinx
pypandoc_binary
sphinx-rtd-theme==0.5.2
sphinx-copybutton==0.3.1
sphinx-tabs
git+https://github.com/ecmwf/pyflow.git
sphinx-tabs
1 change: 1 addition & 0 deletions pyflow/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
RepeatDate,
RepeatDateList,
RepeatDateTime,
RepeatDateTimeList,
RepeatDay,
RepeatEnumerated,
RepeatInteger,
Expand Down
97 changes: 95 additions & 2 deletions pyflow/attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
expression_from_json,
make_expression,
)
from .importer import ecflow
from .importer import ecflow, supported
from .state import aborted, active, complete, queued, submitted, suspended, unknown

NO_TRIGGER = False
Expand Down Expand Up @@ -641,6 +641,7 @@ def day_of_week(self):
setattr(RepeatDate, day, property(lambda self: Eq(self.day_of_week, dow)))


@supported(">=5.12.0")
class RepeatDateTime(Exportable):
"""
An attribute that allows a node to be repeated by a date+time value.
Expand All @@ -658,11 +659,17 @@ class RepeatDateTime(Exportable):
datetime.datetime(year=2019, month=12, day=31, hour=12, minute=0, second=0),
datetime.timedelta(hours=12, minutes=0, seconds=0))

Date and increment can also be strings::
Start/End values can also be strings: ISO 8601 basic format `yyyymmddTHHMMSS`, DateTime with
hours and minutes ``yyyymmddTHHMM``, DateTime with hours only ``yyyymmddTHH``,
or simply a date ``yyyymmdd`` (the missing components are assumed to be 0), and increment can also be a string::

pyflow.RepeatDateTime('REPEAT_DATETIME',
'20190101T120000', '20191231T120000', '12:00:00')

Note::

This repeat type is only supported in ecFlow 5.12.0 and later.

"""

def __init__(
Expand Down Expand Up @@ -727,6 +734,92 @@ def day_of_week(self):
return Mod(Add(Div(self, 86400), 4), 7)


@supported(">=5.17.0")
class RepeatDateTimeList(Repeat):
"""
An attribute that allows a node to be repeated over a list of datetime values.

Comment thread
marcosbento marked this conversation as resolved.
Parameters:
name(str): The name of the repeat attribute.
values(list of datetime/date): The list of datetime/date values, as datetime/date objects or strings.

Example::

pyflow.RepeatDateTimeList('REPEAT_DATETIME',
[datetime.date(year=2019, month=1, day=1),
datetime.datetime(year=2019, month=1, day=3, hour=12, minute=0, seconds=0)])

Values can also be strings: ISO 8601 basic format `yyyymmddTHHMMSS`, DateTime with
hours and minutes ``yyyymmddTHHMM``, DateTime with hours only ``yyyymmddTHH``,
or simply a date ``yyyymmdd`` (the missing components are assumed to be 0)::

pyflow.RepeatDateTimeList('REPEAT_DATETIME', ['20190101T120000', '20190103'])

Note::

This repeat type is only supported in ecFlow 5.17.0 and later.
"""

def __init__(self, name, values):
if values is None:
raise ValueError("values cannot be None")
if not isinstance(values, list):
raise TypeError("values must be a list")
if isinstance(values, list) and not values:
raise ValueError("values cannot be an empty list")
if not all(
isinstance(value, (datetime.datetime, datetime.date, str))
for value in values
):
raise TypeError("values must be a list of datetime objects or strings")

super().__init__(name, values)

def _build(self, ecflow_parent):
# Format all datetime values as ISO 8601 basic format `yyyymmddTHHMMSS`
values = [as_date(value).strftime("%Y%m%dT%H%M%S") for value in self.values]

repeat = ecflow.RepeatDateTimeList(
str(self.name),
values,
)

ecflow_parent.add_repeat(repeat)
Comment thread
marcosbento marked this conversation as resolved.

@property
def values(self):
"""*list*: The list of datetime values."""
return [
x if isinstance(x, datetime.datetime) else as_date(x) for x in self.value
]

def __add__(self, other):
return Add(self, other)

def __sub__(self, other):
return Sub(self, other)

@property
def second(self):
"""*int*: The second of the repeat datetime."""
return Mod(self, 60)

@property
def minute(self):
"""*int*: The minute of the repeat datetime."""
return Mod(Div(self, 60), 60)

@property
def hour(self):
"""*int*: The hour of the repeat datetime."""
return Mod(Div(self, 3600), 24)

@property
def day_of_week(self):
"""*int*: The day of the week of the repeat datetime."""
return Mod(Add(Div(self, 86400), 4), 7)


def is_date(value):
return (
isinstance(value, (datetime.date, datetime.datetime))
Expand Down
71 changes: 71 additions & 0 deletions pyflow/importer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import functools
import os
import sys
import types

from packaging.specifiers import SpecifierSet

try:
import ecflow
Expand Down Expand Up @@ -30,3 +34,70 @@
raise ImportError(
"Could not find ecflow Python library, try to set ECFLOW_DIR environment variable to correct path"
)


def supported(specifier: str, current: str = ecflow.__version__):
"""
A decorator that ensures the decorated class can only be used when
the available ecFlow version satisfies ``specifier``.

Every method and property of the class is wrapped so that invoking it
(e.g. instantiating the class via ``__init__``, or accessing a property)
raises :class:`NotImplementedError` when ``current`` does not satisfy
``specifier``.

The version comparison is evaluated once, when the class is decorated
(i.e. at import time, using ``current`` which defaults to the version of
the imported ecFlow module).
The ``current`` argument allows tests to instrument the behaviour for an arbitrary version.

Parameters:
specifier(str): A :class:`packaging.specifiers.SpecifierSet` string, e.g. ``">=5.12.0,<6.0.0"``.
current(str): The current version, e.g. ``"5.12.0"``. Defaults to the version of the imported ecFlow module.
"""

# Evaluate the comparison once, since both versions are fixed for the
# lifetime of the decorated class.
is_supported = SpecifierSet(specifier).contains(current)

def decorator(cls):

# Define a wrapper factory that checks the supported version before calling the original function
def make_wrapper(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not is_supported:
raise NotImplementedError(
"{} functionality is only supported for ecFlow {}, but current version is {}".format(
cls.__name__, specifier, current
)
)

return func(*args, **kwargs)

return wrapper

# Iterate over a copy as we mutate the class namespace while iterating.
for attr_name, attr_value in list(cls.__dict__.items()):

if isinstance(attr_value, types.FunctionType):
# Wrapped plain methods directly.
setattr(cls, attr_name, make_wrapper(attr_value))

elif isinstance(attr_value, property):
# Ensure properties must remain properties,
# by wrapping accessors and rebuilding the property so attribute access keeps working.
setattr(
cls,
attr_name,
property(
make_wrapper(attr_value.fget) if attr_value.fget else None,
make_wrapper(attr_value.fset) if attr_value.fset else None,
make_wrapper(attr_value.fdel) if attr_value.fdel else None,
attr_value.__doc__,
),
)

return cls

return decorator
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dynamic = ["version", "readme"]

dependencies = [
"jinja2",
"packaging",
"requests",
]

Expand Down
91 changes: 91 additions & 0 deletions tests/test_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,97 @@ def test_repeat_datetime(self):

s.check_definition()

def test_repeat_datetimelist_basic_usage(self):
from datetime import datetime as dt

i = dt(2000, 1, 1, 12, 0, 0)
j = dt(2000, 1, 2)

input_tests = (
("A", [i]),
("B", [i, j]),
("C", ["20000103T120000", "20000104T000000"]),
("D", [i, "20010105T123456"]),
)

with pyflow.Suite("s") as s:
for idx, args in enumerate(input_tests):
with pyflow.Task(f"t{idx}"):
pyflow.RepeatDateTimeList(*args)

asserts = (
'repeat datetimelist A "20000101T120000"',
'repeat datetimelist B "20000101T120000" "20000102T000000"',
'repeat datetimelist C "20000103T120000" "20000104T000000"',
'repeat datetimelist D "20000101T120000" "20010105T123456"',
)
defn = str(s.ecflow_definition())
for a in asserts:
assert a in defn

s.check_definition()

def test_repeat_datetimelist_allowing_date_and_datetime_object(self):
import datetime

i = datetime.datetime(2000, 1, 1, 12, 34, 56)
j = datetime.date(2000, 1, 2)

input_tests = (("A", [i, j]),)

with pyflow.Suite("s") as s:
for idx, args in enumerate(input_tests):
with pyflow.Task(f"t{idx}"):
pyflow.RepeatDateTimeList(*args)

asserts = 'repeat datetimelist A "20000101T123456" "20000102T000000"'
defn = str(s.ecflow_definition())
for a in asserts:
assert a in defn

s.check_definition()

def test_repeat_datetimelist_with_truncated_string_values(self):
input_tests = (
("A", ["20000101T01", "20000102T0102", "20000103T010203"]),
("B", ["20000104", "20000105T12", "20010106T1234"]),
)

with pyflow.Suite("s") as s:
for idx, args in enumerate(input_tests):
with pyflow.Task(f"t{idx}"):
pyflow.RepeatDateTimeList(*args)

asserts = (
'repeat datetimelist A "20000101T010000" "20000102T010200" "20000103T010203"',
'repeat datetimelist B "20000104T000000" "20000105T120000" "20010106T123400"',
)
defn = str(s.ecflow_definition())
for a in asserts:
assert a in defn

s.check_definition()

def test_repeat_datetimelist_with_none_value(self):
with pytest.raises(ValueError, match="values cannot be None"):
pyflow.RepeatDateTimeList("N", None)

def test_repeat_datetimelist_with_empty_values_list(self):
with pytest.raises(ValueError, match="values cannot be an empty list"):
pyflow.RepeatDateTimeList("E", [])

def test_repeat_datetimelist_with_invalid_type_values_list(self):
with pytest.raises(
TypeError, match="values must be a list of datetime objects or strings"
):
pyflow.RepeatDateTimeList("I", [20050101])

def test_repeat_datetimelist_with_literal_type_value(self):
from datetime import datetime as dt

with pytest.raises(TypeError, match="values must be a list"):
pyflow.RepeatDateTimeList("I", dt(2000, 1, 1, 0, 0, 0))

def test_repeat_date_list(self):
i = date(year=2019, month=12, day=31)
j = date(year=2020, month=1, day=1)
Expand Down
Loading
Loading