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

Add CYLC_ variables to template engine globals. #5571

Merged
merged 4 commits into from
Jul 26, 2023
Merged
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
1 change: 1 addition & 0 deletions changes.d/5571.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Make workflow `CYLC_` variables available to the template processor during parsing.
7 changes: 7 additions & 0 deletions cylc/flow/parsec/empysupport.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import typing as t

from cylc.flow.parsec.exceptions import EmPyError
from cylc.flow.parsec.fileparse import get_cylc_env_vars


def empyprocess(
Expand Down Expand Up @@ -52,6 +53,12 @@ def empyprocess(
ftempl = StringIO('\n'.join(flines))
xtempl = StringIO()
interpreter = em.Interpreter(output=em.UncloseableFile(xtempl))

# Add `CYLC_` environment variables to the global namespace.
interpreter.updateGlobals(
get_cylc_env_vars()
)

try:
interpreter.file(ftempl, '<template>', template_vars)
except Exception as exc:
Expand Down
24 changes: 22 additions & 2 deletions cylc/flow/parsec/fileparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,27 @@
)


def get_cylc_env_vars() -> t.Dict[str, str]:
"""Return a restricted dict of CYLC_ environment variables for templating.

The following variables are ignored because the do not necessarily reflect
the running code version (I might not use the "cylc" wrapper, or it might
select a different version):

CYLC_VERSION
Set as a template variable elsewhere, from the hardwired code version.

CYLC_ENV_NAME
Providing it as a template variable would just be misleading.
"""
return {
key: val
for key, val in os.environ.items()
if key.startswith('CYLC_')
if key not in ["CYLC_VERSION", "CYLC_ENV_NAME"]
MetRonnie marked this conversation as resolved.
Show resolved Hide resolved
}


def _concatenate(lines):
"""concatenate continuation lines"""
index = 0
Expand Down Expand Up @@ -446,10 +467,9 @@ def read_and_proc(
flines = inline(
flines, fdir, fpath, viewcfg=viewcfg)

# Add the hardwired code version to template vars as CYLC_VERSION
template_vars['CYLC_VERSION'] = __version__

template_vars = merge_template_vars(template_vars, extra_vars)

template_vars['CYLC_TEMPLATE_VARS'] = template_vars

# Fail if templating_detected ≠ hashbang
Expand Down
23 changes: 8 additions & 15 deletions cylc/flow/parsec/jinja2support.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@

from cylc.flow import LOG
from cylc.flow.parsec.exceptions import Jinja2Error
from cylc.flow.parsec.fileparse import get_cylc_env_vars

TRACEBACK_LINENO = re.compile(
r'\s+?File "(?P<file>.*)", line (?P<line>\d+), in .*template'
Expand Down Expand Up @@ -186,11 +187,15 @@ def jinja2environment(dir_=None):
envnsp[fname] = getattr(module, fname)

# Import WORKFLOW HOST USER ENVIRONMENT into template:
# (usage e.g.: {{environ['HOME']}}).
# (Usage e.g.: {{environ['HOME']}}).
env.globals['environ'] = os.environ
env.globals['raise'] = raise_helper
env.globals['assert'] = assert_helper

# Add `CYLC_` environment variables to the global namespace.
env.globals.update(
get_cylc_env_vars()
)
return env


Expand Down Expand Up @@ -269,7 +274,6 @@ def jinja2process(
# CALLERS SHOULD HANDLE JINJA2 TEMPLATESYNTAXERROR AND TEMPLATEERROR
# AND TYPEERROR (e.g. for not using "|int" filter on number inputs.
# Convert unicode to plain str, ToDo - still needed for parsec?)

try:
env = jinja2environment(dir_)
template = env.from_string('\n'.join(flines[1:]))
Expand Down Expand Up @@ -300,16 +304,5 @@ def jinja2process(
lines=get_error_lines(fpath, flines),
)

flow_config = []
for line in lines:
# Jinja2 leaves blank lines where source lines contain
# only Jinja2 code; this matters if line continuation
# markers are involved, so we remove blank lines here.
if not line.strip():
continue
# restoring newlines here is only necessary for display by
# the cylc view command:
# ##flow_config.append(line + '\n')
flow_config.append(line)

return flow_config
# Ignore blank lines (lone Jinja2 statements leave blank lines behind)
return [line for line in lines if line.strip()]
hjoliver marked this conversation as resolved.
Show resolved Hide resolved
37 changes: 23 additions & 14 deletions cylc/flow/scripts/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@

Print a processed workflow configuration.

Note:
This is different to `cylc config` which displays the parsed
configuration (as Cylc would see it).
Print workflow configurations as processed before full parsing by Cylc. This
hjoliver marked this conversation as resolved.
Show resolved Hide resolved
includes Jinja2 or Empy template processing, and inlining of include-files.
Some explanatory markup may also be requested.

Warning:
This command will fail if `CYLC_` template variables are referenced
without default values, because they are only defined for full parsing.
E.g. (Jinja2): `{{CYLC_WORKFLOW_ID | default("not defined")}}`.

See also `cylc config`, which displays the fully parsed configuration.

"""

import asyncio
Expand Down Expand Up @@ -115,20 +123,21 @@ async def _main(options: 'Values', workflow_id: str) -> None:
constraint='workflows',
)
# read in the flow.cylc file
viewcfg = {
'mark': options.mark,
'single': options.single,
'label': options.label,
'empy': options.empy or options.process,
'jinja2': options.jinja2 or options.process,
'contin': options.cat or options.process,
'inline': (options.inline or options.jinja2 or options.empy
or options.process),
}
for line in read_and_proc(
flow_file,
get_template_vars(options),
viewcfg=viewcfg,
viewcfg={
'mark': options.mark,
'single': options.single,
'label': options.label,
'empy': options.empy or options.process,
'jinja2': options.jinja2 or options.process,
'contin': options.cat or options.process,
'inline': (
options.jinja2 or options.empy or
options.inline or options.process
),
},
opts=options,
):
print(line)
6 changes: 4 additions & 2 deletions tests/functional/cylc-cat-log/01-remote.t
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ TEST_NAME="${TEST_NAME_BASE}-validate"
run_ok "${TEST_NAME}" cylc validate "${WORKFLOW_NAME}"
#-------------------------------------------------------------------------------
TEST_NAME="${TEST_NAME_BASE}-run"
workflow_run_ok "${TEST_NAME}" cylc play --debug --no-detach "${WORKFLOW_NAME}"
workflow_run_ok "${TEST_NAME}" \
cylc play --debug --no-detach \
-s "CYLC_TEST_PLATFORM='${CYLC_TEST_PLATFORM}'" "${WORKFLOW_NAME}"
#-------------------------------------------------------------------------------
TEST_NAME=${TEST_NAME_BASE}-task-out
cylc cat-log -f o "${WORKFLOW_NAME}//1/a-task" >"${TEST_NAME}.out"
Expand All @@ -56,7 +58,7 @@ grep_ok "jumped over the lazy dog" "${TEST_NAME}.out"
# remote
TEST_NAME=${TEST_NAME_BASE}-task-status
cylc cat-log -f s "${WORKFLOW_NAME}//1/a-task" >"${TEST_NAME}.out"
grep_ok "CYLC_JOB_RUNNER_NAME=background" "${TEST_NAME}.out"
grep_ok "CYLC_JOB_RUNNER_NAME=at" "${TEST_NAME}.out"
#-------------------------------------------------------------------------------
# local
TEST_NAME=${TEST_NAME_BASE}-task-activity
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/parsec/test_fileparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from cylc.flow.parsec.fileparse import (
_prepend_old_templatevars,
_get_fpath_for_source,
get_cylc_env_vars,
addict,
addsect,
multiline,
Expand Down Expand Up @@ -736,3 +737,23 @@ def test_get_fpath_for_source(tmp_path):
opts.against_source = True
assert _get_fpath_for_source(
rundir / 'flow.cylc', opts) == str(srcdir / 'flow.cylc')


def test_get_cylc_env_vars(monkeypatch):
"""It should return CYLC env vars but not CYLC_VERSION or CYLC_ENV_NAME."""
monkeypatch.setattr(
'os.environ',
{
"CYLC_VERSION": "betwixt",
"CYLC_ENV_NAME": "between",
"CYLC_QUESTION": "que?",
"CYLC_ANSWER": "42",
"FOO": "foo"
}
)
assert (
get_cylc_env_vars() == {
"CYLC_QUESTION": "que?",
"CYLC_ANSWER": "42",
}
)
73 changes: 73 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.

import os
import sys
from optparse import Values
from typing import Any, Callable, Dict, List, Optional, Tuple, Type
from pathlib import Path
Expand All @@ -34,6 +35,7 @@
WorkflowConfigError,
XtriggerConfigError,
)
from cylc.flow.parsec.exceptions import Jinja2Error, EmPyError
from cylc.flow.scheduler_cli import RunOptions
from cylc.flow.scripts.validate import ValidateOptions
from cylc.flow.workflow_files import WorkflowFiles
Expand Down Expand Up @@ -1019,6 +1021,77 @@ def test_rsync_includes_will_not_accept_sub_directories(tmp_flow_config):
assert "Directories can only be from the top level" in str(exc.value)


@pytest.mark.parametrize(
'cylc_var, expected_err',
[
["CYLC_WORKFLOW_NAME", None],
["CYLC_BEEF_WELLINGTON", (Jinja2Error, "is undefined")],
hjoliver marked this conversation as resolved.
Show resolved Hide resolved
]
)
def test_jinja2_cylc_vars(tmp_flow_config, cylc_var, expected_err):
"""Defined CYLC_ variables should be available to Jinja2 during parsing.

This test is not located in the jinja2_support unit test module because
CYLC_ variables are only defined during workflow config parsing.
"""
reg = 'nodule'
flow_file = tmp_flow_config(reg, """#!Jinja2
# {{""" + cylc_var + """}}
[scheduler]
allow implicit tasks = True
[scheduling]
[[graph]]
R1 = foo
""")
if expected_err is None:
WorkflowConfig(workflow=reg, fpath=flow_file, options=Values())
else:
with pytest.raises(expected_err[0]) as exc:
WorkflowConfig(workflow=reg, fpath=flow_file, options=Values())
assert expected_err[1] in str(exc)
hjoliver marked this conversation as resolved.
Show resolved Hide resolved


@pytest.mark.parametrize(
'cylc_var, expected_err',
[
["CYLC_WORKFLOW_NAME", None],
["CYLC_BEEF_WELLINGTON", (EmPyError, "is not defined")],
]
)
def test_empy_cylc_vars(tmp_flow_config, cylc_var, expected_err):
"""Defined CYLC_ variables should be available to empy during parsing.

This test is not located in the empy_support unit test module because
CYLC_ variables are only defined during workflow config parsing.
"""
reg = 'nodule'
flow_file = tmp_flow_config(reg, """#!empy
# @(""" + cylc_var + """)
[scheduler]
allow implicit tasks = True
[scheduling]
[[graph]]
R1 = foo
""")

# empy replaces sys.stdout with a "proxy". And pytest needs it for capture?
# (clue: "pytest --capture=no" avoids the error)
stdout = sys.stdout
sys.stdout._testProxy = lambda: ''
sys.stdout.pop = lambda _: ''
sys.stdout.push = lambda _: ''
sys.stdout.clear = lambda _: ''

if expected_err is None:
WorkflowConfig(workflow=reg, fpath=flow_file, options=Values())
else:
with pytest.raises(expected_err[0]) as exc:
WorkflowConfig(workflow=reg, fpath=flow_file, options=Values())
assert expected_err[1] in str(exc)

sys.stdout = stdout


def test_valid_rsync_includes_returns_correct_list(tmp_flow_config):
"""Test that the rsync includes in the correct """
id_ = 'rsynctest'
Expand Down
Loading