Skip to content

Commit 89df434

Browse files
authored
Merge pull request #26 from con2/index
Add Index and Group tags
2 parents 22dbecb + 59e5075 commit 89df434

File tree

9 files changed

+249
-34
lines changed

9 files changed

+249
-34
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,21 +78,23 @@ Python 3.5+ required. Python 2 is not and will not be supported.
7878
| `!Exists` | JSONPath expression | `!Exists foo` | Returns `true` if the JSONPath expression returns one or more matches, `false` otherwise. |
7979
| `!Filter` | `test`, `over` | See `tests/test_cond.py` | Takes in a list and only returns elements that pass a predicate. |
8080
| `!Format` | Format string | `!Format "{foo} {bar!d}"` | Interpolate strings using [Python format strings](https://docs.python.org/3/library/string.html#formatstrings). <br> JSONPath supported in variable lookup (eg. `{people[0].first_name}` will do the right thing). <br> **NOTE:** When the format string starts with `{`, you need to quote it in order to avoid being interpreted as a YAML object. |
81+
| `!Group` | Accepts the same arguments as `!Loop`, except `template` is optional (default identity), plus the following: <br> `by`: (required) An expression used to determine the key for the current value <br> `result_as`: (optional, string) When evaluating `by`, the enriched `template` is available under this name. | TBD | Makes a dict out of a list. Keys are determined by `by`. Items with the same key are grouped in a list. |
8182
| `!If` | `test`, `then`, `else` | See `tests/test_cond.py` | Returns one of two values based on a condition. |
8283
| `!Include` | Path to a template to include | `!Include ../foo.yml` | Renders the requested template at this location. Both absolute and relative paths work. |
8384
| `!IncludeBase64` | Path to a binary file | `!IncludeBase64 ../foo.pdf` | Loads the given binary file and returns the contents encoded as Base64. |
8485
| `!IncludeBinary` | Path to a binary file | `!IncludeBinary ../foo.pdf` | Loads the given binary file and returns the contents as bytes. This is practically only useful for hashing. |
8586
| `!IncludeText` | Path to an UTF-8 text file | `!IncludeText ../foo.toml` | Loads the given UTF-8 text file and returns the contents as a string. |
87+
| `!Index` | Accepts the same arguments as `!Loop`, except `template` is optional (default identity), plus the following: <br> `by`: (required) An expression used to determine the key for the current value <br> `result_as`: (optional, string) When evaluating `by`, the enriched `template` is available under this name. <br> `duplicates`: (optional, default `error`) `error`, `warn(ing)` or `ignore` duplicate values. | TBD | Makes a dict out of a list. Keys are determined by `by`. |
8688
| `!Join` | `items`: (required) A list of items to be joined together. <br> `separator`: (optional, default space) The separator to place between the items. <br> **OR** <br> a list of items to be joined together with a space as the separator. | `!Join [foo, bar]` <br> `!Join { items: [foo, bar], separator: ', ' }` | Joins a list of items together with a separator. The result is always a string. |
8789
| `!Lookup` | JSONPath expression | `!Lookup people[0].first_name` | Performs a JSONPath lookup returning the first match. If there is no match, an error is raised. |
8890
| `!LookupAll` | JSONPath expression | `!LookupAll people[*].first_name` | Performs a JSONPath lookup returning all matches as a list. If no matches are found, the empty list `[]` is returned. |
89-
| `!Loop` | `over`: (required) The data to iterate over (a literal list or dict, or !Var) <br> `as`: (optional, default `item`) The variable name given to the current value <br> `index_as`: (optional) The variable name given to the loop index. If over is a list, this is a numeric index starting from `0`. If over is a dict, this is the dict key. <br> `index_start`: (optional, default `0`) First index, for eg. 1-based indexing. <br> `template`: (required) The template to process for each iteration of the loop. | See `examples/loop/`. | Loops over a list or dict and renders a template for each iteration. The output is always a list. |
90-
| `!MD5` | Data to hash | `!MD5 'Törkylempijävongahdus' | Hashes the given data using the MD5 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
91+
| `!Loop` | `over`: (required) The data to iterate over (a literal list or dict, or !Var) <br> `as`: (optional, default `item`) The variable name given to the current value <br> `index_as`: (optional) The variable name given to the loop index. If over is a list, this is a numeric index starting from `0`. If over is a dict, this is the dict key. <br> `index_start`: (optional, default `0`) First index, for eg. 1-based indexing. <br> `previous_as`: (optional) The variable name given to the previous value. On the first iteration of the loop, the previous value is `null`. _Added in 0.2.0_ <br> `template`: (required) The template to process for each iteration of the loop. | See `examples/loop/`. | Loops over a list or dict and renders a template for each iteration. The output is always a list. |
92+
| `!MD5` | Data to hash | `!MD5 'some data to hash'` | Hashes the given data using the MD5 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
9193
| `!Merge` | A list of dicts | `!Merge [{a: 5}, {b: 6}]` | Merges objects. For overlapping keys the last one takes precedence. |
9294
| `!Not` | a value | `!Not !Var foo` | Logically negates the given value (in Python semantics). |
9395
| `!Op` | `a`, `op`, `b` | See `tests/test_cond.py` | Performs binary operators. Especially useful with `!If` to implement greater-than etc. |
94-
| `!SHA1` | Data to hash | `!SHA1 'Törkylempijävongahdus' | Hashes the given data using the SHA1 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
95-
| `!SHA256` | Data to hash | `!SHA256 'Törkylempijävongahdus' | Hashes the given data using the SHA256 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
96+
| `!SHA1` | Data to hash | `!SHA1 'some data to hash'` | Hashes the given data using the SHA1 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
97+
| `!SHA256` | Data to hash | `!SHA256 'some data to hash'` | Hashes the given data using the SHA256 algorithm. If the data is not binary, it is converted to UTF-8 bytes. |
9698
| `!URLEncode` | A string to encode <br> **OR** <br> `url`: The URL to combine query parameters into <br> `query`: An object of query string parameters to add. | `!URLEncode "foo+bar"` <br> `!URLEncode { url: "https://example.com/", query: { foo: bar } }` | Encodes strings for safe inclusion in a URL, or combines query string parameters into a URL. |
9799
| `!Var` | Variable name | `!Var image_name` | Replaced with the value of the variable. |
98100
| `!Void` | Anything or nothing | `foo: !Void` | The dict key, list item or YAML document that resolves to `!Void` is removed from the output. |

emrichen/tags/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .hash import MD5, SHA1, SHA256
1515
from .if_ import If
1616
from .include import Include, IncludeBase64, IncludeBinary, IncludeText
17+
from .index import Group, Index
1718
from .join import Join
1819
from .lookup import Lookup, LookupAll
1920
from .loop import Loop
@@ -37,11 +38,13 @@
3738
'Exists',
3839
'Filter',
3940
'Format',
41+
'Group',
4042
'If',
4143
'Include',
4244
'IncludeBase64',
4345
'IncludeBinary',
4446
'IncludeText',
47+
'Index',
4548
'Join',
4649
'Lookup',
4750
'LookupAll',

emrichen/tags/index.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
from collections import OrderedDict
2+
from sys import stderr
3+
4+
from .loop import Loop
5+
from .var import Var
6+
7+
8+
class _BaseIndex(Loop):
9+
value_types = (dict,)
10+
output_factory = OrderedDict
11+
12+
def __init__(self, data):
13+
if 'template' not in data:
14+
as_ = data.get('as', 'item')
15+
data = dict(data, template=Var(as_))
16+
17+
super(_BaseIndex, self).__init__(data)
18+
19+
20+
class Index(_BaseIndex):
21+
"""
22+
arguments: |
23+
Accepts the same arguments as `!Loop`, except `template` is optional (default identity), plus the following:
24+
`by`: (required) An expression used to determine the key for the current value
25+
`result_as`: (optional, string) When evaluating `by`, the enriched `template` is available under this name.
26+
`duplicates`: (optional, default `error`) `error`, `warn(ing)` or `ignore` duplicate values.
27+
28+
example: TBD
29+
description: Makes a dict out of a list. Keys are determined by `by`.
30+
"""
31+
def __init__(self, data):
32+
if 'template' not in data:
33+
as_ = data.get('as', 'item')
34+
data = dict(data, template=Var(as_))
35+
36+
super(Index, self).__init__(data)
37+
38+
def process_item(self, context, output, value, result):
39+
from ..context import Context
40+
41+
by = self.data['by']
42+
result_as = self.data.get('result_as')
43+
44+
if result_as:
45+
context = Context(context, {result_as: result})
46+
47+
key = context.enrich(by)
48+
49+
if key in output:
50+
# Duplicate key
51+
action = self.data.get('duplicates', 'error')
52+
message = '{self}: Duplicate key {key!r}'.format(self=self, key=key)
53+
if action == 'warn':
54+
# TODO logger
55+
stderr.write(message + '\n')
56+
elif action == 'error':
57+
raise ValueError(message)
58+
59+
output[key] = result
60+
61+
62+
class Group(_BaseIndex):
63+
"""
64+
arguments: |
65+
Accepts the same arguments as `!Loop`, except `template` is optional (default identity), plus the following:
66+
`by`: (required) An expression used to determine the key for the current value
67+
`result_as`: (optional, string) When evaluating `by`, the enriched `template` is available under this name.
68+
69+
example: TBD
70+
description: Makes a dict out of a list. Keys are determined by `by`. Items with the same key are grouped in a list.
71+
"""
72+
def process_item(self, context, output, value, result):
73+
from ..context import Context
74+
75+
by = self.data['by']
76+
result_as = self.data.get('result_as')
77+
78+
if result_as:
79+
context = Context(context, {result_as: result})
80+
81+
key = context.enrich(by)
82+
group = output.setdefault(key, [])
83+
group.append(result)

emrichen/tags/loop.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,13 @@ class Loop(BaseTag):
4040
`as`: (optional, default `item`) The variable name given to the current value
4141
`index_as`: (optional) The variable name given to the loop index. If over is a list, this is a numeric index starting from `0`. If over is a dict, this is the dict key.
4242
`index_start`: (optional, default `0`) First index, for eg. 1-based indexing.
43+
`previous_as`: (optional) The variable name given to the previous value. On the first iteration of the loop, the previous value is `null`. _Added in 0.2.0_
4344
`template`: (required) The template to process for each iteration of the loop.
4445
example: See `examples/loop/`.
4546
description: Loops over a list or dict and renders a template for each iteration. The output is always a list.
46-
4747
"""
4848
value_types = (dict,)
49+
output_factory = list
4950

5051
def enrich(self, context):
5152
from ..context import Context
@@ -54,22 +55,37 @@ def enrich(self, context):
5455
index_as = str(self.data.get('index_as') or '')
5556
compact = bool(self.data.get('compact'))
5657
index_start = self.data.get('index_start')
58+
previous_as = str(self.data.get('previous_as') or '')
5759

5860
template = self.data.get('template')
5961
if template is None:
6062
raise ValueError('{self}: missing template'.format(self=self))
6163

62-
output = []
64+
output = self.output_factory()
6365
iterable, _ = self.get_iterable(context, index_start)
66+
previous_value = None
6467
for index, value in iterable:
6568
subcontext = {as_: value}
6669
if index_as:
6770
subcontext[index_as] = index
68-
value = Context(context, subcontext).enrich(template)
69-
if compact and not value:
71+
if previous_as:
72+
subcontext[previous_as] = previous_value
73+
subcontext = Context(context, subcontext)
74+
75+
result = subcontext.enrich(template)
76+
previous_value = value
77+
78+
if compact and not result:
7079
continue
71-
output.append(value)
80+
81+
self.process_item(subcontext, output, value, result)
7282
return output
7383

7484
def get_iterable(self, context, index_start):
7585
return get_iterable(self, self.data.get('over'), context, index_start)
86+
87+
def process_item(self, context, output, value, result):
88+
'''
89+
Used by Loop subclasses to do things every iteration.
90+
'''
91+
output.append(result)

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
setup(
1111
name='emrichen',
12-
version='0.1.1',
12+
version='0.2.0',
1313
description='Template engine for YAML & JSON',
1414
long_description=long_description,
1515
long_description_content_type='text/markdown',

tests/test_group.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from emrichen import Template
2+
3+
4+
def test_group():
5+
assert Template.parse('''
6+
!Group
7+
over:
8+
- name: manifold
9+
score: 7.8
10+
- name: John
11+
score: 9.9
12+
- name: John
13+
score: 9.8
14+
as: flavour
15+
by: !Lookup flavour.name
16+
template: !Lookup flavour.score
17+
''').enrich({}) == [{'manifold': [7.8], 'John': [9.9, 9.8]}]

tests/test_index.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import pytest
2+
3+
from emrichen import Template
4+
5+
6+
def test_index():
7+
assert Template.parse('''
8+
!Index
9+
over:
10+
- name: manifold
11+
score: 7.8
12+
- name: John
13+
score: 9.9
14+
- name: John
15+
score: 9.8
16+
as: flavour
17+
by: !Lookup flavour.name
18+
duplicates: ignore
19+
template: !Lookup flavour.score
20+
''').enrich({}) == [{'manifold': 7.8, 'John': 9.8}]
21+
22+
23+
def test_index_without_template():
24+
assert Template.parse('''
25+
!Index
26+
over:
27+
- name: manifold
28+
score: 7.8
29+
- name: John
30+
score: 9.9
31+
- name: John
32+
score: 9.8
33+
as: flavour
34+
by: !Lookup flavour.name
35+
duplicates: ignore
36+
''').enrich({}) == [{'manifold': {'name': 'manifold', 'score': 7.8}, 'John': {'name': 'John', 'score': 9.8}}]
37+
38+
39+
def test_index_result_as():
40+
assert Template.parse('''
41+
!Index
42+
over:
43+
- name: manifold
44+
score: 7.8
45+
- name: John
46+
score: 9.9
47+
- name: John
48+
score: 9.8
49+
as: flavour
50+
template:
51+
NAME: !Lookup flavour.name
52+
SCORE: !Lookup flavour.score
53+
result_as: result
54+
by: !Lookup result.NAME
55+
duplicates: ignore
56+
''').enrich({}) == [{'manifold': {'NAME': 'manifold', 'SCORE': 7.8}, 'John': {'NAME': 'John', 'SCORE': 9.8}}]
57+
58+
59+
def test_index_duplicates_error():
60+
with pytest.raises(ValueError):
61+
assert Template.parse('''
62+
!Index
63+
over:
64+
- name: manifold
65+
score: 7.8
66+
- name: John
67+
score: 9.9
68+
- name: John
69+
score: 9.8
70+
as: flavour
71+
by: !Lookup flavour.name
72+
duplicates: error
73+
template: !Lookup flavour.score
74+
''').enrich({})

tests/test_loop.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,13 @@ def test_no_index_start_with_dict():
9090
template.render({})
9191

9292
assert 'index_start with dict' in str(nope.value)
93+
94+
95+
def test_previous_as():
96+
assert Template.parse('''
97+
!Loop
98+
over: [1, 2, 3]
99+
as: item
100+
previous_as: previous_item
101+
template: [!Var previous_item, !Var item]
102+
''').enrich({}) == [[[None, 1], [1, 2], [2,3]]]

update_tags_init.py

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,11 @@
88
from collections import defaultdict
99

1010

11-
class ClassDefWalker(ast.NodeVisitor):
11+
class Universe:
1212
def __init__(self):
13+
self.cls_modules = {}
1314
self.cls_bases = defaultdict(set)
1415

15-
def _stringify_id_node(self, node):
16-
if isinstance(node, ast.Name):
17-
return node.id
18-
elif isinstance(node, ast.Attribute):
19-
return f'{self._stringify_id_node(node.value)}.{self._stringify_id_node(node.attr)}'
20-
elif isinstance(node, str):
21-
return node
22-
else:
23-
warnings.warn(f'Dont know how to stringify {node}')
24-
return '<none>'
25-
26-
def visit_ClassDef(self, classdef):
27-
for base in classdef.bases:
28-
base_name = self._stringify_id_node(base)
29-
self.cls_bases[classdef.name].add(base_name)
30-
3116
def get_all_bases(self, name):
3217
bases = set()
3318
open = list(self.cls_bases[name])
@@ -38,28 +23,53 @@ def get_all_bases(self, name):
3823
bases.add(name)
3924
open.extend(list(self.cls_bases[name]))
4025

41-
def get_classes_deriving_from(self, base_name):
26+
def get_classes_deriving_from(self, base_names):
4227
for name in list(self.cls_bases.keys()):
4328
for base in self.get_all_bases(name):
44-
if base == base_name:
29+
if base in base_names:
4530
yield name
4631
break
4732

4833

34+
class ClassDefWalker(ast.NodeVisitor):
35+
def __init__(self, module, universe):
36+
self.module = module
37+
self.universe = universe
38+
39+
def _stringify_id_node(self, node):
40+
if isinstance(node, ast.Name):
41+
return node.id
42+
elif isinstance(node, ast.Attribute):
43+
return f'{self._stringify_id_node(node.value)}.{self._stringify_id_node(node.attr)}'
44+
elif isinstance(node, str):
45+
return node
46+
else:
47+
warnings.warn(f'Dont know how to stringify {node}')
48+
return '<none>'
49+
50+
def visit_ClassDef(self, classdef):
51+
for base in classdef.bases:
52+
base_name = self._stringify_id_node(base)
53+
self.universe.cls_bases[classdef.name].add(base_name)
54+
self.universe.cls_modules[classdef.name] = self.module
55+
56+
4957
def find_tags_in_modules():
5058
tags_per_module = defaultdict(set)
51-
59+
universe = Universe()
5260
for filepath in glob.glob('emrichen/tags/*.py'):
5361
modname = os.path.splitext(os.path.basename(filepath))[0]
5462
if modname.startswith('_'):
5563
continue
5664
with open(filepath) as infp:
5765
tree = ast.parse(infp.read(), filepath)
58-
cdw = ClassDefWalker()
66+
cdw = ClassDefWalker(module=modname, universe=universe)
5967
cdw.visit(tree)
60-
for tag in cdw.get_classes_deriving_from('BaseTag'):
61-
if not tag.startswith('_'):
62-
tags_per_module[modname].add(tag)
68+
69+
for tag in universe.get_classes_deriving_from(('BaseTag',)):
70+
if not tag.startswith('_'):
71+
tags_per_module[universe.cls_modules[tag]].add(tag)
72+
6373
return tags_per_module
6474

6575

0 commit comments

Comments
 (0)