mapres 3.dev20__tar.gz → 3.dev22__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.
Files changed (24) hide show
  1. {mapres-3.dev20/src/mapres.egg-info → mapres-3.dev22}/PKG-INFO +1 -1
  2. {mapres-3.dev20 → mapres-3.dev22}/pyproject.toml +1 -1
  3. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/datamap.py +5 -11
  4. mapres-3.dev22/src/mapres/evaluator.py +123 -0
  5. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/parser.py +21 -34
  6. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/tokenizer.py +99 -100
  7. {mapres-3.dev20 → mapres-3.dev22/src/mapres.egg-info}/PKG-INFO +1 -1
  8. mapres-3.dev20/src/mapres/evaluator.py +0 -221
  9. {mapres-3.dev20 → mapres-3.dev22}/LICENSE +0 -0
  10. {mapres-3.dev20 → mapres-3.dev22}/README.md +0 -0
  11. {mapres-3.dev20 → mapres-3.dev22}/setup.cfg +0 -0
  12. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/__init__.py +0 -0
  13. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/ast.py +0 -0
  14. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/cache.py +0 -0
  15. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/exceptions.py +0 -0
  16. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/layers.py +0 -0
  17. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/maps/__init__.py +0 -0
  18. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/maps/color.py +0 -0
  19. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/maps/time.py +0 -0
  20. {mapres-3.dev20 → mapres-3.dev22}/src/mapres/resolver.py +0 -0
  21. {mapres-3.dev20 → mapres-3.dev22}/src/mapres.egg-info/SOURCES.txt +0 -0
  22. {mapres-3.dev20 → mapres-3.dev22}/src/mapres.egg-info/dependency_links.txt +0 -0
  23. {mapres-3.dev20 → mapres-3.dev22}/src/mapres.egg-info/requires.txt +0 -0
  24. {mapres-3.dev20 → mapres-3.dev22}/src/mapres.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapres
3
- Version: 3.dev20
3
+ Version: 3.dev22
4
4
  Summary: A powerfull mapping utility
5
5
  Author: iFamished
6
6
  License: MIT
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "mapres"
10
- version = "3-dev.20"
10
+ version = "3-dev.22"
11
11
  description = "A powerfull mapping utility"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.9"
@@ -1,4 +1,3 @@
1
- import re
2
1
  from dataclasses import dataclass, fields, is_dataclass
3
2
 
4
3
  # syntax identifiers
@@ -30,15 +29,13 @@ class DataMap:
30
29
 
31
30
  Mode metadata (__mode__) controls behavior:
32
31
  - None: plain static map
33
- - "dynamic": values may be provided by callables/providers
34
- - "config": config-style maps (no dynamic evaluation)
35
- - "callable": map exposes callable methods for nested calls
32
+ - 'dynamic': values may be provided by callables/providers
33
+ - 'config': config-style maps (no dynamic evaluation)
34
+ - 'callable': map exposes callable methods for nested calls
36
35
  '''
37
36
  __syntax__: str = None
38
- __mode__: str | None = None # "dynamic", "config", "callable", or None
39
- __recursive__: bool = False
37
+ __mode__: str | None = None # 'dynamic', 'config', 'callable', or None
40
38
  __missing_key__: str = 'error' # 'error', 'silent', 'placeholder'
41
- __ignore_delimiters__: bool = False
42
39
 
43
40
  def as_map(self):
44
41
  '''
@@ -95,14 +92,11 @@ class DataMap:
95
92
  return out
96
93
 
97
94
 
98
- def datamap(_cls=None, *, syntax=None, mode=None, recursive=None, missing_key=None, ignore_delimiters=None):
99
-
95
+ def datamap(_cls=None, *, syntax=None, mode=None, missing_key=None):
100
96
  def wrap(cls):
101
97
  cls.__syntax__ = syntax
102
98
  cls.__mode__ = mode
103
- cls.__recursive__ = recursive
104
99
  cls.__missing_key__ = missing_key or 'error'
105
- cls.__ignore_delimiters__ = ignore_delimiters or False
106
100
 
107
101
  namespace = dict(cls.__dict__)
108
102
  namespace['__dict__'] = {}
@@ -0,0 +1,123 @@
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
+ return out
44
+
45
+ # identifier
46
+ def _eval_ident(self, node: IdentNode):
47
+ name = node.name
48
+
49
+ # cycle detection
50
+ if name in self._seen:
51
+ raise EvaluationError(f"Cycle detected in identifier '{name}'")
52
+ self._seen.add(name)
53
+
54
+ parts = name.split('.')
55
+
56
+ # 1. ctx first
57
+ val = self._lookup_hierarchical(self.ctx, parts)
58
+ if val is not None:
59
+ self._seen.remove(name)
60
+ return str(val)
61
+
62
+ # 2. syntax-bound maps
63
+ for m in self.layerstack.all_maps():
64
+ if getattr(m, '__syntax__', None) not in (None, node.syntax):
65
+ continue
66
+
67
+ d = self._map_to_dict(m)
68
+ val = self._lookup_hierarchical(d, parts)
69
+ if val is not None:
70
+ self._seen.remove(name)
71
+ return str(val)
72
+
73
+ self._seen.remove(name)
74
+ raise MissingKeyError(name)
75
+
76
+ # call (nested token)
77
+ def _eval_call(self, node: CallNode):
78
+ outer = node.name
79
+
80
+ if outer in self._seen:
81
+ raise EvaluationError(f"Cycle detected in call '{outer}'")
82
+ self._seen.add(outer)
83
+
84
+ arg_value = self._eval(node.arg)
85
+ parts = [outer, arg_value]
86
+
87
+ # ctx first
88
+ val = self._lookup_hierarchical(self.ctx, parts)
89
+ if val is not None:
90
+ self._seen.remove(outer)
91
+ return str(val)
92
+
93
+ # syntax-bound maps
94
+ for m in self.layerstack.all_maps():
95
+ if getattr(m, '__syntax__', None) not in (None, node.syntax):
96
+ continue
97
+
98
+ d = self._map_to_dict(m)
99
+ val = self._lookup_hierarchical(d, parts)
100
+ if val is not None:
101
+ self._seen.remove(outer)
102
+ return str(val)
103
+
104
+ self._seen.remove(outer)
105
+ raise MissingKeyError(f'{outer}({arg_value})')
106
+
107
+ # helpers
108
+ def _map_to_dict(self, m):
109
+ if hasattr(m, 'as_map'):
110
+ return m.as_map()
111
+ if isinstance(m, dict):
112
+ return m
113
+ raise EvaluationError(f'Invalid map object: {m}')
114
+
115
+ def _lookup_hierarchical(self, root, parts):
116
+ cur = root
117
+ for p in parts:
118
+ if not isinstance(cur, dict):
119
+ return None
120
+ if p not in cur:
121
+ return None
122
+ cur = cur[p]
123
+ return cur
@@ -22,11 +22,10 @@ class Parser:
22
22
  def expect(self, type_: TokenType) -> Token:
23
23
  tok = self.peek()
24
24
  if tok is None or tok.type != type_:
25
- raise ParserError(f"Expected {type_}, got {tok}")
25
+ raise ParserError(f'Expected {type_}, got {tok}')
26
26
  self.advance()
27
27
  return tok
28
28
 
29
- # top-level template parse
30
29
  def parse(self) -> TemplateNode:
31
30
  children: list[Node] = []
32
31
  while self.peek() is not None:
@@ -38,88 +37,76 @@ class Parser:
38
37
  children.append(self.parse_token())
39
38
  return TemplateNode(children)
40
39
 
41
- # nested template parse (for IDENT(...))
42
40
  def parse_until(self, stop_types: tuple[TokenType, ...]) -> TemplateNode:
43
41
  children: list[Node] = []
44
42
  while True:
45
43
  tok = self.peek()
46
44
  if tok is None or tok.type in stop_types:
47
45
  break
48
-
49
46
  if tok.type is TokenType.TEXT:
50
47
  children.append(TextNode(tok.value))
51
48
  self.advance()
52
49
  else:
53
50
  children.append(self.parse_token())
54
-
55
51
  return TemplateNode(children)
56
52
 
57
- # dispatch by opening delimiter
58
53
  def parse_token(self) -> Node:
59
54
  tok = self.peek()
60
55
 
61
56
  if tok.type is TokenType.COLON_OPEN:
62
- return self.parse_colon_token()
57
+ return self.parse_colon()
63
58
 
64
59
  if tok.type is TokenType.BRACE_OPEN:
65
- return self.parse_brace_token()
60
+ return self.parse_brace()
66
61
 
67
62
  if tok.type is TokenType.DOLLAR_OPEN:
68
- return self.parse_dollar_token()
63
+ return self.parse_dollar()
69
64
 
70
65
  if tok.type is TokenType.ANGLE_OPEN:
71
- return self.parse_angle_token()
66
+ return self.parse_angle()
72
67
 
73
68
  if tok.type is TokenType.PIPE_OPEN:
74
- return self.parse_pipe_token()
69
+ return self.parse_pipe()
75
70
 
76
71
  if tok.type is TokenType.PERCENT_OPEN:
77
- return self.parse_percent_token()
72
+ return self.parse_percent()
78
73
 
79
- raise ParserError(f"Unexpected token {tok}")
74
+ raise ParserError(f'Unexpected token {tok}')
80
75
 
81
- # core pattern: IDENT or IDENT(arg)
82
76
  def _parse_ident_or_call(self, closing_type: TokenType, syntax_name: str) -> Node:
83
77
  ident_tok = self.expect(TokenType.IDENT)
84
78
  name = ident_tok.value
85
79
 
86
- # IDENT(arg)
87
80
  if self.peek() and self.peek().type is TokenType.LPAREN:
88
- self.advance() # consume '('
89
-
90
- # parse only until RPAREN
81
+ self.advance()
91
82
  arg = self.parse_until((TokenType.RPAREN,))
92
-
93
83
  self.expect(TokenType.RPAREN)
94
84
  self.expect(closing_type)
95
85
  return CallNode(name=name, arg=arg, syntax=syntax_name)
96
86
 
97
- # IDENT
98
87
  self.expect(closing_type)
99
88
  return IdentNode(name=name, syntax=syntax_name)
100
89
 
101
- # --- syntax-bound token handlers ---
102
-
103
- def parse_colon_token(self) -> Node:
90
+ def parse_colon(self) -> Node:
104
91
  self.expect(TokenType.COLON_OPEN)
105
- return self._parse_ident_or_call(TokenType.COLON_CLOSE, "colons")
92
+ return self._parse_ident_or_call(TokenType.COLON_CLOSE, 'colons')
106
93
 
107
- def parse_brace_token(self) -> Node:
94
+ def parse_brace(self) -> Node:
108
95
  self.expect(TokenType.BRACE_OPEN)
109
- return self._parse_ident_or_call(TokenType.BRACE_CLOSE, "braces")
96
+ return self._parse_ident_or_call(TokenType.BRACE_CLOSE, 'braces')
110
97
 
111
- def parse_dollar_token(self) -> Node:
98
+ def parse_dollar(self) -> Node:
112
99
  self.expect(TokenType.DOLLAR_OPEN)
113
- return self._parse_ident_or_call(TokenType.DOLLAR_CLOSE, "dollars")
100
+ return self._parse_ident_or_call(TokenType.DOLLAR_CLOSE, 'dollars')
114
101
 
115
- def parse_angle_token(self) -> Node:
102
+ def parse_angle(self) -> Node:
116
103
  self.expect(TokenType.ANGLE_OPEN)
117
- return self._parse_ident_or_call(TokenType.ANGLE_CLOSE, "angles")
104
+ return self._parse_ident_or_call(TokenType.ANGLE_CLOSE, 'angles')
118
105
 
119
- def parse_pipe_token(self) -> Node:
106
+ def parse_pipe(self) -> Node:
120
107
  self.expect(TokenType.PIPE_OPEN)
121
- return self._parse_ident_or_call(TokenType.PIPE_CLOSE, "pipes")
108
+ return self._parse_ident_or_call(TokenType.PIPE_CLOSE, 'pipes')
122
109
 
123
- def parse_percent_token(self) -> Node:
110
+ def parse_percent(self) -> Node:
124
111
  self.expect(TokenType.PERCENT_OPEN)
125
- return self._parse_ident_or_call(TokenType.PERCENT_CLOSE, "percents")
112
+ return self._parse_ident_or_call(TokenType.PERCENT_CLOSE, 'percents')
@@ -33,37 +33,29 @@ class Token:
33
33
 
34
34
 
35
35
  class Tokenizer:
36
- def __init__(self, text):
36
+ def __init__(self, text: str):
37
37
  self.text = text
38
38
  self.i = 0
39
39
  self.n = len(text)
40
- self.tokens = []
40
+ self.tokens: list[Token] = []
41
41
 
42
- # toggle states
43
- self.in_pipe = False
44
- self.in_percent = False
45
- self.in_colon = False
46
-
47
- # mixed states
48
- self.in_dollar = False
49
-
50
- def peek(self, k=0):
42
+ def peek(self, k: int = 0) -> str:
51
43
  idx = self.i + k
52
44
  if idx >= self.n:
53
45
  return ''
54
46
  return self.text[idx]
55
47
 
56
- def advance(self, k=1):
48
+ def advance(self, k: int = 1) -> None:
57
49
  self.i += k
58
50
 
59
- def match(self, s):
51
+ def match(self, s: str) -> bool:
60
52
  return self.text.startswith(s, self.i)
61
53
 
62
- def emit(self, type_, value=None):
54
+ def emit(self, type_, value=None) -> None:
63
55
  self.tokens.append(Token(type_, value, self.i))
64
56
 
65
- def tokenize(self):
66
- buf = []
57
+ def tokenize(self) -> list[Token]:
58
+ buf: list[str] = []
67
59
 
68
60
  def flush_text():
69
61
  if buf:
@@ -95,93 +87,109 @@ class Tokenizer:
95
87
  if self.match(r'\)'):
96
88
  buf.append(')'); self.advance(2); continue
97
89
 
98
- # --- paired delimiters ---
99
-
100
- # braces {{ }}
90
+ # --- {{IDENT}} ---
101
91
  if self.match('{{'):
102
92
  flush_text()
103
- self.emit(TokenType.BRACE_OPEN)
104
- self.advance(2)
105
- continue
106
-
107
- if self.match('}}'):
108
- flush_text()
109
- self.emit(TokenType.BRACE_CLOSE)
93
+ start = self.i
110
94
  self.advance(2)
111
- continue
112
-
113
- # angles < >
114
- if self.match('<'):
115
- flush_text()
116
- self.emit(TokenType.ANGLE_OPEN)
117
- self.advance()
118
- continue
119
-
120
- if self.match('>'):
121
- flush_text()
122
- self.emit(TokenType.ANGLE_CLOSE)
95
+ ident = self._read_ident()
96
+ if ident is not None and self.match('}}'):
97
+ self.emit(TokenType.BRACE_OPEN)
98
+ self.emit(TokenType.IDENT, ident)
99
+ self.advance(2)
100
+ self.emit(TokenType.BRACE_CLOSE)
101
+ continue
102
+ self.i = start
103
+ buf.append(self.peek())
123
104
  self.advance()
124
105
  continue
125
106
 
126
- # --- mixed delimiters (dollars) ---
127
-
107
+ # --- ${IDENT} ---
128
108
  if self.match('${'):
129
109
  flush_text()
130
- self.emit(TokenType.DOLLAR_OPEN)
131
- self.in_dollar = True
110
+ start = self.i
132
111
  self.advance(2)
112
+ ident = self._read_ident()
113
+ if ident is not None and self.peek() == '}':
114
+ self.emit(TokenType.DOLLAR_OPEN)
115
+ self.emit(TokenType.IDENT, ident)
116
+ self.advance()
117
+ self.emit(TokenType.DOLLAR_CLOSE)
118
+ continue
119
+ self.i = start
120
+ buf.append(self.peek())
121
+ self.advance()
133
122
  continue
134
123
 
135
- if ch == '}':
124
+ # --- <IDENT> ---
125
+ if ch == '<':
136
126
  flush_text()
137
- if self.in_dollar:
138
- self.emit(TokenType.DOLLAR_CLOSE)
139
- self.in_dollar = False
140
- else:
141
- buf.append('}')
127
+ start = self.i
128
+ self.advance()
129
+ ident = self._read_ident()
130
+ if ident is not None and self.peek() == '>':
131
+ self.emit(TokenType.ANGLE_OPEN)
132
+ self.emit(TokenType.IDENT, ident)
133
+ self.advance()
134
+ self.emit(TokenType.ANGLE_CLOSE)
135
+ continue
136
+ self.i = start
137
+ buf.append(self.peek())
142
138
  self.advance()
143
139
  continue
144
140
 
145
- # --- toggle delimiters ---
146
-
147
- # pipes |...|
148
- if self.match('|'):
141
+ # --- |IDENT| ---
142
+ if ch == '|':
149
143
  flush_text()
150
- if not self.in_pipe:
144
+ start = self.i
145
+ self.advance()
146
+ ident = self._read_ident()
147
+ if ident is not None and self.peek() == '|':
151
148
  self.emit(TokenType.PIPE_OPEN)
152
- self.in_pipe = True
153
- else:
149
+ self.emit(TokenType.IDENT, ident)
150
+ self.advance()
154
151
  self.emit(TokenType.PIPE_CLOSE)
155
- self.in_pipe = False
152
+ continue
153
+ self.i = start
154
+ buf.append(self.peek())
156
155
  self.advance()
157
156
  continue
158
157
 
159
- # percents %...%
160
- if self.match('%'):
158
+ # --- %IDENT% ---
159
+ if ch == '%':
161
160
  flush_text()
162
- if not self.in_percent:
161
+ start = self.i
162
+ self.advance()
163
+ ident = self._read_ident()
164
+ if ident is not None and self.peek() == '%':
163
165
  self.emit(TokenType.PERCENT_OPEN)
164
- self.in_percent = True
165
- else:
166
+ self.emit(TokenType.IDENT, ident)
167
+ self.advance()
166
168
  self.emit(TokenType.PERCENT_CLOSE)
167
- self.in_percent = False
169
+ continue
170
+ self.i = start
171
+ buf.append(self.peek())
168
172
  self.advance()
169
173
  continue
170
174
 
171
- # colons :...:
172
- if self.match(':'):
175
+ # --- :IDENT: ---
176
+ if ch == ':':
173
177
  flush_text()
174
- if not self.in_colon:
178
+ start = self.i
179
+ self.advance()
180
+ ident = self._read_ident()
181
+ if ident is not None and self.peek() == ':':
175
182
  self.emit(TokenType.COLON_OPEN)
176
- self.in_colon = True
177
- else:
183
+ self.emit(TokenType.IDENT, ident)
184
+ self.advance()
178
185
  self.emit(TokenType.COLON_CLOSE)
179
- self.in_colon = False
186
+ continue
187
+ self.i = start
188
+ buf.append(self.peek())
180
189
  self.advance()
181
190
  continue
182
191
 
183
192
  # --- parens ---
184
-
185
193
  if ch == '(':
186
194
  flush_text()
187
195
  self.emit(TokenType.LPAREN)
@@ -194,39 +202,30 @@ class Tokenizer:
194
202
  self.advance()
195
203
  continue
196
204
 
197
- # --- IDENT (your rules) ---
198
-
199
- if ch.isalnum():
200
- flush_text()
201
- start = self.i
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
-
216
- ident = self.text[start:self.i]
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)
224
- continue
225
-
226
- # --- fallback: TEXT ---
227
-
205
+ # fallback TEXT
228
206
  buf.append(ch)
229
207
  self.advance()
230
208
 
231
209
  flush_text()
232
210
  return self.tokens
211
+
212
+ def _read_ident(self) -> str | None:
213
+ start = self.i
214
+ ch = self.peek()
215
+ if not ch or not ch.isalnum():
216
+ return None
217
+
218
+ self.advance()
219
+ while True:
220
+ nxt = self.peek()
221
+ if not nxt:
222
+ break
223
+ if nxt.isalnum() or nxt in '._':
224
+ self.advance()
225
+ else:
226
+ break
227
+
228
+ ident = self.text[start:self.i]
229
+ if ident.startswith('_') or ident.endswith('_') or '-' in ident:
230
+ return None
231
+ return ident
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mapres
3
- Version: 3.dev20
3
+ Version: 3.dev22
4
4
  Summary: A powerfull mapping utility
5
5
  Author: iFamished
6
6
  License: MIT
@@ -1,221 +0,0 @@
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) is True:
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) is True
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
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes