1
0

initial commit

This commit is contained in:
2025-06-05 11:01:19 +03:00
commit 48f13f97a3
297 changed files with 7136 additions and 0 deletions

18
j2cfg/j2cfg-dump.py Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
import os.path
import sys
def main():
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import j2cfg
j = j2cfg.J2cfg(dump_only=True)
print(j.dump_config())
sys.exit(0)
if __name__ == "__main__":
main()

24
j2cfg/j2cfg-multi.py Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env python3
import os.path
import sys
def main():
if len(sys.argv) < 2:
raise ValueError('not enough arguments (min: 1)')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import j2cfg
r = j2cfg.J2cfg(strict=False)
ret = 0
for f in sys.argv[1:]:
if not r.render_file(f, None):
ret = 1
sys.exit(ret)
if __name__ == "__main__":
main()

28
j2cfg/j2cfg-single.py Executable file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
import os.path
import sys
def main():
if len(sys.argv) < 2:
raise ValueError('not enough arguments (min: 1)')
if len(sys.argv) > 3:
raise ValueError('too many arguments (max: 2)')
if not sys.argv[1]:
raise ValueError('specify input file')
if (len(sys.argv) == 3) and (not sys.argv[2]):
raise ValueError('specify output file')
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import j2cfg
r = j2cfg.J2cfg()
r.render_file(*sys.argv[1:])
sys.exit(0)
if __name__ == "__main__":
main()

266
j2cfg/j2cfg/__init__.py Normal file
View File

@@ -0,0 +1,266 @@
import importlib
import json
import os
import os.path
import sys
import jinja2
import wcmatch.wcmatch
import yaml
from .functions import *
from .settings import *
J2CFG_CONFIG_EXT = ['yml', 'yaml', 'json']
class J2cfg:
def __init__(self, strict=True, config_file=None, config_path=None,
modules=None, search_path=None, template_suffix=None,
dump_only=False):
if dump_only is None:
self.dump_only = False
else:
self.dump_only = bool(dump_only)
self.config_file = config_file or os.getenv('J2CFG_CONFIG')
if self.config_file is not None:
self.config_file = str(self.config_file)
if config_path is not None:
self.config_path = any_to_str_list(config_path)
else:
self.config_path = os.getenv('J2CFG_PATH')
if self.config_path is not None:
self.config_path = str_split_to_list(self.config_path, ':')
if self.config_path is not None:
self.config_path = uniq_str_list(self.config_path)
else:
self.config_path = J2CFG_PATH
self.kwargs = {'j2cfg': {}}
def merge_dict_from_file(filename):
if filename is None:
return False
f = str(filename)
if f == '':
return False
if not os.path.exists(f):
return False
if not os.path.isfile(f):
print(
f'J2cfg: not a file, skipping: {filename}',
file=sys.stderr)
return False
if f.endswith('.yml') or f.endswith('.yaml'):
with open(f, mode='r', encoding='utf-8') as fx:
for x in yaml.safe_load_all(fx):
if not x:
continue
self.kwargs['j2cfg'] = merge_dict_recurse(
self.kwargs['j2cfg'], x
)
return True
if f.endswith('.json'):
with open(f, mode='r', encoding='utf-8') as fx:
self.kwargs['j2cfg'] = merge_dict_recurse(
self.kwargs['j2cfg'], json.load(fx)
)
return True
print(
f'J2cfg: non-recognized name extension: {filename}',
file=sys.stderr)
return False
def merge_dict_default():
search_pattern = '|'.join(['*.' + ext for ext in J2CFG_CONFIG_EXT])
search_flags = wcmatch.wcmatch.SYMLINKS
for d in self.config_path:
if not os.path.isdir(d):
continue
m = wcmatch.wcmatch.WcMatch(d, search_pattern,
flags=search_flags)
for f in sorted(m.match()):
if self.dump_only:
real_f = os.path.realpath(f)
if f == real_f:
print(
f'J2cfg: try loading {f}',
file=sys.stderr
)
else:
print(
f'J2cfg: try loading {f} <- {real_f}',
file=sys.stderr
)
merge_dict_from_file(f)
if self.config_file is None:
merge_dict_from_file(J2CFG_DEFAULTS_FILE)
merge_dict_default()
else:
if os.path.isfile(self.config_file):
merge_dict_from_file(self.config_file)
else:
print(
'J2cfg: J2cfg config file does not exist, skipping: '
+ f'{self.config_file}',
file=sys.stderr
)
if self.dump_only:
return
self.strict = strict
if not isinstance(self.strict, bool):
self.strict = True
self.search_path = search_path
if self.search_path is None:
self.search_path = os.getenv('J2CFG_SEARCH_PATH')
if self.search_path is not None:
self.search_path = str_split_to_list(self.search_path, ':')
if self.search_path is None:
self.search_path = self.config_path.copy()
else:
self.search_path = uniq_str_list(any_to_str_list(self.search_path))
# RFC: should we use the current working directory early?
for d in [os.getcwd()]:
if d not in self.search_path:
self.search_path.insert(0, d)
self.modules = modules or os.getenv('J2CFG_MODULES')
if self.modules is None:
self.modules = J2CFG_PYTHON_MODULES
else:
if isinstance(self.modules, str):
self.modules = str_split_to_list(self.modules)
else:
self.modules = any_to_str_list(self.modules)
self.modules = uniq_str_list(self.modules)
self.template_suffix = template_suffix or os.getenv('J2CFG_SUFFIX')
if self.template_suffix is None:
self.template_suffix = J2CFG_TEMPLATE_EXT
else:
self.template_suffix = str(self.template_suffix)
if self.template_suffix == '':
self.template_suffix = J2CFG_TEMPLATE_EXT
if not self.template_suffix.startswith('.'):
self.template_suffix = '.' + self.template_suffix
self.kwargs.update({
'env': os.environ,
'env_vars_preserve': J2CFG_PRESERVE_ENVS,
'env_vars_passthrough': J2CFG_PASSTHROUGH_ENVS,
})
for m in self.modules:
if m in self.kwargs:
print(f'J2cfg: kwargs already has {m} key',
file=sys.stderr)
continue
self.kwargs[m] = importlib.import_module(m)
self.j2fs_loaders = {
d: jinja2.FileSystemLoader(
d, encoding='utf-8', followlinks=True,
) for d in self.search_path
}
self.j2env = jinja2.Environment(
extensions=J2CFG_JINJA_EXTENSIONS,
loader=jinja2.ChoiceLoader([
self.j2fs_loaders[d] for d in self.search_path
]),
)
def init_env(e: jinja2.Environment):
for s in J2CFG_FILTERS:
n = s.__name__
if n in e.filters:
print(f'J2cfg: filters already has {n} key',
file=sys.stderr)
continue
e.filters[n] = s
init_env(self.j2env)
def dump_config(self):
return yaml.safe_dump(self.kwargs['j2cfg'])
def ensure_fs_loader_for(self, directory: str):
if self.dump_only:
raise ValueError('dump_only is True')
if directory in self.j2fs_loaders:
return
self.j2fs_loaders[directory] = jinja2.FileSystemLoader(
directory, encoding='utf-8', followlinks=True,
)
def render_file(self, file_in, file_out=None) -> bool:
if self.dump_only:
raise ValueError('dump_only is True')
def render_error(msg) -> bool:
if self.strict:
raise ValueError(msg)
print(f'J2cfg: {msg}', file=sys.stderr)
return False
if file_in is None:
return render_error(
'argument "file_in" is None')
f_in = str(file_in)
if f_in == '':
return render_error(
'argument "file_in" is empty')
if not os.path.exists(f_in):
return render_error(
f'file is missing: {file_in}')
if not os.path.isfile(f_in):
return render_error(
f'not a file: {file_in}')
f_out = file_out
if f_out is None:
if not f_in.endswith(self.template_suffix):
return render_error(
f'input file name extension mismatch: {file_in}')
f_out = os.path.splitext(f_in)[0]
dirs = self.search_path.copy()
for d in [os.getcwd(), os.path.dirname(f_in)]:
if d in dirs:
continue
self.ensure_fs_loader_for(d)
dirs.insert(0, d)
if f_in.startswith('/'):
self.ensure_fs_loader_for('/')
dirs.append('/')
j2_environ = self.j2env.overlay(loader=jinja2.ChoiceLoader([
self.j2fs_loaders[d] for d in dirs
]))
j2_template = j2_environ.get_template(f_in)
rendered = j2_template.render(**self.kwargs)
if os.path.lexists(f_out):
if os.path.islink(f_out) or (not os.path.isfile(f_out)):
return render_error(
f'output file is not safely writable: {f_out}')
if os.path.exists(f_out):
if os.path.samefile(f_in, f_out):
return render_error(
f'unable to process template inplace: {file_in}')
with open(f_out, mode='w', encoding='utf-8') as f:
f.write(rendered)
return True

387
j2cfg/j2cfg/functions.py Normal file
View File

@@ -0,0 +1,387 @@
import collections.abc
import itertools
import os.path
import pathlib
import re
import sys
import jinja2
from .settings import is_env_banned
def is_sequence(x) -> bool:
if isinstance(x, str):
return False
return isinstance(x, collections.abc.Sequence)
def is_mapping(x) -> bool:
return isinstance(x, collections.abc.Mapping)
def uniq(a: list | set) -> list:
return sorted(set(a))
def remove_non_str(a: list | set) -> list:
return list(filter(lambda x: isinstance(x, str), a))
def remove_empty_str(a: list | set) -> list:
return list(filter(None, a))
def uniq_str_list(a: list | set) -> list:
return remove_empty_str(uniq(a))
def str_split_to_list(s: str, sep=r'\s+') -> list:
return remove_empty_str(re.split(sep, s))
def dict_to_env_str_list(x: dict) -> list:
r = []
for k in sorted(x.keys()):
if x[k] is None:
r.append(f'{k}')
else:
r.append(f'{k}={str(x[k])}')
return r
def any_to_str_list(x) -> list:
if x is None:
return []
if isinstance(x, str):
return [x]
if is_sequence(x):
return [str(e) for e in x]
if is_mapping(x):
return dict_to_env_str_list(x)
return [str(x)]
def is_re_match(x, pattern, flags=0) -> bool:
if isinstance(x, str):
return bool(re.match(pattern, x, flags))
if is_sequence(x):
return any(is_re_match(v, pattern, flags) for v in x)
if is_mapping(x):
return any(is_re_match(v, pattern, flags) for v in x.keys())
return False
def is_re_fullmatch(x, pattern, flags=0) -> bool:
if isinstance(x, str):
return bool(re.fullmatch(pattern, x, flags))
if is_sequence(x):
return any(is_re_fullmatch(v, pattern, flags) for v in x)
if is_mapping(x):
return any(is_re_fullmatch(v, pattern, flags) for v in x.keys())
return False
def re_match(x, pattern, flags=0):
if isinstance(x, str):
return re.match(pattern, x, flags)
if is_sequence(x):
return [v for v in x
if re_match(v, pattern, flags)]
if is_mapping(x):
return {k: v for k, v in x.items()
if re_match(k, pattern, flags)}
return None
def re_fullmatch(x, pattern, flags=0):
if isinstance(x, str):
return re.fullmatch(pattern, x, flags)
if is_sequence(x):
return [v for v in x
if re_fullmatch(v, pattern, flags)]
if is_mapping(x):
return {k: v for k, v in x.items()
if re_fullmatch(k, pattern, flags)}
return None
def re_match_negate(x, pattern, flags=0):
if isinstance(x, str):
return not bool(re.match(pattern, x, flags))
if is_sequence(x):
return [v for v in x
if re_match_negate(v, pattern, flags)]
if is_mapping(x):
return {k: v for k, v in x.items()
if re_match_negate(k, pattern, flags)}
return x
def re_fullmatch_negate(x, pattern, flags=0):
if isinstance(x, str):
return not bool(re.fullmatch(pattern, x, flags))
if is_sequence(x):
return [v for v in x
if re_fullmatch_negate(v, pattern, flags)]
if is_mapping(x):
return {k: v for k, v in x.items()
if re_fullmatch_negate(k, pattern, flags)}
return x
def dict_remap_keys(x: dict, key_map) -> dict:
if key_map is None:
return x
p = set(x.keys())
m = {}
for k in x:
v = key_map(k)
if v == k:
continue
m[k] = v
p.discard(k)
p.discard(v)
return {k: x[k] for k in p} | {v: x[k] for k, v in m.items()}
def re_sub(x, pattern, repl, count=0, flags=0):
if isinstance(x, str):
return re.sub(pattern, repl, x, count, flags)
if is_sequence(x):
return [
re_sub(v, pattern, repl, count, flags)
for v in x
]
if is_mapping(x):
return dict_remap_keys(
x, lambda k:
re_sub(k, pattern, repl, count, flags)
)
return x
def as_cgi_hdr(x):
if isinstance(x, str):
return 'HTTP_' + re.sub('[^A-Z0-9]+', '_', x.upper()).strip('_')
if is_sequence(x):
return uniq([
as_cgi_hdr(v)
for v in x
])
if is_mapping(x):
return dict_remap_keys(
x, as_cgi_hdr
)
return x
def as_ngx_var(x, pfx='custom'):
if isinstance(x, str):
parts = remove_empty_str(
[re.sub('[^a-z0-9]+', '_', str(i).lower()).strip('_')
for i in (pfx, x)]
)
if len(parts) < 2:
print(
f'as_ngx_var: parts={parts}',
file=sys.stderr
)
raise ValueError('as_ngx_var: incomplete string array')
return '$' + '_'.join(parts)
if is_sequence(x):
return uniq([
as_ngx_var(v, pfx)
for v in x
])
if is_mapping(x):
return dict_remap_keys(
x, lambda k:
as_ngx_var(k, pfx)
)
return x
def any_to_env_dict(x) -> dict:
if x is None:
return {}
h = {}
def feed(k, parse=False, v=None):
if v is None:
return
k = str(k)
if parse:
k2, m, v2 = k.partition('=')
if m == '=':
k = k2
v = v2
if not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_]*', k):
return
if is_env_banned(k):
return
if k in h:
return
h[k] = v if v is None else str(v)
if isinstance(x, str):
feed(x, True)
elif is_sequence(x):
for e in x:
feed(e, True)
elif is_mapping(x):
for k in x:
feed(k, False, x[k])
else:
return {}
return h
def dict_keys(x: dict) -> list:
return sorted([k for k in x.keys()])
def dict_empty_keys(x: dict) -> list:
return sorted([k for k in x.keys() if x[k] is None])
def dict_non_empty_keys(x: dict) -> list:
return sorted([k for k in x.keys() if x[k] is not None])
def list_diff(a: list | set, b: list | set) -> list:
return list(set(a) - set(b))
def list_intersect(a: list | set, b: list | set) -> list:
return list(set(a) & set(b))
@jinja2.pass_environment
def sh_like_file_to_list(j2env, file_in: str) -> list:
tpl = j2env.get_template(file_in)
text = pathlib.Path(tpl.filename).read_text(encoding='utf-8')
lines = re.split(r'[\r\n]', text)
return list(itertools.filterfalse(
lambda x: re.match(r'\s*#', x), lines
))
def ngx_esc(x):
if isinstance(x, str):
if x == "":
return "''"
if re.search(r'(?:\s|[;{}()\[\]\\\'"*?])', x):
return repr(x)
return x
if is_sequence(x):
return uniq([
ngx_esc(v)
for v in x
])
if is_mapping(x):
return dict_remap_keys(
x, ngx_esc
)
if x is None:
return None
return ngx_esc(str(x))
def from_gobool(x) -> bool:
if isinstance(x, str):
return x.lower() in {'1', 't', 'true'}
return bool(x)
def merge_dict_recurse(d1, d2: dict) -> dict:
x = {} | d1
keys1 = set(x.keys())
keys2 = set(d2.keys())
common = keys1 & keys2
missing = keys2 - common
map1 = {k for k in common if is_mapping(x.get(k))}
seq1 = {k for k in common if is_sequence(x.get(k))}
misc1 = common - seq1 - map1
merge_safe = missing | misc1
x.update({k: d2.get(k) for k in merge_safe})
map_common = {k for k in map1 if is_mapping(d2.get(k))}
for k in map_common:
y = d2.get(k)
if not y:
x[k] = {}
continue
x[k] = merge_dict_recurse(x.get(k), y)
seq_common = {k for k in seq1 if is_sequence(d2.get(k))}
for k in seq_common:
y = d2.get(k)
if not y:
x[k] = []
continue
x[k] = uniq(list(x.get(k)) + list(y))
unmerged = (map1 - map_common) | (seq1 - seq_common)
for k in unmerged:
t1 = type(x.get(k))
t2 = type(d2.get(k))
print(
f'merge_dict_recurse(): skipping key {k}'
+ f' due to type mismatch: {t1} vs. {t2}',
file=sys.stderr)
return x
def join_prefix(prefix: str, *paths) -> str:
pfx = prefix or '/'
pfx = '/' + pfx.strip('/')
rv = os.path.normpath(os.path.join(pfx, *paths).rstrip('/')).rstrip('/')
if rv == pfx:
raise ValueError('join_prefix: empty path after prefix')
common = os.path.commonpath([pfx, rv])
if common == pfx:
return rv
# slowpath
rv = rv.removeprefix(common).lstrip('/')
rv = os.path.join(pfx, rv)
return rv
J2CFG_FILTERS = [
any_to_env_dict,
any_to_str_list,
as_cgi_hdr,
as_ngx_var,
dict_empty_keys,
dict_keys,
dict_non_empty_keys,
dict_remap_keys,
dict_to_env_str_list,
from_gobool,
is_mapping,
is_re_fullmatch,
is_re_match,
is_sequence,
join_prefix,
list_diff,
list_intersect,
ngx_esc,
re_fullmatch,
re_fullmatch_negate,
re_match,
re_match_negate,
re_sub,
remove_empty_str,
remove_non_str,
sh_like_file_to_list,
str_split_to_list,
uniq,
uniq_str_list,
]

80
j2cfg/j2cfg/settings.py Normal file
View File

@@ -0,0 +1,80 @@
import re
J2CFG_TEMPLATE_EXT = '.j2'
J2CFG_DEFAULTS_FILE = '/run/ngx/conf/j2cfg.yml'
J2CFG_PATH = [
'/run/ngx/conf/j2cfg',
]
J2CFG_PYTHON_MODULES = [
'itertools',
'json',
'os',
'os.path',
'pathlib',
're',
'sys',
# installed through pip
'psutil',
'wcmatch',
]
J2CFG_JINJA_EXTENSIONS = [
'jinja2.ext.do',
'jinja2.ext.loopcontrols',
]
J2CFG_PRESERVE_ENVS = [
# generic
'PATH',
'LD_LIBRARY_PATH',
'LD_PRELOAD',
# glibc
'GLIBC_TUNABLES',
'MALLOC_ARENA_MAX',
# jemalloc
'MALLOC_CONF',
]
J2CFG_PASSTHROUGH_ENVS = [
# openssl (man 7 openssl-env)
'SSL_CERT_DIR',
'SSL_CERT_FILE',
'OPENSSL_CONF',
'OPENSSL_CONF_INCLUDE',
'OPENSSL_CONFIG',
'OPENSSL_ENGINES',
'OPENSSL_MODULES',
'RANDFILE',
'CTLOG_FILE',
'QLOGDIR',
'OSSL_QFILTER',
# openssl: processor capabilities
'OPENSSL_armcap',
'OPENSSL_ia32cap',
'OPENSSL_ppccap',
'OPENSSL_riscvcap',
'OPENSSL_s390xcap',
'OPENSSL_sparcv9cap',
# generic proxy settings
'NO_PROXY',
'HTTPS_PROXY',
'HTTP_PROXY',
]
J2CFG_BANNED_ENVS = [
r'ANGIE(|_BPF_MAPS)(=|$)',
r'__IEP_', r'IEP_',
r'NGX_STATIC_',
r'ENVSUBST_',
r'J2CFG_',
]
def is_env_banned(k: str) -> bool:
for r in J2CFG_BANNED_ENVS:
if re.match(r, k):
return True
return False

173
j2cfg/j2cfg/test.j2 Normal file
View File

@@ -0,0 +1,173 @@
j2cfg:
{{ j2cfg }}
{% set x = [1,2,3,4] %}
x = {{ x }}
is_sequence:
{{ x | is_sequence }}
{% set x = {1:2,3:4} %}
x = {{ x }}
is_sequence:
{{ x | is_sequence }}
{% set x = [1,2,3,4] %}
x = {{ x }}
is_mapping:
{{ x | is_mapping }}
{% set x = {1:2,3:4} %}
x = {{ x }}
is_mapping:
{{ x | is_mapping }}
{% set x = [2,3,1,2] %}
x = {{ x }}
uniq:
{{ x | uniq }}
{% set x = ['2',3,'1','2'] %}
x = {{ x }}
remove_non_str:
{{ x | remove_non_str }}
{% set x = ['2','','1','2'] %}
x = {{ x }}
remove_empty_str:
{{ x | remove_empty_str }}
{% set x = ['2','3','1','2'] %}
x = {{ x }}
uniq_str_list:
{{ x | uniq_str_list }}
{% set x = '2 3 1 2 ' %}
x = {{ x.__repr__() }}
str_split_to_list:
{{ x | str_split_to_list }}
{% set x = '2:3::1:2:' %}
x = {{ x.__repr__() }}
str_split_to_list(':'):
{{ x | str_split_to_list(':') }}
{% set x = { 'VAR1': 'Etc/UTC', 'VAR2': '', 'VAR3': None, '4VAR4': 'yeah', 'VAR5=not': 'yeah', 'VAR5=real yeah': None, 'VAR6': {'pi': 3.1415926}, 'VAR7': ['pi', 3.1415926] } %}
x = {{ x }}
dict_to_env_str_list:
{{ x | dict_to_env_str_list }}
{% set x = '1 2 3 4' %}
x = {{ x.__repr__() }}
any_to_str_list:
{{ x | any_to_str_list }}
{% set x = [1,2,3,4] %}
x = {{ x }}
any_to_str_list:
{{ x | any_to_str_list }}
{% set x = 3.1415926 %}
x = {{ x }}
any_to_str_list:
{{ x | any_to_str_list }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
is_re_match('[ab]'):
{{ x | is_re_match('[ab]') }}
is_re_match('[mn]'):
{{ x | is_re_match('[mn]') }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
is_re_fullmatch('[ab]'):
{{ x | is_re_fullmatch('[ab]') }}
is_re_fullmatch('[ab][12]'):
{{ x | is_re_fullmatch('[ab][12]') }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
re_match('[ab]'):
{{ x | re_match('[ab]') }}
re_match('[mn]'):
{{ x | re_match('[mn]') }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
re_fullmatch('[ab]'):
{{ x | re_fullmatch('[ab]') }}
re_fullmatch('[ab][12]'):
{{ x | re_fullmatch('[ab][12]') }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
re_match_negate('[ab]'):
{{ x | re_match_negate('[ab]') }}
re_match_negate('[mn]'):
{{ x | re_match_negate('[mn]') }}
{% set x = ['a2','b3','c1','d2'] %}
x = {{ x }}
re_fullmatch_negate('[ab]'):
{{ x | re_fullmatch_negate('[ab]') }}
re_fullmatch_negate('[ab][12]'):
{{ x | re_fullmatch_negate('[ab][12]') }}
{% set x = ['a2b','b3b','c1f','d2g'] %}
x = {{ x }}
re_sub('[ab]', '_'):
{{ x | re_sub('[ab]', '_') }}
re_sub('[mn]', '_'):
{{ x | re_sub('[mn]', '_') }}
{% set x = 'settings.py' %}
x = {{ x.__repr__() }}
sh_like_file_to_list:
{{ 'settings.py' | sh_like_file_to_list }}
{% set x = 'Accept-Encoding' %}
x = {{ x.__repr__() }}
as_cgi_hdr:
{{ x | as_cgi_hdr }}
{% set x = '_Permissions-Policy--' %}
x = {{ x.__repr__() }}
as_cgi_hdr:
{{ x | as_cgi_hdr }}
{% set x = 'VAR1=Etc/UTC' %}
x = {{ x.__repr__() }}
any_to_env_dict:
{{ x | any_to_env_dict }}
{% set x = ['VAR1=Etc/UTC', 'VAR2=', 'VAR3', '4VAR4=yeah', 'VAR5=yeah', 'VAR5=not-yeah'] %}
x = {{ x }}
any_to_env_dict:
{{ x | any_to_env_dict }}
{% set x = { 'VAR1': 'Etc/UTC', 'VAR2': '', 'VAR3': None, '4VAR4': 'yeah', 'VAR5=not': 'yeah', 'VAR5=real yeah': None, 'VAR6': {'pi': 3.1415926}, 'VAR7': ['pi', 3.1415926] } %}
x = {{ x }}
any_to_env_dict:
{{ x | any_to_env_dict }}
{% set x = { 'VAR1': 'Etc/UTC', 'VAR2': '', 'VAR3': None, '4VAR4': 'yeah', 'VAR5=not': 'yeah', 'VAR5=real yeah': None, 'VAR6': {'pi': 3.1415926}, 'VAR7': ['pi', 3.1415926] } %}
x = {{ x }}
dict_keys:
{{ x | dict_keys }}
dict_empty_keys:
{{ x | dict_empty_keys }}
dict_non_empty_keys:
{{ x | dict_non_empty_keys }}
{% set x = [1,2,3,4] %}
{% set y = [3,4,5,6] %}
x = {{ x }}
y = {{ y }}
list_diff(x, y):
{{ x | list_diff(y) }}
list_diff(y, x):
{{ y | list_diff(x) }}
list_intersect(x, y):
{{ x | list_intersect(y) }}
list_intersect(y, x):
{{ y | list_intersect(x) }}