From 9333f34839ff4de1f81741e1ba614c3aa8d437a4 Mon Sep 17 00:00:00 2001 From: Gert Mertes Date: Wed, 27 May 2026 13:01:28 +0000 Subject: [PATCH 01/13] Add RepeatDateTimeList --- pyflow/attributes.py | 59 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 6ef5947..24dc8fd 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -727,6 +727,65 @@ def day_of_week(self): return Mod(Add(Div(self, 86400), 4), 7) +class RepeatDateTimeList(Repeat): + """ + An attribute that allows a node to be repeated by a list of datetime values. + + Parameters: + name(str): The name of the repeat attribute. + values(list of datetime): The list of datetime values, as datetime objects or strings. + + Example:: + + pyflow.RepeatDateTimeList('REPEAT_DATETIME', + [datetime.datetime(year=2019, month=1, day=1), + datetime.datetime(year=2019, month=1, day=3)]) + + Values can also be strings in ISO 8601 basic format `yyyymmddTHHMMSS`, or `YYYYMMDD`:: + + pyflow.RepeatDateTimeList('REPEAT_DATETIME', ['20190101T120000', '20190103']) + """ + + def __init__(self, name, values): + super().__init__(name, values) + + def _build(self, ecflow_parent): + 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) + + 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)) From 93e729a3d49c6dfed0c1b1d9cefbbb2b7cd477b1 Mon Sep 17 00:00:00 2001 From: Gert Mertes <13658335+gmertes@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:05:08 +0100 Subject: [PATCH 02/13] self.values -> self.value Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pyflow/attributes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 24dc8fd..6b323e5 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -750,8 +750,7 @@ def __init__(self, name, values): super().__init__(name, values) def _build(self, ecflow_parent): - values = [as_date(value).strftime("%Y%m%dT%H%M%S") for value in self.values] - + values = [as_date(value).strftime("%Y%m%dT%H%M%S") for value in self.value] repeat = ecflow.RepeatDateTimeList( str(self.name), values, From b2d7d3ca575cdaefdc6b25404d66282c69a35097 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 13:50:22 +0100 Subject: [PATCH 03/13] Export RepeatDateList in the pyflow module --- pyflow/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyflow/__init__.py b/pyflow/__init__.py index 305eb3d..20c0e80 100644 --- a/pyflow/__init__.py +++ b/pyflow/__init__.py @@ -30,6 +30,7 @@ RepeatDate, RepeatDateList, RepeatDateTime, + RepeatDateTimeList, RepeatDay, RepeatEnumerated, RepeatInteger, From c1aafed37555d06a7d34e5c2b5c91ae1910c59c9 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 13:56:20 +0100 Subject: [PATCH 04/13] Add missing `values` property --- pyflow/attributes.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 6b323e5..2a11068 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -750,7 +750,9 @@ def __init__(self, name, values): super().__init__(name, values) def _build(self, ecflow_parent): - values = [as_date(value).strftime("%Y%m%dT%H%M%S") for value in self.value] + # 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, @@ -758,6 +760,13 @@ def _build(self, ecflow_parent): ecflow_parent.add_repeat(repeat) + @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) From 18236a516d3cb6287ca9a1aa4aa7233c64948765 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 13:57:13 +0100 Subject: [PATCH 05/13] Add basic test to construct RepeatDateTimeList attributes --- tests/test_attributes.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 594eac9..023101a 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -554,6 +554,35 @@ def test_repeat_datetime(self): s.check_definition() + def test_repeat_datetime_list(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", "20000104"]), + ("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_date_list(self): i = date(year=2019, month=12, day=31) j = date(year=2020, month=1, day=1) From 56168f5b16f69db2f0abcae25f3d1e648217e147 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 14:50:20 +0100 Subject: [PATCH 06/13] Safeguard against invalid RepeatDateTimeList values --- pyflow/attributes.py | 13 +++++++++++-- tests/test_attributes.py | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 2a11068..d35ee6c 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -729,7 +729,7 @@ def day_of_week(self): class RepeatDateTimeList(Repeat): """ - An attribute that allows a node to be repeated by a list of datetime values. + An attribute that allows a node to be repeated over a list of datetime values. Parameters: name(str): The name of the repeat attribute. @@ -741,12 +741,21 @@ class RepeatDateTimeList(Repeat): [datetime.datetime(year=2019, month=1, day=1), datetime.datetime(year=2019, month=1, day=3)]) - Values can also be strings in ISO 8601 basic format `yyyymmddTHHMMSS`, or `YYYYMMDD`:: + Values can also be strings in ISO 8601 basic format `yyyymmddTHHMMSS`, or `yyyymmdd`:: pyflow.RepeatDateTimeList('REPEAT_DATETIME', ['20190101T120000', '20190103']) """ 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, 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): diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 023101a..48a43fb 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -554,7 +554,7 @@ def test_repeat_datetime(self): s.check_definition() - def test_repeat_datetime_list(self): + 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) @@ -583,6 +583,23 @@ def test_repeat_datetime_list(self): 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) From 9fc0ee458eec1f077724908d3e187565d3fc24a5 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 15:29:27 +0100 Subject: [PATCH 07/13] Update docstring regarding the use of truncated datetime strings as values Add test to ensure the functionality is as documented. --- pyflow/attributes.py | 8 ++++++-- tests/test_attributes.py | 23 ++++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index d35ee6c..2b914f5 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -658,7 +658,9 @@ 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') @@ -741,7 +743,9 @@ class RepeatDateTimeList(Repeat): [datetime.datetime(year=2019, month=1, day=1), datetime.datetime(year=2019, month=1, day=3)]) - Values can also be strings in ISO 8601 basic format `yyyymmddTHHMMSS`, or `yyyymmdd`:: + 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']) """ diff --git a/tests/test_attributes.py b/tests/test_attributes.py index 48a43fb..f9c4d26 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -562,7 +562,7 @@ def test_repeat_datetimelist_basic_usage(self): input_tests = ( ("A", [i]), ("B", [i, j]), - ("C", ["20000103T120000", "20000104"]), + ("C", ["20000103T120000", "20000104T000000"]), ("D", [i, "20010105T123456"]), ) @@ -583,6 +583,27 @@ def test_repeat_datetimelist_basic_usage(self): 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) From 19613e721ecb198a887cb0e2fda73d289e983ed3 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 15:47:06 +0100 Subject: [PATCH 08/13] Allow taking datetime.date objects in as values --- pyflow/attributes.py | 8 ++++---- tests/test_attributes.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 2b914f5..389602a 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -735,13 +735,13 @@ class RepeatDateTimeList(Repeat): Parameters: name(str): The name of the repeat attribute. - values(list of datetime): The list of datetime values, as datetime objects or strings. + values(list of datetime/date): The list of datetime/date values, as datetime/date objects or strings. Example:: pyflow.RepeatDateTimeList('REPEAT_DATETIME', - [datetime.datetime(year=2019, month=1, day=1), - datetime.datetime(year=2019, month=1, day=3)]) + [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``, @@ -757,7 +757,7 @@ def __init__(self, name, values): 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, str)) for value in values): + 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) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index f9c4d26..a5bf0be 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -583,6 +583,29 @@ def test_repeat_datetimelist_basic_usage(self): 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"]), From 935ae4ab2baca39073ef9919d97b00ede69373d0 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 15:56:44 +0100 Subject: [PATCH 09/13] Correct formatting issues --- pyflow/attributes.py | 5 ++++- tests/test_attributes.py | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 389602a..55e8ef2 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -757,7 +757,10 @@ def __init__(self, name, values): 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): + 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) diff --git a/tests/test_attributes.py b/tests/test_attributes.py index a5bf0be..36ce55e 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -556,6 +556,7 @@ def test_repeat_datetime(self): 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) @@ -585,21 +586,18 @@ def test_repeat_datetimelist_basic_usage(self): 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]), - ) + 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"' - ) + asserts = 'repeat datetimelist A "20000101T123456" "20000102T000000"' defn = str(s.ecflow_definition()) for a in asserts: assert a in defn @@ -628,20 +626,23 @@ def test_repeat_datetimelist_with_truncated_string_values(self): s.check_definition() def test_repeat_datetimelist_with_none_value(self): - with pytest.raises(ValueError, match="values cannot be None" ): + 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" ): + 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" ): + 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" ): + + 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): From 9ba45e6eca2d8003753e3f296f3f12c620ae08f1 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Thu, 4 Jun 2026 16:44:03 +0100 Subject: [PATCH 10/13] Update docs to include RepeatDateTime/List attributes --- docs/_ext/ecflow_lexers.py | 2 +- docs/content/api-reference.rst | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/_ext/ecflow_lexers.py b/docs/_ext/ecflow_lexers.py index a202c1c..1c2ee3f 100644 --- a/docs/_ext/ecflow_lexers.py +++ b/docs/_ext/ecflow_lexers.py @@ -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 diff --git a/docs/content/api-reference.rst b/docs/content/api-reference.rst index f7df6d5..d518e46 100644 --- a/docs/content/api-reference.rst +++ b/docs/content/api-reference.rst @@ -160,6 +160,14 @@ Repeat .. autoclass:: pyflow.attributes.RepeatDateList +.. _RepeatDateTime: + +.. autoclass:: pyflow.attributes.RepeatDateTime + +.. _RepeatDateTimeList: + +.. autoclass:: pyflow.attributes.RepeatDateTimeList + .. _RepeatDay: .. autoclass:: pyflow.attributes.RepeatDay From e04b20dc9be80ccd3bbb9eb1355ee2f3e7b5c1b0 Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Mon, 8 Jun 2026 13:29:49 +0100 Subject: [PATCH 11/13] Install the latest sources when generating docs This allows using the latest changes when building the documentation. --- .readthedocs.yaml | 6 ++++++ docs/environment.yml | 1 - docs/requirements.txt | 4 ++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index d347971..3807566 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -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 . diff --git a/docs/environment.yml b/docs/environment.yml index db553a6..a244d83 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -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 diff --git a/docs/requirements.txt b/docs/requirements.txt index e4b5648..cc30bbd 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -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 \ No newline at end of file +sphinx-tabs \ No newline at end of file From 4993b38d47b0c86ec57baf2f93a4f27ecd6bed4c Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Mon, 8 Jun 2026 15:29:13 +0100 Subject: [PATCH 12/13] Use pull_request_target when triggering docs build As per the documentation of readthedocs/actions/preview@v1. --- .github/workflows/readthedocs-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/readthedocs-pr.yml b/.github/workflows/readthedocs-pr.yml index 7939a4c..6dd0782 100644 --- a/.github/workflows/readthedocs-pr.yml +++ b/.github/workflows/readthedocs-pr.yml @@ -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 From d16cc03d6e68791b06de34a64f463b9534643d1e Mon Sep 17 00:00:00 2001 From: Marcos Bento Date: Tue, 9 Jun 2026 09:03:49 +0100 Subject: [PATCH 13/13] Decorate RepeatDateTimeList with `supported('')` --- pyflow/attributes.py | 12 ++- pyflow/importer.py | 71 ++++++++++++++++ pyproject.toml | 1 + tests/test_supported.py | 176 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 tests/test_supported.py diff --git a/pyflow/attributes.py b/pyflow/attributes.py index 55e8ef2..06f3653 100644 --- a/pyflow/attributes.py +++ b/pyflow/attributes.py @@ -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 @@ -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. @@ -665,6 +666,10 @@ class RepeatDateTime(Exportable): 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__( @@ -729,6 +734,7 @@ 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. @@ -748,6 +754,10 @@ class RepeatDateTimeList(Repeat): 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): diff --git a/pyflow/importer.py b/pyflow/importer.py index 870c7b8..3969ee7 100644 --- a/pyflow/importer.py +++ b/pyflow/importer.py @@ -1,5 +1,9 @@ +import functools import os import sys +import types + +from packaging.specifiers import SpecifierSet try: import ecflow @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 440f8ce..a07e905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dynamic = ["version", "readme"] dependencies = [ "jinja2", + "packaging", "requests", ] diff --git a/tests/test_supported.py b/tests/test_supported.py new file mode 100644 index 0000000..cd58406 --- /dev/null +++ b/tests/test_supported.py @@ -0,0 +1,176 @@ +import ecflow +import pytest +from packaging import version + +import pyflow +from pyflow.importer import supported + + +def make_widget(specifier, current): + """Build a class decorated with an explicitly instrumented specifier/current version.""" + + @supported(specifier, current=current) + class Widget: + def __init__(self, value=0): + self.value = value + + def a_method(self): + "A Widget method." + return self.value + + @property + def a_property(self): + "A Widget property." + return self.value * 2 + + return Widget + + +def test_widget_use_is_disallowed_on_older_version(): + Widget = make_widget(specifier=">=5.12.0", current="5.0.0") + with pytest.raises(NotImplementedError): + Widget() + + +def test_widget_use_is_allowed_on_newer_version(): + Widget = make_widget(specifier=">=5.12.0", current="5.13.0") + assert Widget(3).value == 3 + + +def test_widget_use_is_allowed_on_boundary_version(): + """The exact lower-bound version must be accepted (>= semantics).""" + Widget = make_widget(specifier=">=5.12.0", current="5.12.0") + assert Widget(7).value == 7 + + +def test_widget_use_is_disallowed_on_just_below_boundary(): + Widget = make_widget(specifier=">=5.12.0", current="5.11.9") + with pytest.raises(NotImplementedError): + Widget() + + +def test_error_message_contains_specifier_and_name(): + Widget = make_widget(specifier=">=5.20.1", current="5.6.7") + with pytest.raises(NotImplementedError) as exc: + Widget() + message = str(exc.value) + assert "Widget" in message + assert ">=5.20.1" in message + assert "5.6.7" in message + + +def test_methods_are_guarded_when_unsupported(): + """Ensure non-__init__ methods also raise when unsupported.""" + + Widget = make_widget(specifier=">=5.12.0", current="5.0.0") + # __init__ is guarded too, so build with an unguarded instance via __new__. + w = Widget.__new__(Widget) + with pytest.raises(NotImplementedError): + w.a_method() + + +def test_methods_work_when_supported(): + Widget = make_widget(specifier=">=5.12.0", current="5.20.0") + w = Widget(5) + assert w.a_method() == 5 + + +def test_properties_remain_properties(): + """ + Ensure properties behave as properties (return a computed value) after decoration. + """ + Widget = make_widget(specifier=">=5.12.0", current="5.20.0") + w = Widget(4) + assert w.a_property == 8 + assert isinstance(type(w).__dict__["a_property"], property) + + +def test_properties_are_disallowed_on_older_version(): + Widget = make_widget(specifier=">=5.12.0", current="5.0.0") + w = Widget.__new__(Widget) + with pytest.raises(NotImplementedError): + _ = w.a_property + + +def test_properties_are_allowed_on_newer_version(): + Widget = make_widget(specifier=">=5.12.0", current="5.20.0") + w = Widget(4) + assert w.a_property == 8 + + +def test_wraps_preserves_metadata(): + Widget = make_widget(specifier=">=5.12.0", current="5.20.0") + assert Widget.a_method.__name__ == "a_method" + assert Widget.a_method.__doc__ == "A Widget method." + assert Widget.__dict__["a_property"].__doc__ == "A Widget property." + + +def test_current_defaults_to_installed_ecflow_version(): + """Without an explicit ``current``, the installed ecFlow version is used.""" + + @supported(">=9999.0.0") + class Future: + def __init__(self): + pass + + with pytest.raises(NotImplementedError): + Future() + + @supported(">=0.0.1") + class Ancient: + def __init__(self): + self.ok = True + + assert Ancient().ok is True + + +def test_compound_specifier_excludes_upper_bound(): + """A compound specifier like >=5.12.0,<5.13.0 rejects versions outside the range.""" + Widget = make_widget(specifier=">=5.12.0,<5.13.0", current="5.13.0") + with pytest.raises(NotImplementedError): + Widget() + + +def test_compound_specifier_allows_version_in_range(): + Widget = make_widget(specifier=">=5.12.0,<5.13.0", current="5.12.5") + assert Widget(1).value == 1 + + +# ----------------------------------------------------------------------------- + + +def _installed_below(min_version): + return version.parse(ecflow.__version__) < version.parse(min_version) + + +def test_repeat_datetime_builds_on_installed_ecflow(): + if _installed_below("5.12.0"): + pytest.skip("RepeatDateTime requires ecFlow >= 5.12.0") + + with pyflow.Suite("s"): + with pyflow.Task("t"): + repeat = pyflow.RepeatDateTime( + "REPEAT_DATETIME", + "20190101T120000", + "20191231T120000", + "12:00:00", + ) + + assert repeat.name == "REPEAT_DATETIME" + assert not callable(repeat.second) + assert isinstance(type(repeat).__dict__["second"], property) + + +def test_repeat_datetimelist_builds_on_installed_ecflow(): + if _installed_below("5.17.0"): + pytest.skip("RepeatDateTimeList requires ecFlow >= 5.17.0") + + with pyflow.Suite("s"): + with pyflow.Task("t"): + repeat = pyflow.RepeatDateTimeList( + "REPEAT_DATETIME", ["20190101T120000", "20190103"] + ) + + assert repeat.name == "REPEAT_DATETIME" + assert not callable(repeat.values) + assert isinstance(type(repeat).__dict__["values"], property)