Skip to content

Commit 4798b7a

Browse files
authored
Resolve lookup plugin variables before execution (#152)
* lookup arg improvements * Bump black version * Additional test coverage * Correct version number * bump version v26.8.1 -> v26.10.0
1 parent ba785af commit 4798b7a

6 files changed

Lines changed: 230 additions & 15 deletions

File tree

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ repos:
3232
hooks:
3333
- id: prettier
3434
- repo: https://github.com/psf/black
35-
rev: 26.1.0
35+
rev: 26.3.0
3636
hooks:
3737
- id: black
3838
args:

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
# v26.10.0
4+
5+
- Resolve Jinja-templated lookup arguments before invoking lookup plugins.
6+
- Make lookup plugin `globals` resolution lazy so plugins can access needed variables without forcing unrelated deep variable chains to resolve.
7+
- Preserve lookup compatibility when lazy variable loading is enabled.
8+
39
# v25.8.1
410

511
- Handle race condition when creating directories in logging module, triggered byu many threads trying to create the same directory at the same time.

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "opentaskpy"
7-
version = "v26.8.1"
7+
version = "v26.10.0"
88
authors = [{ name = "Adam McDonagh", email = "adam@elitemonkey.net" }]
99
license-files = [ "LICENSE" ]
1010

@@ -42,7 +42,7 @@ requires-python = ">=3.11"
4242
dev = [
4343
"types-requests >=2.28",
4444
"types-paramiko >=3.0",
45-
"black == 26.1.0",
45+
"black == 26.3.0",
4646
"isort",
4747
"pytest",
4848
"bumpver",
@@ -71,7 +71,7 @@ otf-batch-validator = "opentaskpy.cli.batch_validator:main"
7171
profile = 'black'
7272

7373
[tool.bumpver]
74-
current_version = "v26.8.1"
74+
current_version = "v26.10.0"
7575
version_pattern = "vYY.WW.PATCH[-TAG]"
7676
commit_message = "bump version {old_version} -> {new_version}"
7777
commit = true

src/opentaskpy/config/loader.py

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import os
88
import sys
99
from glob import glob
10+
from typing import Any
1011

1112
import jinja2
1213
from jinja2 import meta
@@ -21,6 +22,45 @@
2122
MAX_DEPTH = 5
2223

2324

25+
class LazyResolvedDict(dict):
26+
"""Dictionary wrapper that resolves values only when accessed."""
27+
28+
def __init__(
29+
self,
30+
values: dict[str, Any],
31+
resolver: Any,
32+
resolve_lookups: bool = False,
33+
) -> None:
34+
"""Initialise the wrapper around a dictionary of unresolved values."""
35+
super().__init__(values)
36+
self._resolver = resolver
37+
self._resolve_lookups = resolve_lookups
38+
39+
def _wrap(self, value: Any) -> Any:
40+
if isinstance(value, dict):
41+
return LazyResolvedDict(
42+
value,
43+
self._resolver,
44+
resolve_lookups=self._resolve_lookups,
45+
)
46+
47+
if isinstance(value, list):
48+
return [self._wrap(item) for item in value]
49+
50+
return self._resolver(value, resolve_lookups=self._resolve_lookups)
51+
52+
def __getitem__(self, key: str) -> Any:
53+
"""Return a lazily resolved value for the requested key."""
54+
return self._wrap(super().__getitem__(key))
55+
56+
def get(self, key: str, default: Any = None) -> Any:
57+
"""Return a lazily resolved value when the key exists."""
58+
if key not in self:
59+
return default
60+
61+
return self[key]
62+
63+
2464
class ConfigLoader:
2565
"""Class responsible for loading and validating config files."""
2666

@@ -159,6 +199,28 @@ def get_global_variables(self) -> dict:
159199

160200
return self.global_variables
161201

202+
def _resolve_lookup_value(self, value: Any, resolve_lookups: bool = True) -> Any:
203+
"""Resolve templated values before they are passed to a lookup plugin."""
204+
if isinstance(value, dict):
205+
return {
206+
key: self._resolve_lookup_value(item, resolve_lookups=resolve_lookups)
207+
for key, item in value.items()
208+
}
209+
210+
if isinstance(value, list):
211+
return [
212+
self._resolve_lookup_value(item, resolve_lookups=resolve_lookups)
213+
for item in value
214+
]
215+
216+
if isinstance(value, str):
217+
if not resolve_lookups and "lookup(" in value:
218+
return value
219+
220+
return self._resolve_templated_variables_from_string(value)
221+
222+
return value
223+
162224
def template_lookup(self, plugin: str, **kwargs) -> str: # type: ignore[no-untyped-def]
163225
"""Lookup function used by Jinja.
164226
@@ -177,8 +239,16 @@ def template_lookup(self, plugin: str, **kwargs) -> str: # type: ignore[no-unty
177239
11, f"Got call to lookup function {plugin} with kwargs {kwargs}"
178240
)
179241

242+
kwargs = {
243+
key: self._resolve_lookup_value(value) for key, value in kwargs.items()
244+
}
245+
180246
# Append the globals to the kwargs
181-
kwargs["globals"] = self.global_variables
247+
kwargs["globals"] = LazyResolvedDict(
248+
self.global_variables,
249+
self._resolve_lookup_value,
250+
resolve_lookups=False,
251+
)
182252

183253
# Import the plugin if its not already loaded
184254
if f"opentaskpy.plugins.lookup.{plugin}" not in sys.modules:
Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
# ruff: noqa
21
"""An example plugin that simply returns the word "hello"."""
32

3+
import re
4+
45
import opentaskpy.otflogging
56

67
logger = opentaskpy.otflogging.init_logging(__name__)
@@ -9,10 +10,30 @@
910

1011

1112
def run(**kwargs) -> str: # type: ignore[no-untyped-def]
12-
"""Returns hello.
13+
"""Test."""
14+
dd = str(kwargs.get("dd"))
15+
yyyy = str(kwargs.get("yyyy"))
16+
globals_dict = kwargs.get("globals", {})
17+
global_dd = globals_dict.get("DD")
18+
global_yyyy = globals_dict.get("NESTED_VAR", {}).get("NESTED_VAR1")
19+
20+
# dd and YYY should be ints not strings, they should have been resolved. If not then we should error
21+
# Do a regex match to check that the variables are ints
22+
if (
23+
not re.match(r"^\d+$", dd)
24+
or not re.match(r"^\d+$", yyyy)
25+
or not re.match(r"^\d+$", global_dd)
26+
or not re.match(r"^\d+$", global_yyyy)
27+
):
28+
raise Exception( # pylint: disable=broad-exception-raised
29+
"dd, yyyy and globals values should be resolved integers"
30+
)
31+
32+
# dd and yyyy should be ints, so we can do some maths on them so they're not just strings when returned
33+
# to prove that they're resolved
34+
dd_int = int(dd)
35+
yyyy_int = int(yyyy)
36+
37+
result = f"hello {dd_int + 1 } {yyyy_int + 1}"
1338

14-
Returns:
15-
str: The word hello
16-
"""
17-
# Return a random number
18-
return "hello"
39+
return result

tests/test_config_loader.py

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from jinja2.exceptions import UndefinedError
1111
from pytest_shell import fs
1212

13-
from opentaskpy.config.loader import ConfigLoader
13+
from opentaskpy.config.loader import ConfigLoader, LazyResolvedDict
1414
from opentaskpy.exceptions import VariableResolutionTooDeepError
1515

1616
GLOBAL_VARIABLES: str | None = None
@@ -312,7 +312,13 @@ def test_custom_plugin(tmpdir):
312312
[
313313
{
314314
f"{tmpdir}/variables.json.j2": {
315-
"content": '{"test": "{{ lookup(\'test_plugin\') }}"}'
315+
"content": (
316+
"{"
317+
'"DD": "{{ now().strftime(\'%d\') }}",'
318+
'"YYYY": "{{ now().strftime(\'%Y\') }}",'
319+
'"NESTED_VAR": {"NESTED_VAR1": "{{ YYYY }}"},'
320+
'"test": "{{ lookup(\'test_plugin\', dd=DD, yyyy=NESTED_VAR.NESTED_VAR1) }}"}'
321+
)
316322
}
317323
},
318324
]
@@ -325,8 +331,120 @@ def test_custom_plugin(tmpdir):
325331

326332
# Test that the global variables are loaded correctly
327333
config_loader = ConfigLoader(tmpdir)
334+
global_variables = config_loader.get_global_variables()
335+
336+
dd = datetime.now().strftime("%d")
337+
yyyy = datetime.now().strftime("%Y")
338+
339+
assert global_variables["DD"] == dd
340+
assert global_variables["YYYY"] == yyyy
341+
assert global_variables["NESTED_VAR"] == {"NESTED_VAR1": yyyy}
342+
assert global_variables["test"] == f"hello {int(dd)+1} {int(yyyy)+1}"
343+
344+
345+
def test_custom_plugin_lazy_load(tmpdir):
346+
os.environ["OTF_LAZY_LOAD_VARIABLES"] = "1"
347+
348+
fs.create_files(
349+
[
350+
{
351+
f"{tmpdir}/variables.json.j2": {
352+
"content": (
353+
"{"
354+
'"DD": "{{ now().strftime(\'%d\') }}",'
355+
'"YYYY": "{{ now().strftime(\'%Y\') }}",'
356+
'"NESTED_VAR": {"NESTED_VAR1": "{{ YYYY }}"},'
357+
'"test": "{{ lookup(\'test_plugin\', dd=DD, yyyy=NESTED_VAR.NESTED_VAR1) }}"}'
358+
)
359+
}
360+
},
361+
{f"{tmpdir}/task.json": {"content": '{"test": "{{ test }}"}'}},
362+
]
363+
)
364+
365+
os.symlink(
366+
os.path.join(os.path.dirname(__file__), "../test/cfg", "plugins"),
367+
f"{tmpdir}/plugins",
368+
)
369+
370+
config_loader = ConfigLoader(tmpdir)
371+
372+
del os.environ["OTF_LAZY_LOAD_VARIABLES"]
373+
374+
dd = datetime.now().strftime("%d")
375+
yyyy = datetime.now().strftime("%Y")
376+
377+
assert config_loader.load_task_definition("task", cache=False) == {
378+
"test": f"hello {int(dd)+1} {int(yyyy)+1}"
379+
}
380+
381+
382+
def test_resolve_lookup_value_variants(tmpdir):
383+
fs.create_files(
384+
[
385+
{
386+
f"{tmpdir}/variables.json.j2": {
387+
"content": json.dumps(
388+
{
389+
"YYYY": "2026",
390+
"MM": "03",
391+
}
392+
)
393+
}
394+
},
395+
]
396+
)
397+
398+
config_loader = ConfigLoader(tmpdir)
399+
400+
assert config_loader._resolve_lookup_value({"year": "{{ YYYY }}"}) == {
401+
"year": "2026"
402+
}
403+
assert config_loader._resolve_lookup_value(["{{ YYYY }}", "{{ MM }}", 7]) == [
404+
"2026",
405+
"03",
406+
7,
407+
]
408+
assert (
409+
config_loader._resolve_lookup_value(
410+
"{{ lookup('file', path='/tmp/skip.txt') }}", resolve_lookups=False
411+
)
412+
== "{{ lookup('file', path='/tmp/skip.txt') }}"
413+
)
414+
assert config_loader._resolve_lookup_value(1234) == 1234
415+
416+
417+
def test_lazy_resolved_dict_accessors(tmpdir):
418+
fs.create_files(
419+
[
420+
{
421+
f"{tmpdir}/variables.json.j2": {
422+
"content": json.dumps(
423+
{
424+
"YYYY": "2026",
425+
"MM": "03",
426+
}
427+
)
428+
}
429+
},
430+
]
431+
)
432+
433+
config_loader = ConfigLoader(tmpdir)
434+
lazy_dict = LazyResolvedDict(
435+
{
436+
"nested": {"value": "{{ YYYY }}"},
437+
"items": ["{{ MM }}", 2],
438+
"lookup": "{{ lookup('file', path='/tmp/skip.txt') }}",
439+
},
440+
config_loader._resolve_lookup_value,
441+
resolve_lookups=False,
442+
)
328443

329-
assert config_loader.get_global_variables() == {"test": "hello"}
444+
assert lazy_dict["nested"]["value"] == "2026"
445+
assert lazy_dict["items"] == ["03", 2]
446+
assert lazy_dict.get("lookup") == "{{ lookup('file', path='/tmp/skip.txt') }}"
447+
assert lazy_dict.get("missing", "fallback") == "fallback"
330448

331449

332450
def test_default_filters(tmpdir):

0 commit comments

Comments
 (0)