Skip to content
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Multiple datafiles can be specified, and the variables from each will be accessi
in a namespace defined by the
[stem of the filename](https://docs.python.org/3/library/pathlib.html#pathlib.PurePath.stem).

#### Command line
##### Command line

Template variables can also be directly specified via the command line option `--datavar`
and can be accessed in the special namespace `__argdata__`. For example:
Expand All @@ -185,6 +185,33 @@ addmeta -d job.yaml -m meta.yaml --datavar freq='1daily' file.nc
```
Multiple variables can be defined in this way with multiple `--datavar` options.

#### Number Templates

In order for dynamically templated attributes to resolve to integers or floats rather than strings use the Jinja-like filter `| number`.
E.g. with the following datafile.yaml,
```yaml
integer_val: 5
float_val: 1.234
```
a metadata file similar to the following can be used,
```yaml
global:
# This non-dynamic attribute resolves to an integer
this_is_a_number: 5
# This dynamic attribute resolves to a string
this_is_a_string: "{{ datafile.integer }}"
# This dynamic attribute resolves to an integer
this_is_a_int: "{{ datafile.integer_val | number }}"
# These dynamic attributes resolve to floats
this_is_a_float: "{{ datafile.float_val | number }}"
this_is_also_a_float: "{{ datafile.integer_val | float | number }}"
```

- `| number` must be the last portion of the Jinja template (i.e. the string between `{{` and `}}`)
- `| number` is not valid Jinja itself, it will be removed before resolving the rest of the template with Jinja
- When using `| number`, `addmeta` will attempt to resolve the attribute's value to an integer first, then a float.


### metadata.yaml support

ACCESS-NRI models produce, and intake catalogues consume, a `metadata.yaml` file
Expand Down
32 changes: 32 additions & 0 deletions addmeta/addmeta.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,16 @@ def set_attribute(group, attribute, value, template_vars, verbose=False):
# Only valid to use jinja templates on strings
if isinstance(value, str):
try:
value, convert_to_number = detect_number_filter(value)

value = Template(value, undefined=StrictUndefined).render(template_vars)

if convert_to_number:
# Try to convert to an integer first then a float
try:
value = int(value)
except ValueError:
value = float(value)
except UndefinedError as e:
warn(f"Skip setting attribute '{attribute}': {e}")
return
Expand All @@ -195,6 +204,29 @@ def set_attribute(group, attribute, value, template_vars, verbose=False):

group.setncattr(attribute, value)

def detect_number_filter(value):
"""
Look for the jinja-like filter "| number".

If found return the value string with "| number" removed and True
Otherwise return the value string and False

- There might be multiple occurances of "| number"
- Number of whitespace characters is unknown
"""
# Match "| number }}" with any number of whitespace between
regx = r"(\|\s*number)\s*}}"
Comment thread
joshuatorrance marked this conversation as resolved.
Outdated
matches = re.findall(regx, value)
if matches:
# Remove the "| number" with however many spaces as captured by the regex
# Do this in a loop just in case there were multiple permutations of '| number'
for match in matches:
value = value.replace(match, '')

return value, True
else:
return value, False

Comment thread
joshuatorrance marked this conversation as resolved.
def serialise_dict_values(dictionary):
"""Serialise any list or arrays values in a dictionary"""
return {k: array_to_csv(v) if isinstance(v, (tuple, list)) else v for k, v in dictionary.items()}
Expand Down
177 changes: 176 additions & 1 deletion test/test_write_templated.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
from datetime import datetime, timezone, timedelta
from pathlib import Path

import netCDF4 as nc
import numpy as np
import jinja2
import pytest

from addmeta import read_yaml, read_metadata, add_meta, find_and_add_meta, isoformat
Expand Down Expand Up @@ -286,3 +287,177 @@ def test_now(make_nc):
meta_now = datetime.strptime(now_str, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
utc_now = datetime.now(timezone.utc)
assert meta_now - utc_now < timedelta(minutes=1)

@pytest.mark.parametrize(
"metadata,templates,expected,number_type",
[
# Test a raw number
({"number": 5}, {}, {"number": 5}, np.int32),
# Test a templated integer
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "5"}},
{"number": 5},
np.int32
),
# Test a templated integer cast to a float with jinja
(
{"number": "{{ __template__.number | float | number }}"},
{"__template__": {"number": "5"}},
{"number": 5},
np.float64
),
# Test a templated integer with no spaces
(
{"number": "{{__template__.number|number}}"},
{"__template__": {"number": "5"}},
{"number": 5},
np.int32
),
# Test a templated integer with excessive spaces
(
{"number": "{{ __template__.number | number}}"},
{"__template__": {"number": "5"}},
{"number": 5},
np.int32
),
# Test a templated integer with newline and tab characters
(
{"number": "{{ __template__.number|\t\nnumber\n}}"},
{"__template__": {"number": "5"}},
{"number": 5},
np.int32
),
# Test multiple "| numbers"
(
{"number": "{{ __template__.number | number}}{{ __template__.number | number}}"},
{"__template__": {"number": "5"}},
{"number": 55},
np.int32
),
Comment thread
joshuatorrance marked this conversation as resolved.
# Test multiple "| numbers" with varying whitespace
(
{"number": "{{__template__.number|number}}{{ __template__.number | number}}{{ __template__.number | number}}"},
{"__template__": {"number": "5"}},
{"number": 555},
np.int32
),
# Test a templated integer without the jinja brackets
(
{"number": "__template__.number | number"},
{"__template__": {"number": "5"}},
{"number": "__template__.number | number"},
str
),
# Test a templated integer with underscored notation
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "5_000_000"}},
{"number": 5000000},
np.int32
),
# Test a templated float
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "5.1"}},
{"number": 5.1},
np.float64
),
# Test a templated float that happens to be preceded by digits
(
{"number": "123{{ __template__.number | number }}"},
{"__template__": {"number": "5.1"}},
{"number": 1235.1},
np.float64
),
# Test a templated float with no decimal point numbers
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "5."}},
{"number": 5.},
np.float64
),
# Test a templated float in exponential notation
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "1.5e5"}},
{"number": 1.5e5},
np.float64
),
# Test a templated number without the fake jinja filter
Comment thread
joshuatorrance marked this conversation as resolved.
Outdated
(
{"number": "{{ __template__.number }}"},
{"__template__": {"number": "5.1"}},
{"number": "5.1"},
str
),
]
)
def test_number_templates(make_nc, metadata, templates, expected, number_type):
# Put the metadata under global
metadata = {"global": metadata}

# Add the attrs from make_nc to expected
common_attrs = {
"Publisher": "Will be overwritten",
"unlikelytobeoverwritten": "total rubbish",
}
expected.update(common_attrs)

find_and_add_meta([make_nc], metadata, templates, [])

actual = get_meta_data_from_file(make_nc)

assert actual == expected
assert isinstance(actual["number"], number_type)

@pytest.mark.parametrize(
"metadata,templates,failure_str",
[
# Test a string with the fake number jinja filter
Comment thread
joshuatorrance marked this conversation as resolved.
Outdated
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "five"}},
None
),
# Test a malformed float
(
{"number": "{{ __template__.number | number }}"},
{"__template__": {"number": "5.1.2"}},
None
),
# Test a valid float but with a string around it
(
{"number": "xx{{ __template__.number | number }}xx"},
{"__template__": {"number": "5.1"}},
"xx5.1xx"
),
]
)
def test_number_templates_failures(make_nc, metadata, templates, failure_str):
# Put the metadata under global
metadata = {"global": metadata}

value = templates["__template__"]["number"]

# If failure string hasn't been supplied just use the template value
failure_str = failure_str if failure_str else value
with pytest.raises(ValueError, match=f"could not convert string to float: \'{failure_str}\'"):
find_and_add_meta([make_nc], metadata, templates, [])

@pytest.mark.parametrize(
"metadata,templates",
[
# Test with real jinja filter but without number last
(
{"number": "{{ __template__.number | number | float }}"},
{"__template__": {"number": "5.1"}},
),
]
)
def test_number_templates_failure_filter_order(make_nc, metadata, templates):
# Put the metadata under global
metadata = {"global": metadata}

with pytest.raises(jinja2.exceptions.TemplateAssertionError, match="No filter named \'number\'"):
find_and_add_meta([make_nc], metadata, templates, [])
Loading