agentainer 0.1.0

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.
package/lib/minyaml.py ADDED
@@ -0,0 +1,376 @@
1
+ """A small YAML-subset parser.
2
+
3
+ Used only when PyYAML is not importable, so that AgentSwarm keeps working on a
4
+ bare Python 3 install. Supports the subset AgentSwarm configs actually need:
5
+
6
+ * nested block mappings and block sequences
7
+ * scalars: strings, ints, floats, booleans, null
8
+ * single/double quoted strings
9
+ * literal (``|``) and folded (``>``) block scalars, with ``-``/``+`` chomping
10
+ * flow collections: ``[a, b]`` and ``{a: 1, b: 2}``
11
+ * ``#`` comments (whole-line and trailing)
12
+
13
+ Anything fancier (anchors, aliases, tags, multi-doc, complex keys) raises
14
+ YAMLError telling the user to install PyYAML.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+
21
+ __all__ = ["load", "YAMLError"]
22
+
23
+
24
+ class YAMLError(Exception):
25
+ pass
26
+
27
+
28
+ _KEY_RE = re.compile(
29
+ r"""^(?P<key>
30
+ "(?:[^"\\]|\\.)*" # double quoted key
31
+ | '(?:[^']|'')*' # single quoted key
32
+ | [^\s:#][^:#]*? # plain key
33
+ )
34
+ \s*:(?:\s+(?P<rest>.*))?$""",
35
+ re.VERBOSE,
36
+ )
37
+
38
+ _BLOCK_RE = re.compile(r"^([|>])([+-]?)(\d*)$")
39
+
40
+ _UNSUPPORTED = ("&", "*", "!!", "---", "...")
41
+
42
+
43
+ def load(text: str):
44
+ """Parse *text* and return the corresponding Python object."""
45
+ lines = text.replace("\t", " ").splitlines()
46
+ for raw in lines:
47
+ stripped = raw.strip()
48
+ if stripped.startswith(("---", "...")) and stripped not in ("---", "..."):
49
+ continue
50
+ if stripped in ("---", "..."):
51
+ raise YAMLError(
52
+ "multi-document YAML is not supported by the builtin parser; "
53
+ "install PyYAML (pip install pyyaml)"
54
+ )
55
+ parser = _Parser(lines)
56
+ value = parser.parse()
57
+ parser.expect_eof()
58
+ return value if value is not None else {}
59
+
60
+
61
+ # --------------------------------------------------------------------------
62
+ # scalar handling
63
+ # --------------------------------------------------------------------------
64
+
65
+
66
+ def _strip_comment(s: str) -> str:
67
+ """Remove a trailing ``# comment``, respecting quotes and brackets."""
68
+ out = []
69
+ quote = None
70
+ prev = ""
71
+ for ch in s:
72
+ if quote:
73
+ out.append(ch)
74
+ if ch == quote and prev != "\\":
75
+ quote = None
76
+ elif ch in "\"'":
77
+ quote = ch
78
+ out.append(ch)
79
+ elif ch == "#" and (not out or prev in " \t"):
80
+ break
81
+ else:
82
+ out.append(ch)
83
+ prev = ch
84
+ return "".join(out).rstrip()
85
+
86
+
87
+ def _split_flow(body: str) -> list[str]:
88
+ """Split ``a, b, [c, d]`` on top-level commas."""
89
+ parts, depth, quote, cur = [], 0, None, []
90
+ for ch in body:
91
+ if quote:
92
+ cur.append(ch)
93
+ if ch == quote:
94
+ quote = None
95
+ continue
96
+ if ch in "\"'":
97
+ quote = ch
98
+ elif ch in "[{":
99
+ depth += 1
100
+ elif ch in "]}":
101
+ depth -= 1
102
+ elif ch == "," and depth == 0:
103
+ parts.append("".join(cur))
104
+ cur = []
105
+ continue
106
+ cur.append(ch)
107
+ if "".join(cur).strip():
108
+ parts.append("".join(cur))
109
+ return [p.strip() for p in parts]
110
+
111
+
112
+ def _is_balanced(s: str) -> bool:
113
+ """True once every ``[``/``{`` opened in *s* has been closed."""
114
+ depth, quote = 0, None
115
+ for ch in s:
116
+ if quote:
117
+ if ch == quote:
118
+ quote = None
119
+ elif ch in "\"'":
120
+ quote = ch
121
+ elif ch in "[{":
122
+ depth += 1
123
+ elif ch in "]}":
124
+ depth -= 1
125
+ return depth <= 0
126
+
127
+
128
+ def _unquote(s: str) -> str:
129
+ if len(s) >= 2 and s[0] == s[-1] == '"':
130
+ return s[1:-1].encode("utf-8").decode("unicode_escape")
131
+ if len(s) >= 2 and s[0] == s[-1] == "'":
132
+ return s[1:-1].replace("''", "'")
133
+ return s
134
+
135
+
136
+ def _scalar(raw: str):
137
+ s = _strip_comment(raw).strip()
138
+ if not s:
139
+ return None
140
+ if s[0] in "\"'":
141
+ return _unquote(s)
142
+ if s.startswith("["):
143
+ if not s.endswith("]"):
144
+ raise YAMLError(f"unterminated flow sequence: {raw!r}")
145
+ return [_scalar(p) for p in _split_flow(s[1:-1])]
146
+ if s.startswith("{"):
147
+ if not s.endswith("}"):
148
+ raise YAMLError(f"unterminated flow mapping: {raw!r}")
149
+ out = {}
150
+ for part in _split_flow(s[1:-1]):
151
+ if ":" not in part:
152
+ raise YAMLError(f"bad flow mapping entry: {part!r}")
153
+ k, _, v = part.partition(":")
154
+ out[_unquote(k.strip())] = _scalar(v)
155
+ return out
156
+ if s.startswith(_UNSUPPORTED):
157
+ raise YAMLError(
158
+ f"unsupported YAML feature in {raw!r}; install PyYAML (pip install pyyaml)"
159
+ )
160
+
161
+ low = s.lower()
162
+ if low in ("null", "~"):
163
+ return None
164
+ if low in ("true", "yes", "on"):
165
+ return True
166
+ if low in ("false", "no", "off"):
167
+ return False
168
+ try:
169
+ return int(s, 10)
170
+ except ValueError:
171
+ pass
172
+ try:
173
+ return float(s)
174
+ except ValueError:
175
+ pass
176
+ return s
177
+
178
+
179
+ # --------------------------------------------------------------------------
180
+ # block parser
181
+ # --------------------------------------------------------------------------
182
+
183
+
184
+ def _indent_of(line: str) -> int:
185
+ return len(line) - len(line.lstrip(" "))
186
+
187
+
188
+ def _is_blank(line: str) -> bool:
189
+ s = line.strip()
190
+ return not s or s.startswith("#")
191
+
192
+
193
+ class _Parser:
194
+ def __init__(self, lines: list[str]):
195
+ self.lines = lines
196
+ self.i = 0
197
+
198
+ # -- cursor helpers ----------------------------------------------------
199
+
200
+ def peek(self):
201
+ """Return (index, indent, stripped) of the next meaningful line."""
202
+ while self.i < len(self.lines) and _is_blank(self.lines[self.i]):
203
+ self.i += 1
204
+ if self.i >= len(self.lines):
205
+ return None
206
+ line = self.lines[self.i]
207
+ return self.i, _indent_of(line), line.strip()
208
+
209
+ def expect_eof(self):
210
+ if self.peek() is not None:
211
+ _, indent, text = self.peek()
212
+ raise YAMLError(f"unexpected content at indent {indent}: {text!r}")
213
+
214
+ def finish_flow(self, first: str):
215
+ """Parse a flow collection, consuming continuation lines until balanced.
216
+
217
+ The cursor must already sit *after* the line ``first`` came from.
218
+ """
219
+ buf = _strip_comment(first)
220
+ while not _is_balanced(buf):
221
+ if self.i >= len(self.lines):
222
+ raise YAMLError(f"unterminated flow collection: {first!r}")
223
+ line = self.lines[self.i]
224
+ self.i += 1
225
+ if _is_blank(line):
226
+ continue
227
+ buf += " " + _strip_comment(line.strip())
228
+ return _scalar(buf)
229
+
230
+ # -- grammar -----------------------------------------------------------
231
+
232
+ def parse(self, indent: int | None = None):
233
+ head = self.peek()
234
+ if head is None:
235
+ return None
236
+ _, cur_indent, text = head
237
+ if indent is None:
238
+ indent = cur_indent
239
+ if cur_indent < indent:
240
+ return None
241
+ if text == "-" or text.startswith("- "):
242
+ return self.parse_seq(cur_indent)
243
+ # Check flow collections before the key regex: `{name: a, type: b}` would
244
+ # otherwise look like a block mapping with the key `{name`.
245
+ if text.startswith(("{", "[")):
246
+ self.i += 1
247
+ return self.finish_flow(text)
248
+ if _KEY_RE.match(text):
249
+ return self.parse_map(cur_indent)
250
+ # A lone scalar (only reachable for sequence item bodies).
251
+ self.i += 1
252
+ return _scalar(text)
253
+
254
+ def parse_map(self, indent: int) -> dict:
255
+ out: dict = {}
256
+ while True:
257
+ head = self.peek()
258
+ if head is None:
259
+ break
260
+ _, cur_indent, text = head
261
+ if cur_indent < indent:
262
+ break
263
+ if cur_indent > indent:
264
+ raise YAMLError(f"unexpected indentation before {text!r}")
265
+ match = _KEY_RE.match(text)
266
+ if not match:
267
+ break
268
+ key = _unquote(match.group("key").strip())
269
+ rest = (match.group("rest") or "").strip()
270
+ self.i += 1
271
+
272
+ block = _BLOCK_RE.match(_strip_comment(rest)) if rest else None
273
+ if block:
274
+ out[key] = self.parse_block_scalar(indent, block)
275
+ elif rest.startswith(("{", "[")) and not _is_balanced(_strip_comment(rest)):
276
+ out[key] = self.finish_flow(rest)
277
+ elif rest:
278
+ out[key] = _scalar(rest)
279
+ else:
280
+ nxt = self.peek()
281
+ if nxt and nxt[1] > indent:
282
+ out[key] = self.parse(nxt[1])
283
+ elif nxt and nxt[1] == indent and nxt[2].startswith(("-", "- ")):
284
+ # Sequences may sit at the same indent as their key.
285
+ out[key] = self.parse_seq(indent)
286
+ else:
287
+ out[key] = None
288
+ return out
289
+
290
+ def parse_seq(self, indent: int) -> list:
291
+ out: list = []
292
+ while True:
293
+ head = self.peek()
294
+ if head is None:
295
+ break
296
+ idx, cur_indent, text = head
297
+ if cur_indent != indent or not (text == "-" or text.startswith("- ")):
298
+ break
299
+
300
+ line = self.lines[idx]
301
+ # Rewrite "- foo" into " foo" so the item body is a normal block
302
+ # that starts at the column right after the dash.
303
+ body_first = line[:indent] + " " + line[indent + 1 :]
304
+ self.i += 1
305
+
306
+ if not body_first.strip():
307
+ nxt = self.peek()
308
+ out.append(self.parse(nxt[1]) if nxt and nxt[1] > indent else None)
309
+ continue
310
+
311
+ body = [body_first]
312
+ while self.i < len(self.lines):
313
+ nxt_line = self.lines[self.i]
314
+ if _is_blank(nxt_line):
315
+ body.append(nxt_line)
316
+ self.i += 1
317
+ continue
318
+ if _indent_of(nxt_line) > indent:
319
+ body.append(nxt_line)
320
+ self.i += 1
321
+ continue
322
+ break
323
+
324
+ sub = _Parser(body)
325
+ out.append(sub.parse())
326
+ sub.expect_eof()
327
+ return out
328
+
329
+ def parse_block_scalar(self, indent: int, match: re.Match) -> str:
330
+ style, chomp, explicit = match.group(1), match.group(2), match.group(3)
331
+
332
+ raw: list[str] = []
333
+ while self.i < len(self.lines):
334
+ line = self.lines[self.i]
335
+ if line.strip() and _indent_of(line) <= indent:
336
+ break
337
+ raw.append(line)
338
+ self.i += 1
339
+
340
+ while raw and not raw[-1].strip():
341
+ raw.pop()
342
+ if not raw:
343
+ return ""
344
+
345
+ if explicit:
346
+ block_indent = indent + int(explicit)
347
+ else:
348
+ block_indent = min(_indent_of(l) for l in raw if l.strip())
349
+
350
+ body = [l[block_indent:] if len(l) > block_indent else "" for l in raw]
351
+
352
+ if style == "|":
353
+ text = "\n".join(body)
354
+ else:
355
+ folded, buf = [], []
356
+ for line in body:
357
+ if not line.strip():
358
+ folded.append(" ".join(buf))
359
+ buf = []
360
+ folded.append("")
361
+ elif line.startswith(" "): # more-indented lines keep their breaks
362
+ if buf:
363
+ folded.append(" ".join(buf))
364
+ buf = []
365
+ folded.append(line)
366
+ else:
367
+ buf.append(line.strip())
368
+ if buf:
369
+ folded.append(" ".join(buf))
370
+ text = "\n".join(folded)
371
+
372
+ if chomp == "-":
373
+ return text.rstrip("\n")
374
+ if chomp == "+":
375
+ return text + "\n"
376
+ return text.rstrip("\n") + "\n"