mapres 3.dev14__tar.gz → 3.dev16__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mapres-3.dev14/src/mapres.egg-info → mapres-3.dev16}/PKG-INFO +1 -1
- {mapres-3.dev14 → mapres-3.dev16}/pyproject.toml +1 -1
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/datamap.py +44 -14
- mapres-3.dev16/src/mapres/evaluator.py +221 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/exceptions.py +3 -1
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/resolver.py +19 -2
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/tokenizer.py +69 -23
- {mapres-3.dev14 → mapres-3.dev16/src/mapres.egg-info}/PKG-INFO +1 -1
- mapres-3.dev14/src/mapres/evaluator.py +0 -119
- {mapres-3.dev14 → mapres-3.dev16}/LICENSE +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/README.md +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/setup.cfg +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/__init__.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/ast.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/cache.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/layers.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/__init__.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/color.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/maps/time.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres/parser.py +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/SOURCES.txt +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/dependency_links.txt +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/requires.txt +0 -0
- {mapres-3.dev14 → mapres-3.dev16}/src/mapres.egg-info/top_level.txt +0 -0
|
@@ -35,7 +35,10 @@ class DataMap:
|
|
|
35
35
|
- "callable": map exposes callable methods for nested calls
|
|
36
36
|
'''
|
|
37
37
|
__syntax__: str = None
|
|
38
|
-
__mode__: str | None = None
|
|
38
|
+
__mode__: str | None = None # "dynamic", "config", "callable", or None
|
|
39
|
+
__recursive__: bool = False
|
|
40
|
+
__missing_key__: str = 'error' # 'error', 'silent', 'placeholder'
|
|
41
|
+
__ignore_delimiters__: bool = False
|
|
39
42
|
|
|
40
43
|
def as_map(self):
|
|
41
44
|
'''
|
|
@@ -47,8 +50,9 @@ class DataMap:
|
|
|
47
50
|
'''
|
|
48
51
|
result = {}
|
|
49
52
|
|
|
53
|
+
# dataclass fields
|
|
50
54
|
for f in fields(self):
|
|
51
|
-
if not f.init or f.name
|
|
55
|
+
if not f.init or f.name.startswith('__'):
|
|
52
56
|
continue
|
|
53
57
|
|
|
54
58
|
val = getattr(self, f.name)
|
|
@@ -63,24 +67,58 @@ class DataMap:
|
|
|
63
67
|
|
|
64
68
|
result[f.name] = val
|
|
65
69
|
|
|
70
|
+
# nested classes > nested dicts
|
|
71
|
+
for name, obj in self.__class__.__dict__.items():
|
|
72
|
+
if name.startswith('__'):
|
|
73
|
+
continue
|
|
74
|
+
if isinstance(obj, type):
|
|
75
|
+
result[name] = self._class_to_dict(obj)
|
|
76
|
+
|
|
66
77
|
return result
|
|
67
78
|
|
|
79
|
+
def _class_to_dict(self, cls):
|
|
80
|
+
out = {}
|
|
81
|
+
|
|
82
|
+
if is_dataclass(cls):
|
|
83
|
+
dummy = cls()
|
|
84
|
+
for f in fields(dummy):
|
|
85
|
+
if not f.init:
|
|
86
|
+
continue
|
|
87
|
+
out[f.name] = getattr(dummy, f.name)
|
|
88
|
+
|
|
89
|
+
for name, obj in cls.__dict__.items():
|
|
90
|
+
if name.startswith('__'):
|
|
91
|
+
continue
|
|
92
|
+
if isinstance(obj, type):
|
|
93
|
+
out[name] = self._class_to_dict(obj)
|
|
94
|
+
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def datamap(_cls=None, *, syntax=None, mode=None, recursive=None,
|
|
99
|
+
missing_key=None, ignore_delimiters=None):
|
|
68
100
|
|
|
69
|
-
# main decorator factory
|
|
70
|
-
def datamap(_cls=None, *, syntax=None, mode=None):
|
|
71
|
-
'''@datamap decorator'''
|
|
72
101
|
def wrap(cls):
|
|
73
102
|
cls.__syntax__ = syntax
|
|
74
103
|
cls.__mode__ = mode
|
|
104
|
+
cls.__recursive__ = recursive
|
|
105
|
+
cls.__missing_key__ = missing_key or 'error'
|
|
106
|
+
cls.__ignore_delimiters__ = ignore_delimiters or False
|
|
107
|
+
|
|
75
108
|
namespace = dict(cls.__dict__)
|
|
76
109
|
namespace['__dict__'] = {}
|
|
77
110
|
rules_obj = namespace.get('rules', None)
|
|
111
|
+
|
|
78
112
|
cls = type(cls.__name__, (DataMap,), namespace)
|
|
113
|
+
|
|
79
114
|
if rules_obj is not None:
|
|
80
115
|
setattr(cls, 'rules', rules_obj)
|
|
116
|
+
|
|
81
117
|
return dataclass(frozen=False)(cls)
|
|
82
|
-
|
|
118
|
+
|
|
119
|
+
if _cls is not None and isinstance(_cls, type):
|
|
83
120
|
return wrap(_cls)
|
|
121
|
+
|
|
84
122
|
return wrap
|
|
85
123
|
|
|
86
124
|
|
|
@@ -98,30 +136,23 @@ class _SyntaxFactory:
|
|
|
98
136
|
self._syntax = syntax_name
|
|
99
137
|
|
|
100
138
|
def __call__(self, _cls=None, **kwargs):
|
|
101
|
-
# Case 1: bare decorator: @datamap.angles
|
|
102
139
|
if _cls is not None and isinstance(_cls, type):
|
|
103
140
|
return datamap(_cls, syntax=self._syntax)
|
|
104
|
-
|
|
105
|
-
# Case 2: decorator with args: @datamap.angles(mode='config')
|
|
106
141
|
return datamap(syntax=self._syntax, **kwargs)
|
|
107
142
|
|
|
108
143
|
@property
|
|
109
144
|
def config(self):
|
|
110
|
-
# Case 3: @datamap.angles.config
|
|
111
145
|
return datamap(syntax=self._syntax, mode='config')
|
|
112
146
|
|
|
113
147
|
@property
|
|
114
148
|
def dynamic(self):
|
|
115
|
-
# @datamap.angles.dynamic
|
|
116
149
|
return datamap(syntax=self._syntax, mode='dynamic')
|
|
117
150
|
|
|
118
151
|
@property
|
|
119
152
|
def callable(self):
|
|
120
|
-
# @datamap.angles.callable
|
|
121
153
|
return datamap(syntax=self._syntax, mode='callable')
|
|
122
154
|
|
|
123
155
|
|
|
124
|
-
# build syntax shortcuts
|
|
125
156
|
datamap.braces = _SyntaxFactory(syntax.braces)
|
|
126
157
|
datamap.dollars = _SyntaxFactory(syntax.dollars)
|
|
127
158
|
datamap.angles = _SyntaxFactory(syntax.angles)
|
|
@@ -132,7 +163,6 @@ datamap.at_tags = _SyntaxFactory(syntax.at_tags)
|
|
|
132
163
|
datamap.hash_tags = _SyntaxFactory(syntax.hash_tags)
|
|
133
164
|
datamap.paren_dollars = _SyntaxFactory(syntax.paren_dollars)
|
|
134
165
|
|
|
135
|
-
# global mode shortcuts
|
|
136
166
|
datamap.config = datamap(mode='config')
|
|
137
167
|
datamap.dynamic = datamap(mode='dynamic')
|
|
138
168
|
datamap.callable = datamap(mode='callable')
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
from .ast import TemplateNode, TextNode, IdentNode, CallNode
|
|
2
|
+
from .tokenizer import Tokenizer
|
|
3
|
+
from .parser import Parser
|
|
4
|
+
from .exceptions import MapResError, MissingKeyError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class EvaluationError(MapResError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Evaluator:
|
|
12
|
+
def __init__(self, layerstack, ctx=None):
|
|
13
|
+
self.layerstack = layerstack
|
|
14
|
+
self.ctx = ctx or {}
|
|
15
|
+
self._seen = set() # cycle detection
|
|
16
|
+
|
|
17
|
+
# public api
|
|
18
|
+
def evaluate(self, node):
|
|
19
|
+
return self._eval(node)
|
|
20
|
+
|
|
21
|
+
# internal dispatch
|
|
22
|
+
def _eval(self, node):
|
|
23
|
+
if isinstance(node, TemplateNode):
|
|
24
|
+
return self._eval_template(node)
|
|
25
|
+
|
|
26
|
+
if isinstance(node, TextNode):
|
|
27
|
+
return node.text
|
|
28
|
+
|
|
29
|
+
if isinstance(node, IdentNode):
|
|
30
|
+
return self._eval_ident(node)
|
|
31
|
+
|
|
32
|
+
if isinstance(node, CallNode):
|
|
33
|
+
return self._eval_call(node)
|
|
34
|
+
|
|
35
|
+
raise EvaluationError(f'Unknown AST node: {node}')
|
|
36
|
+
|
|
37
|
+
# template
|
|
38
|
+
def _eval_template(self, node: TemplateNode):
|
|
39
|
+
parts = []
|
|
40
|
+
for child in node.children:
|
|
41
|
+
parts.append(self._eval(child))
|
|
42
|
+
out = ''.join(parts)
|
|
43
|
+
|
|
44
|
+
# recursion over full template output
|
|
45
|
+
if self._has_recursive_maps():
|
|
46
|
+
seen = set()
|
|
47
|
+
depth = 0
|
|
48
|
+
max_depth = self._get_max_depth()
|
|
49
|
+
|
|
50
|
+
while depth < max_depth:
|
|
51
|
+
new_out = self._re_resolve(out)
|
|
52
|
+
if new_out == out:
|
|
53
|
+
break
|
|
54
|
+
if new_out in seen:
|
|
55
|
+
raise EvaluationError('Cycle detected in template recursion')
|
|
56
|
+
seen.add(new_out)
|
|
57
|
+
out = new_out
|
|
58
|
+
depth += 1
|
|
59
|
+
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
# identifier
|
|
63
|
+
def _eval_ident(self, node: IdentNode):
|
|
64
|
+
name = node.name
|
|
65
|
+
|
|
66
|
+
# ignore_delimiters: treat token as literal for this syntax
|
|
67
|
+
if self._syntax_ignores_delimiters(node.syntax):
|
|
68
|
+
return self._literal_from_syntax(name, node.syntax)
|
|
69
|
+
|
|
70
|
+
# cycle detection
|
|
71
|
+
if name in self._seen:
|
|
72
|
+
raise EvaluationError(f"Cycle detected in identifier '{name}'")
|
|
73
|
+
self._seen.add(name)
|
|
74
|
+
|
|
75
|
+
parts = name.split('.')
|
|
76
|
+
|
|
77
|
+
# 1. ctx first
|
|
78
|
+
val = self._lookup_hierarchical(self.ctx, parts)
|
|
79
|
+
if val is not None:
|
|
80
|
+
self._seen.remove(name)
|
|
81
|
+
return str(val)
|
|
82
|
+
|
|
83
|
+
# 2. syntax-bound maps
|
|
84
|
+
for m in self.layerstack.all_maps():
|
|
85
|
+
if getattr(m, '__syntax__', None) not in (None, node.syntax):
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
d = self._map_to_dict(m)
|
|
89
|
+
val = self._lookup_hierarchical(d, parts)
|
|
90
|
+
if val is not None:
|
|
91
|
+
self._seen.remove(name)
|
|
92
|
+
return str(val)
|
|
93
|
+
|
|
94
|
+
self._seen.remove(name)
|
|
95
|
+
raise MissingKeyError(name)
|
|
96
|
+
|
|
97
|
+
# call (nested token)
|
|
98
|
+
def _eval_call(self, node: CallNode):
|
|
99
|
+
outer = node.name
|
|
100
|
+
|
|
101
|
+
# ignore_delimiters: treat call token as literal
|
|
102
|
+
if self._syntax_ignores_delimiters(node.syntax):
|
|
103
|
+
arg_text = self._eval(node.arg)
|
|
104
|
+
return self._literal_from_syntax(outer, node.syntax, is_call=True, arg_text=arg_text)
|
|
105
|
+
|
|
106
|
+
if outer in self._seen:
|
|
107
|
+
raise EvaluationError(f"Cycle detected in call '{outer}'")
|
|
108
|
+
self._seen.add(outer)
|
|
109
|
+
|
|
110
|
+
arg_value = self._eval(node.arg)
|
|
111
|
+
parts = [outer, arg_value]
|
|
112
|
+
|
|
113
|
+
# ctx first
|
|
114
|
+
val = self._lookup_hierarchical(self.ctx, parts)
|
|
115
|
+
if val is not None:
|
|
116
|
+
self._seen.remove(outer)
|
|
117
|
+
return str(val)
|
|
118
|
+
|
|
119
|
+
# syntax-bound maps
|
|
120
|
+
for m in self.layerstack.all_maps():
|
|
121
|
+
if getattr(m, '__syntax__', None) not in (None, node.syntax):
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
d = self._map_to_dict(m)
|
|
125
|
+
val = self._lookup_hierarchical(d, parts)
|
|
126
|
+
if val is not None:
|
|
127
|
+
self._seen.remove(outer)
|
|
128
|
+
return str(val)
|
|
129
|
+
|
|
130
|
+
self._seen.remove(outer)
|
|
131
|
+
raise MissingKeyError(f'{outer}({arg_value})')
|
|
132
|
+
|
|
133
|
+
# helpers
|
|
134
|
+
def _map_to_dict(self, m):
|
|
135
|
+
if hasattr(m, 'as_map'):
|
|
136
|
+
return m.as_map()
|
|
137
|
+
if isinstance(m, dict):
|
|
138
|
+
return m
|
|
139
|
+
raise EvaluationError(f'Invalid map object: {m}')
|
|
140
|
+
|
|
141
|
+
def _lookup_hierarchical(self, root, parts):
|
|
142
|
+
cur = root
|
|
143
|
+
for p in parts:
|
|
144
|
+
if not isinstance(cur, dict):
|
|
145
|
+
return None
|
|
146
|
+
if p not in cur:
|
|
147
|
+
return None
|
|
148
|
+
cur = cur[p]
|
|
149
|
+
return cur
|
|
150
|
+
|
|
151
|
+
def _has_recursive_maps(self):
|
|
152
|
+
for m in self.layerstack.all_maps():
|
|
153
|
+
if getattr(m, '__recursive__', False):
|
|
154
|
+
return True
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
def _get_max_depth(self):
|
|
158
|
+
depths = [
|
|
159
|
+
getattr(m, '__max_depth__', 10)
|
|
160
|
+
for m in self.layerstack.all_maps()
|
|
161
|
+
if getattr(m, '__recursive__', False)
|
|
162
|
+
]
|
|
163
|
+
return max(depths) if depths else 0
|
|
164
|
+
|
|
165
|
+
def _re_resolve(self, text: str) -> str:
|
|
166
|
+
tokens = Tokenizer(text).tokenize()
|
|
167
|
+
ast = Parser(tokens).parse()
|
|
168
|
+
return self.evaluate(ast)
|
|
169
|
+
|
|
170
|
+
def _syntax_ignores_delimiters(self, syntax_name: str) -> bool:
|
|
171
|
+
for m in self.layerstack.all_maps():
|
|
172
|
+
if getattr(m, '__syntax__', None) == syntax_name:
|
|
173
|
+
if getattr(m, '__ignore_delimiters__', False):
|
|
174
|
+
return True
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
def _literal_from_syntax(self, name: str, syntax_name: str, is_call: bool = False, arg_text: str | None = None) -> str:
|
|
178
|
+
if not is_call:
|
|
179
|
+
if syntax_name == 'colons':
|
|
180
|
+
return f':{name}:'
|
|
181
|
+
if syntax_name == 'braces':
|
|
182
|
+
return f'{{{{{name}}}}}'
|
|
183
|
+
if syntax_name == 'dollars':
|
|
184
|
+
return f'${{{name}}}'
|
|
185
|
+
if syntax_name == 'angles':
|
|
186
|
+
return f'<{name}>'
|
|
187
|
+
if syntax_name == 'pipes':
|
|
188
|
+
return f'|{name}|'
|
|
189
|
+
if syntax_name == 'percents':
|
|
190
|
+
return f'%{name}%'
|
|
191
|
+
if syntax_name == 'paren_dollars':
|
|
192
|
+
return f'$({name})'
|
|
193
|
+
if syntax_name == 'at_tags':
|
|
194
|
+
return f'@{name}@'
|
|
195
|
+
if syntax_name == 'hash_tags':
|
|
196
|
+
return f'#{name}#'
|
|
197
|
+
return name
|
|
198
|
+
|
|
199
|
+
# call literal: syntax-specific wrapping of name(arg_text)
|
|
200
|
+
arg = arg_text or ''
|
|
201
|
+
inner = f'{name}({arg})'
|
|
202
|
+
|
|
203
|
+
if syntax_name == 'colons':
|
|
204
|
+
return f':{inner}:'
|
|
205
|
+
if syntax_name == 'braces':
|
|
206
|
+
return f'{{{{{inner}}}}}'
|
|
207
|
+
if syntax_name == 'dollars':
|
|
208
|
+
return f'${{{inner}}}'
|
|
209
|
+
if syntax_name == 'angles':
|
|
210
|
+
return f'<{inner}>'
|
|
211
|
+
if syntax_name == 'pipes':
|
|
212
|
+
return f'|{inner}|'
|
|
213
|
+
if syntax_name == 'percents':
|
|
214
|
+
return f'%{inner}%'
|
|
215
|
+
if syntax_name == 'paren_dollars':
|
|
216
|
+
return f'$({inner})'
|
|
217
|
+
if syntax_name == 'at_tags':
|
|
218
|
+
return f'@{inner}@'
|
|
219
|
+
if syntax_name == 'hash_tags':
|
|
220
|
+
return f'#{inner}#'
|
|
221
|
+
return inner
|
|
@@ -3,7 +3,7 @@ from .parser import Parser
|
|
|
3
3
|
from .evaluator import Evaluator
|
|
4
4
|
from .layers import LayerStack
|
|
5
5
|
from .cache import LRUCache
|
|
6
|
-
from .exceptions import MapResError
|
|
6
|
+
from .exceptions import MapResError, MissingKeyError
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class MapResolver:
|
|
@@ -65,8 +65,18 @@ class MapResolver:
|
|
|
65
65
|
|
|
66
66
|
return result
|
|
67
67
|
|
|
68
|
+
except MissingKeyError as exc:
|
|
69
|
+
mode = self._resolve_missing_key_mode()
|
|
70
|
+
if mode == 'error':
|
|
71
|
+
raise MapResError(f'Resolver error: {exc}') from exc
|
|
72
|
+
if mode == 'silent':
|
|
73
|
+
return text
|
|
74
|
+
if mode == 'placeholder':
|
|
75
|
+
return f'<missing:{exc.key}>'
|
|
76
|
+
raise MapResError(f'Resolver error: {exc}') from exc
|
|
77
|
+
|
|
68
78
|
except Exception as exc:
|
|
69
|
-
raise MapResError(f
|
|
79
|
+
raise MapResError(f'Resolver error: {exc}') from exc
|
|
70
80
|
|
|
71
81
|
# ---------------------------------------
|
|
72
82
|
# LAYERSTACK BUILDING
|
|
@@ -92,6 +102,13 @@ class MapResolver:
|
|
|
92
102
|
|
|
93
103
|
return self.layers
|
|
94
104
|
|
|
105
|
+
def _resolve_missing_key_mode(self):
|
|
106
|
+
for m in self.layers.all_maps():
|
|
107
|
+
mode = getattr(m, '__missing_key__', None)
|
|
108
|
+
if mode is not None:
|
|
109
|
+
return mode
|
|
110
|
+
return 'error'
|
|
111
|
+
|
|
95
112
|
|
|
96
113
|
# ---------------------------------------
|
|
97
114
|
# SIMPLE GLOBAL USAGE
|
|
@@ -28,8 +28,8 @@ class Token:
|
|
|
28
28
|
|
|
29
29
|
def __repr__(self):
|
|
30
30
|
if self.value is not None:
|
|
31
|
-
return f
|
|
32
|
-
return f
|
|
31
|
+
return f'Token({self.type}, {self.value!r}, pos={self.pos})'
|
|
32
|
+
return f'Token({self.type}, pos={self.pos})'
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
class Tokenizer:
|
|
@@ -38,10 +38,15 @@ class Tokenizer:
|
|
|
38
38
|
self.i = 0
|
|
39
39
|
self.n = len(text)
|
|
40
40
|
self.tokens = []
|
|
41
|
+
|
|
42
|
+
# toggle states
|
|
41
43
|
self.in_pipe = False
|
|
42
44
|
self.in_percent = False
|
|
43
45
|
self.in_colon = False
|
|
44
46
|
|
|
47
|
+
# mixed states
|
|
48
|
+
self.in_dollar = False
|
|
49
|
+
|
|
45
50
|
def peek(self, k=0):
|
|
46
51
|
idx = self.i + k
|
|
47
52
|
if idx >= self.n:
|
|
@@ -68,6 +73,7 @@ class Tokenizer:
|
|
|
68
73
|
while self.i < self.n:
|
|
69
74
|
ch = self.peek()
|
|
70
75
|
|
|
76
|
+
# escapes
|
|
71
77
|
if self.match(r'\:'):
|
|
72
78
|
buf.append(':'); self.advance(2); continue
|
|
73
79
|
if self.match(r'\<'):
|
|
@@ -89,43 +95,57 @@ class Tokenizer:
|
|
|
89
95
|
if self.match(r'\)'):
|
|
90
96
|
buf.append(')'); self.advance(2); continue
|
|
91
97
|
|
|
92
|
-
|
|
98
|
+
# --- paired delimiters ---
|
|
99
|
+
|
|
100
|
+
# braces {{ }}
|
|
101
|
+
if self.match('{{'):
|
|
93
102
|
flush_text()
|
|
94
103
|
self.emit(TokenType.BRACE_OPEN)
|
|
95
104
|
self.advance(2)
|
|
96
105
|
continue
|
|
97
106
|
|
|
98
|
-
if self.match(
|
|
107
|
+
if self.match('}}'):
|
|
99
108
|
flush_text()
|
|
100
109
|
self.emit(TokenType.BRACE_CLOSE)
|
|
101
110
|
self.advance(2)
|
|
102
111
|
continue
|
|
103
112
|
|
|
104
|
-
|
|
113
|
+
# angles < >
|
|
114
|
+
if self.match('<'):
|
|
105
115
|
flush_text()
|
|
106
|
-
self.emit(TokenType.
|
|
107
|
-
self.advance(
|
|
116
|
+
self.emit(TokenType.ANGLE_OPEN)
|
|
117
|
+
self.advance()
|
|
108
118
|
continue
|
|
109
119
|
|
|
110
|
-
if self.match(
|
|
120
|
+
if self.match('>'):
|
|
111
121
|
flush_text()
|
|
112
|
-
self.emit(TokenType.
|
|
122
|
+
self.emit(TokenType.ANGLE_CLOSE)
|
|
113
123
|
self.advance()
|
|
114
124
|
continue
|
|
115
125
|
|
|
116
|
-
|
|
126
|
+
# --- mixed delimiters (dollars) ---
|
|
127
|
+
|
|
128
|
+
if self.match('${'):
|
|
117
129
|
flush_text()
|
|
118
|
-
self.emit(TokenType.
|
|
119
|
-
self.
|
|
130
|
+
self.emit(TokenType.DOLLAR_OPEN)
|
|
131
|
+
self.in_dollar = True
|
|
132
|
+
self.advance(2)
|
|
120
133
|
continue
|
|
121
134
|
|
|
122
|
-
if
|
|
135
|
+
if ch == '}':
|
|
123
136
|
flush_text()
|
|
124
|
-
self.
|
|
137
|
+
if self.in_dollar:
|
|
138
|
+
self.emit(TokenType.DOLLAR_CLOSE)
|
|
139
|
+
self.in_dollar = False
|
|
140
|
+
else:
|
|
141
|
+
buf.append('}')
|
|
125
142
|
self.advance()
|
|
126
143
|
continue
|
|
127
144
|
|
|
128
|
-
|
|
145
|
+
# --- toggle delimiters ---
|
|
146
|
+
|
|
147
|
+
# pipes |...|
|
|
148
|
+
if self.match('|'):
|
|
129
149
|
flush_text()
|
|
130
150
|
if not self.in_pipe:
|
|
131
151
|
self.emit(TokenType.PIPE_OPEN)
|
|
@@ -136,7 +156,8 @@ class Tokenizer:
|
|
|
136
156
|
self.advance()
|
|
137
157
|
continue
|
|
138
158
|
|
|
139
|
-
|
|
159
|
+
# percents %...%
|
|
160
|
+
if self.match('%'):
|
|
140
161
|
flush_text()
|
|
141
162
|
if not self.in_percent:
|
|
142
163
|
self.emit(TokenType.PERCENT_OPEN)
|
|
@@ -147,7 +168,8 @@ class Tokenizer:
|
|
|
147
168
|
self.advance()
|
|
148
169
|
continue
|
|
149
170
|
|
|
150
|
-
|
|
171
|
+
# colons :...:
|
|
172
|
+
if self.match(':'):
|
|
151
173
|
flush_text()
|
|
152
174
|
if not self.in_colon:
|
|
153
175
|
self.emit(TokenType.COLON_OPEN)
|
|
@@ -158,27 +180,51 @@ class Tokenizer:
|
|
|
158
180
|
self.advance()
|
|
159
181
|
continue
|
|
160
182
|
|
|
161
|
-
|
|
183
|
+
# --- parens ---
|
|
184
|
+
|
|
185
|
+
if ch == '(':
|
|
162
186
|
flush_text()
|
|
163
187
|
self.emit(TokenType.LPAREN)
|
|
164
188
|
self.advance()
|
|
165
189
|
continue
|
|
166
190
|
|
|
167
|
-
if ch ==
|
|
191
|
+
if ch == ')':
|
|
168
192
|
flush_text()
|
|
169
193
|
self.emit(TokenType.RPAREN)
|
|
170
194
|
self.advance()
|
|
171
195
|
continue
|
|
172
196
|
|
|
173
|
-
|
|
197
|
+
# --- IDENT (your rules) ---
|
|
198
|
+
|
|
199
|
+
if ch.isalnum():
|
|
174
200
|
flush_text()
|
|
175
201
|
start = self.i
|
|
176
|
-
|
|
177
|
-
|
|
202
|
+
|
|
203
|
+
# first char: [a-zA-Z0-9]
|
|
204
|
+
self.advance()
|
|
205
|
+
|
|
206
|
+
# middle chars: [a-zA-Z0-9._]*
|
|
207
|
+
while True:
|
|
208
|
+
nxt = self.peek()
|
|
209
|
+
if not nxt:
|
|
210
|
+
break
|
|
211
|
+
if nxt.isalnum() or nxt in '._':
|
|
212
|
+
self.advance()
|
|
213
|
+
else:
|
|
214
|
+
break
|
|
215
|
+
|
|
178
216
|
ident = self.text[start:self.i]
|
|
179
|
-
|
|
217
|
+
|
|
218
|
+
# enforce: no leading/trailing '_', no '-'
|
|
219
|
+
if ident.startswith('_') or ident.endswith('_') or '-' in ident:
|
|
220
|
+
# treat as TEXT instead
|
|
221
|
+
buf.append(ident)
|
|
222
|
+
else:
|
|
223
|
+
self.emit(TokenType.IDENT, ident)
|
|
180
224
|
continue
|
|
181
225
|
|
|
226
|
+
# --- fallback: TEXT ---
|
|
227
|
+
|
|
182
228
|
buf.append(ch)
|
|
183
229
|
self.advance()
|
|
184
230
|
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
from .ast import TemplateNode, TextNode, IdentNode, CallNode
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
class EvaluationError(Exception):
|
|
5
|
-
pass
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class Evaluator:
|
|
9
|
-
def __init__(self, layerstack, ctx=None):
|
|
10
|
-
self.layerstack = layerstack
|
|
11
|
-
self.ctx = ctx or {}
|
|
12
|
-
self._seen = set() # cycle detection
|
|
13
|
-
|
|
14
|
-
# public api
|
|
15
|
-
def evaluate(self, node):
|
|
16
|
-
return self._eval(node)
|
|
17
|
-
|
|
18
|
-
# internal dispatch
|
|
19
|
-
def _eval(self, node):
|
|
20
|
-
if isinstance(node, TemplateNode):
|
|
21
|
-
return self._eval_template(node)
|
|
22
|
-
|
|
23
|
-
if isinstance(node, TextNode):
|
|
24
|
-
return node.text
|
|
25
|
-
|
|
26
|
-
if isinstance(node, IdentNode):
|
|
27
|
-
return self._eval_ident(node)
|
|
28
|
-
|
|
29
|
-
if isinstance(node, CallNode):
|
|
30
|
-
return self._eval_call(node)
|
|
31
|
-
|
|
32
|
-
raise EvaluationError(f"Unknown AST node: {node}")
|
|
33
|
-
|
|
34
|
-
# template
|
|
35
|
-
def _eval_template(self, node: TemplateNode):
|
|
36
|
-
parts = []
|
|
37
|
-
for child in node.children:
|
|
38
|
-
parts.append(self._eval(child))
|
|
39
|
-
return "".join(parts)
|
|
40
|
-
|
|
41
|
-
# identifier
|
|
42
|
-
def _eval_ident(self, node: IdentNode):
|
|
43
|
-
name = node.name
|
|
44
|
-
|
|
45
|
-
# cycle detection
|
|
46
|
-
if name in self._seen:
|
|
47
|
-
raise EvaluationError(f"Cycle detected in identifier '{name}'")
|
|
48
|
-
self._seen.add(name)
|
|
49
|
-
|
|
50
|
-
parts = name.split(".")
|
|
51
|
-
|
|
52
|
-
# 1. ctx first
|
|
53
|
-
val = self._lookup_hierarchical(self.ctx, parts)
|
|
54
|
-
if val is not None:
|
|
55
|
-
self._seen.remove(name)
|
|
56
|
-
return str(val)
|
|
57
|
-
|
|
58
|
-
# 2. syntax-bound maps
|
|
59
|
-
for m in self.layerstack.all_maps():
|
|
60
|
-
if getattr(m, "__syntax__", None) not in (None, node.syntax):
|
|
61
|
-
continue
|
|
62
|
-
|
|
63
|
-
d = self._map_to_dict(m)
|
|
64
|
-
val = self._lookup_hierarchical(d, parts)
|
|
65
|
-
if val is not None:
|
|
66
|
-
self._seen.remove(name)
|
|
67
|
-
return str(val)
|
|
68
|
-
|
|
69
|
-
self._seen.remove(name)
|
|
70
|
-
raise EvaluationError(f"Missing key '{name}'")
|
|
71
|
-
|
|
72
|
-
# call (nested token)
|
|
73
|
-
def _eval_call(self, node: CallNode):
|
|
74
|
-
outer = node.name
|
|
75
|
-
|
|
76
|
-
if outer in self._seen:
|
|
77
|
-
raise EvaluationError(f"Cycle detected in call '{outer}'")
|
|
78
|
-
self._seen.add(outer)
|
|
79
|
-
|
|
80
|
-
arg_value = self._eval(node.arg)
|
|
81
|
-
parts = [outer, arg_value]
|
|
82
|
-
|
|
83
|
-
# ctx first
|
|
84
|
-
val = self._lookup_hierarchical(self.ctx, parts)
|
|
85
|
-
if val is not None:
|
|
86
|
-
self._seen.remove(outer)
|
|
87
|
-
return str(val)
|
|
88
|
-
|
|
89
|
-
# syntax-bound maps
|
|
90
|
-
for m in self.layerstack.all_maps():
|
|
91
|
-
if getattr(m, "__syntax__", None) not in (None, node.syntax):
|
|
92
|
-
continue
|
|
93
|
-
|
|
94
|
-
d = self._map_to_dict(m)
|
|
95
|
-
val = self._lookup_hierarchical(d, parts)
|
|
96
|
-
if val is not None:
|
|
97
|
-
self._seen.remove(outer)
|
|
98
|
-
return str(val)
|
|
99
|
-
|
|
100
|
-
self._seen.remove(outer)
|
|
101
|
-
raise EvaluationError(f"Missing nested key '{outer}({arg_value})'")
|
|
102
|
-
|
|
103
|
-
# helpers
|
|
104
|
-
def _map_to_dict(self, m):
|
|
105
|
-
if hasattr(m, "as_map"):
|
|
106
|
-
return m.as_map()
|
|
107
|
-
if isinstance(m, dict):
|
|
108
|
-
return m
|
|
109
|
-
raise EvaluationError(f"Invalid map object: {m}")
|
|
110
|
-
|
|
111
|
-
def _lookup_hierarchical(self, root, parts):
|
|
112
|
-
cur = root
|
|
113
|
-
for p in parts:
|
|
114
|
-
if not isinstance(cur, dict):
|
|
115
|
-
return None
|
|
116
|
-
if p not in cur:
|
|
117
|
-
return None
|
|
118
|
-
cur = cur[p]
|
|
119
|
-
return cur
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|