yan-notation 1.0.0__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.
- yan_notation-1.0.0/PKG-INFO +21 -0
- yan_notation-1.0.0/pyproject.toml +28 -0
- yan_notation-1.0.0/setup.cfg +4 -0
- yan_notation-1.0.0/src/yan/__init__.py +16 -0
- yan_notation-1.0.0/src/yan/parser.py +299 -0
- yan_notation-1.0.0/src/yan_notation.egg-info/PKG-INFO +21 -0
- yan_notation-1.0.0/src/yan_notation.egg-info/SOURCES.txt +9 -0
- yan_notation-1.0.0/src/yan_notation.egg-info/dependency_links.txt +1 -0
- yan_notation-1.0.0/src/yan_notation.egg-info/entry_points.txt +2 -0
- yan_notation-1.0.0/src/yan_notation.egg-info/top_level.txt +1 -0
- yan_notation-1.0.0/test/test.py +126 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yan-notation
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: YAN (Yet Another Notation) parser for Python
|
|
5
|
+
Author: yan-notation
|
|
6
|
+
License: CC0-1.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/yan-notation/yan-spec
|
|
8
|
+
Project-URL: Repository, https://github.com/yan-notation/yan-spec
|
|
9
|
+
Project-URL: Documentation, https://yan-notation.github.io/yan-spec
|
|
10
|
+
Keywords: yan,parser,config,serialization,notation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "yan-notation"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "YAN (Yet Another Notation) parser for Python"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "CC0-1.0"}
|
|
7
|
+
authors = [{name = "yan-notation"}]
|
|
8
|
+
keywords = ["yan", "parser", "config", "serialization", "notation"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Development Status :: 4 - Beta",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.8",
|
|
15
|
+
"Programming Language :: Python :: 3.9",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
]
|
|
20
|
+
requires-python = ">=3.8"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/yan-notation/yan-spec"
|
|
24
|
+
Repository = "https://github.com/yan-notation/yan-spec"
|
|
25
|
+
Documentation = "https://yan-notation.github.io/yan-spec"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
yan = "yan:parse"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""YAN (Yet Another Notation) parser for Python."""
|
|
2
|
+
|
|
3
|
+
from .parser import YANParser, YANParseError
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
__all__ = ["YANParser", "YANParseError", "parse", "stringify"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def parse(source: str) -> dict:
|
|
10
|
+
"""Parse a YAN source string into a Python dict."""
|
|
11
|
+
return YANParser().parse(source)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def stringify(obj: dict, **kwargs) -> str:
|
|
15
|
+
"""Convert a Python dict to a YAN string."""
|
|
16
|
+
return YANParser().stringify(obj, **kwargs)
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""YAN Parser v1.0 for Python."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, Dict, List, Tuple, Union
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class YANParseError(Exception):
|
|
8
|
+
"""Raised when a YAN document cannot be parsed."""
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class YANParser:
|
|
13
|
+
"""Parse YAN source strings into Python objects and back."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, strict: bool = False):
|
|
16
|
+
self.strict = strict
|
|
17
|
+
|
|
18
|
+
# ==================== PUBLIC API ====================
|
|
19
|
+
|
|
20
|
+
def parse(self, source: str) -> dict:
|
|
21
|
+
"""Parse a YAN source string into a Python dict."""
|
|
22
|
+
cleaned = self._preprocess(source)
|
|
23
|
+
lines = self._split_lines(cleaned)
|
|
24
|
+
result, _ = self._parse_block(lines, 0, -1)
|
|
25
|
+
return result
|
|
26
|
+
|
|
27
|
+
def stringify(self, obj: Any, indent: int = 2, level: int = 0, inline: bool = False) -> str:
|
|
28
|
+
"""Convert a Python object to a YAN string."""
|
|
29
|
+
prefix = " " * (level * indent)
|
|
30
|
+
|
|
31
|
+
if obj is None:
|
|
32
|
+
return "null"
|
|
33
|
+
|
|
34
|
+
if isinstance(obj, bool):
|
|
35
|
+
return "true" if obj else "false"
|
|
36
|
+
|
|
37
|
+
if isinstance(obj, int):
|
|
38
|
+
return str(obj)
|
|
39
|
+
|
|
40
|
+
if isinstance(obj, float):
|
|
41
|
+
s = str(obj)
|
|
42
|
+
return s if "." in s or "e" in s.lower() else s + ".0"
|
|
43
|
+
|
|
44
|
+
if isinstance(obj, str):
|
|
45
|
+
if re.search(r'[:;{}\[\]@"\n\r#\/\s]', obj) or obj.strip() != obj:
|
|
46
|
+
return '"' + obj.replace('"', '\\"') + '"'
|
|
47
|
+
return obj
|
|
48
|
+
|
|
49
|
+
if isinstance(obj, (list, tuple)):
|
|
50
|
+
return "; ".join(self.stringify(v, indent, 0, True) for v in obj)
|
|
51
|
+
|
|
52
|
+
if isinstance(obj, dict):
|
|
53
|
+
if not obj:
|
|
54
|
+
return "{}"
|
|
55
|
+
|
|
56
|
+
entries = list(obj.items())
|
|
57
|
+
if inline or len(entries) <= 2:
|
|
58
|
+
pairs = "; ".join(
|
|
59
|
+
f"{k}: {self.stringify(v, indent, 0, True)}"
|
|
60
|
+
for k, v in entries
|
|
61
|
+
)
|
|
62
|
+
return "{" + pairs + "}"
|
|
63
|
+
|
|
64
|
+
lines = []
|
|
65
|
+
for key, value in obj.items():
|
|
66
|
+
if isinstance(value, dict) and value is not None:
|
|
67
|
+
sub = self.stringify(value, indent, level + 1)
|
|
68
|
+
if sub.startswith("{"):
|
|
69
|
+
lines.append(f"{prefix}{key}: {sub}")
|
|
70
|
+
else:
|
|
71
|
+
lines.append(f"{prefix}{key}:")
|
|
72
|
+
lines.append(sub)
|
|
73
|
+
else:
|
|
74
|
+
lines.append(f"{prefix}{key}: {self.stringify(value, indent, 0, True)}")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
return str(obj)
|
|
78
|
+
|
|
79
|
+
# ==================== INTERNAL METHODS ====================
|
|
80
|
+
|
|
81
|
+
def _preprocess(self, source: str) -> str:
|
|
82
|
+
text = source.replace("\r\n", "\n").replace("\r", "\n")
|
|
83
|
+
# Remove block comments
|
|
84
|
+
text = re.sub(r'/\*.*?\*/', '', text, flags=re.DOTALL)
|
|
85
|
+
# Remove line comments
|
|
86
|
+
text = re.sub(r'#.*$', '', text, flags=re.MULTILINE)
|
|
87
|
+
return text
|
|
88
|
+
|
|
89
|
+
def _split_lines(self, text: str) -> List[dict]:
|
|
90
|
+
lines = []
|
|
91
|
+
for i, raw in enumerate(text.split("\n"), start=1):
|
|
92
|
+
normalized = raw.replace("\t", " ")
|
|
93
|
+
stripped = normalized.rstrip()
|
|
94
|
+
if not stripped:
|
|
95
|
+
continue
|
|
96
|
+
indent = len(normalized) - len(normalized.lstrip())
|
|
97
|
+
content = stripped.lstrip()
|
|
98
|
+
lines.append({"line": i, "indent": indent, "content": content})
|
|
99
|
+
return lines
|
|
100
|
+
|
|
101
|
+
def _parse_block(self, lines: List[dict], start: int, base_indent: int) -> Tuple[dict, int]:
|
|
102
|
+
result = {}
|
|
103
|
+
i = start
|
|
104
|
+
|
|
105
|
+
while i < len(lines):
|
|
106
|
+
line = lines[i]
|
|
107
|
+
|
|
108
|
+
if line["indent"] <= base_indent:
|
|
109
|
+
break
|
|
110
|
+
|
|
111
|
+
colon_idx = line["content"].find(":")
|
|
112
|
+
if colon_idx == -1:
|
|
113
|
+
raise YANParseError(
|
|
114
|
+
f"Expected ':' on line {line['line']}: '{line['content']}'"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
key = line["content"][:colon_idx].strip()
|
|
118
|
+
raw_value = line["content"][colon_idx + 1:].strip()
|
|
119
|
+
|
|
120
|
+
# Inline object
|
|
121
|
+
if raw_value.startswith("{"):
|
|
122
|
+
value, i = self._parse_inline_object(lines, i, raw_value)
|
|
123
|
+
result[key] = value
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
# Check if next line is indented more (block value)
|
|
127
|
+
next_line = lines[i + 1] if i + 1 < len(lines) else None
|
|
128
|
+
if next_line and next_line["indent"] > line["indent"] and not raw_value:
|
|
129
|
+
block_value, i = self._parse_block(lines, i + 1, line["indent"])
|
|
130
|
+
result[key] = block_value
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
result[key] = self._parse_value(raw_value, line["line"])
|
|
134
|
+
i += 1
|
|
135
|
+
|
|
136
|
+
return result, i
|
|
137
|
+
|
|
138
|
+
def _parse_inline_object(self, lines: List[dict], start: int, raw_value: str) -> Tuple[dict, int]:
|
|
139
|
+
brace_count = 0
|
|
140
|
+
content = ""
|
|
141
|
+
i = start
|
|
142
|
+
|
|
143
|
+
while i < len(lines):
|
|
144
|
+
line = lines[i]
|
|
145
|
+
text = raw_value if i == start else line["content"]
|
|
146
|
+
|
|
147
|
+
for k, ch in enumerate(text):
|
|
148
|
+
if ch == "{":
|
|
149
|
+
brace_count += 1
|
|
150
|
+
elif ch == "}":
|
|
151
|
+
brace_count -= 1
|
|
152
|
+
if brace_count == 0:
|
|
153
|
+
content += text[:k]
|
|
154
|
+
obj = self._parse_inline_pairs(content[1:]) # skip first {
|
|
155
|
+
return obj, i + 1
|
|
156
|
+
|
|
157
|
+
content += text + " "
|
|
158
|
+
i += 1
|
|
159
|
+
|
|
160
|
+
raise YANParseError(f"Unclosed '{{' starting near line {lines[start]['line']}")
|
|
161
|
+
|
|
162
|
+
def _parse_inline_pairs(self, text: str) -> dict:
|
|
163
|
+
result = {}
|
|
164
|
+
pairs = self._smart_split(text, re.compile(r'[,;]'))
|
|
165
|
+
|
|
166
|
+
for pair in pairs:
|
|
167
|
+
trimmed = pair.strip()
|
|
168
|
+
if not trimmed:
|
|
169
|
+
continue
|
|
170
|
+
# Find first colon that's not inside a URL or other value
|
|
171
|
+
colon_idx = self._find_key_colon(trimmed)
|
|
172
|
+
if colon_idx == -1:
|
|
173
|
+
continue
|
|
174
|
+
key = trimmed[:colon_idx].strip()
|
|
175
|
+
value = trimmed[colon_idx + 1:].strip()
|
|
176
|
+
result[key] = self._parse_value(value, 0)
|
|
177
|
+
|
|
178
|
+
return result
|
|
179
|
+
|
|
180
|
+
def _find_key_colon(self, text: str) -> int:
|
|
181
|
+
"""Find the colon that separates key from value."""
|
|
182
|
+
in_quotes = False
|
|
183
|
+
for i, ch in enumerate(text):
|
|
184
|
+
if ch == '"':
|
|
185
|
+
in_quotes = not in_quotes
|
|
186
|
+
elif ch == ':' and not in_quotes:
|
|
187
|
+
return i
|
|
188
|
+
return -1
|
|
189
|
+
|
|
190
|
+
def _smart_split(self, text: str, delimiter: re.Pattern) -> List[str]:
|
|
191
|
+
parts = []
|
|
192
|
+
current = ""
|
|
193
|
+
in_quotes = False
|
|
194
|
+
brace_depth = 0
|
|
195
|
+
|
|
196
|
+
for ch in text:
|
|
197
|
+
if ch == '"' and not in_quotes:
|
|
198
|
+
in_quotes = True
|
|
199
|
+
current += ch
|
|
200
|
+
elif ch == '"' and in_quotes:
|
|
201
|
+
in_quotes = False
|
|
202
|
+
current += ch
|
|
203
|
+
elif ch == "{" and not in_quotes:
|
|
204
|
+
brace_depth += 1
|
|
205
|
+
current += ch
|
|
206
|
+
elif ch == "}" and not in_quotes:
|
|
207
|
+
brace_depth -= 1
|
|
208
|
+
current += ch
|
|
209
|
+
elif delimiter.match(ch) and not in_quotes and brace_depth == 0:
|
|
210
|
+
parts.append(current)
|
|
211
|
+
current = ""
|
|
212
|
+
else:
|
|
213
|
+
current += ch
|
|
214
|
+
|
|
215
|
+
if current.strip():
|
|
216
|
+
parts.append(current)
|
|
217
|
+
return parts
|
|
218
|
+
|
|
219
|
+
def _parse_value(self, raw: str, line_num: int) -> Any:
|
|
220
|
+
value = raw.strip()
|
|
221
|
+
if not value:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# Type hint
|
|
225
|
+
if value.startswith("@"):
|
|
226
|
+
return self._parse_type_hint(value, line_num)
|
|
227
|
+
|
|
228
|
+
# Array
|
|
229
|
+
if self._is_array(value):
|
|
230
|
+
return self._parse_array(value, line_num)
|
|
231
|
+
|
|
232
|
+
# Inline object
|
|
233
|
+
if value.startswith("{"):
|
|
234
|
+
return self._parse_inline_pairs(value)
|
|
235
|
+
|
|
236
|
+
# Quoted string
|
|
237
|
+
if value.startswith('"'):
|
|
238
|
+
return self._parse_string(value, line_num)
|
|
239
|
+
|
|
240
|
+
# Boolean
|
|
241
|
+
lower = value.lower()
|
|
242
|
+
if lower in ("true", "yes", "on"):
|
|
243
|
+
return True
|
|
244
|
+
if lower in ("false", "no", "off"):
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
# Null
|
|
248
|
+
if lower in ("null", "nil", "_", "~"):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
# Number
|
|
252
|
+
if re.match(r'^-?\d+(\.\d+)?([eE][+-]?\d+)?$', value):
|
|
253
|
+
return int(value) if "." not in value and "e" not in value.lower() else float(value)
|
|
254
|
+
|
|
255
|
+
# Unquoted string
|
|
256
|
+
return value
|
|
257
|
+
|
|
258
|
+
def _is_array(self, text: str) -> bool:
|
|
259
|
+
in_quotes = False
|
|
260
|
+
for ch in text:
|
|
261
|
+
if ch == '"':
|
|
262
|
+
in_quotes = not in_quotes
|
|
263
|
+
elif ch == ";" and not in_quotes:
|
|
264
|
+
return True
|
|
265
|
+
return False
|
|
266
|
+
|
|
267
|
+
def _parse_array(self, text: str, line_num: int) -> List[Any]:
|
|
268
|
+
items = self._smart_split(text, re.compile(r';'))
|
|
269
|
+
return [self._parse_value(item.strip(), line_num) for item in items if item.strip()]
|
|
270
|
+
|
|
271
|
+
def _parse_string(self, text: str, line_num: int) -> str:
|
|
272
|
+
if not text.startswith('"'):
|
|
273
|
+
return text
|
|
274
|
+
if not text.endswith('"'):
|
|
275
|
+
raise YANParseError(f"Unclosed string on line {line_num}: {text}")
|
|
276
|
+
return text[1:-1].replace('\\"', '"')
|
|
277
|
+
|
|
278
|
+
def _parse_type_hint(self, text: str, line_num: int) -> Any:
|
|
279
|
+
space_idx = text.find(" ")
|
|
280
|
+
type_name = text[1:space_idx] if space_idx != -1 else text[1:]
|
|
281
|
+
raw_value = text[space_idx + 1:] if space_idx != -1 else ""
|
|
282
|
+
|
|
283
|
+
handlers = {
|
|
284
|
+
"int": lambda v: int(v),
|
|
285
|
+
"float": lambda v: float(v),
|
|
286
|
+
"date": lambda v: {"__type": "date", "__value": v},
|
|
287
|
+
"datetime": lambda v: {"__type": "datetime", "__value": v},
|
|
288
|
+
"hex": lambda v: {"__type": "hex", "__value": v},
|
|
289
|
+
"base64": lambda v: {"__type": "base64", "__value": v},
|
|
290
|
+
"uuid": lambda v: {"__type": "uuid", "__value": v},
|
|
291
|
+
"url": lambda v: {"__type": "url", "__value": v},
|
|
292
|
+
"regex": lambda v: {"__type": "regex", "__value": v},
|
|
293
|
+
"bool": lambda v: v.lower() in ("true", "yes", "on", "1"),
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if type_name in handlers:
|
|
297
|
+
return handlers[type_name](raw_value)
|
|
298
|
+
|
|
299
|
+
return {"__type": type_name, "__value": self._parse_value(raw_value, line_num)}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: yan-notation
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: YAN (Yet Another Notation) parser for Python
|
|
5
|
+
Author: yan-notation
|
|
6
|
+
License: CC0-1.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/yan-notation/yan-spec
|
|
8
|
+
Project-URL: Repository, https://github.com/yan-notation/yan-spec
|
|
9
|
+
Project-URL: Documentation, https://yan-notation.github.io/yan-spec
|
|
10
|
+
Keywords: yan,parser,config,serialization,notation
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.8
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/yan/__init__.py
|
|
3
|
+
src/yan/parser.py
|
|
4
|
+
src/yan_notation.egg-info/PKG-INFO
|
|
5
|
+
src/yan_notation.egg-info/SOURCES.txt
|
|
6
|
+
src/yan_notation.egg-info/dependency_links.txt
|
|
7
|
+
src/yan_notation.egg-info/entry_points.txt
|
|
8
|
+
src/yan_notation.egg-info/top_level.txt
|
|
9
|
+
test/test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
yan
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Tests for YAN Python parser."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../src"))
|
|
6
|
+
|
|
7
|
+
from yan import parse, stringify, YANParser, YANParseError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test(name, actual, expected):
|
|
11
|
+
a = str(actual)
|
|
12
|
+
b = str(expected)
|
|
13
|
+
if a == b:
|
|
14
|
+
print(f" ✓ {name}")
|
|
15
|
+
return True
|
|
16
|
+
else:
|
|
17
|
+
print(f" ✗ {name}")
|
|
18
|
+
print(f" Expected: {b}")
|
|
19
|
+
print(f" Got: {a}")
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
passed = 0
|
|
24
|
+
failed = 0
|
|
25
|
+
|
|
26
|
+
print("Running YAN Python Parser Tests...\n")
|
|
27
|
+
|
|
28
|
+
# Primitives
|
|
29
|
+
if test("primitive string", parse("name: Budi"), {"name": "Budi"}): passed += 1
|
|
30
|
+
else: failed += 1
|
|
31
|
+
|
|
32
|
+
if test("primitive number", parse("age: 25"), {"age": 25}): passed += 1
|
|
33
|
+
else: failed += 1
|
|
34
|
+
|
|
35
|
+
if test("primitive float", parse("pi: 3.14"), {"pi": 3.14}): passed += 1
|
|
36
|
+
else: failed += 1
|
|
37
|
+
|
|
38
|
+
if test("primitive boolean true", parse("active: true"), {"active": True}): passed += 1
|
|
39
|
+
else: failed += 1
|
|
40
|
+
|
|
41
|
+
if test("primitive boolean yes", parse("active: yes"), {"active": True}): passed += 1
|
|
42
|
+
else: failed += 1
|
|
43
|
+
|
|
44
|
+
if test("primitive boolean off", parse("debug: off"), {"debug": False}): passed += 1
|
|
45
|
+
else: failed += 1
|
|
46
|
+
|
|
47
|
+
if test("primitive null", parse("data: null"), {"data": None}): passed += 1
|
|
48
|
+
else: failed += 1
|
|
49
|
+
|
|
50
|
+
if test("primitive null underscore", parse("data: _"), {"data": None}): passed += 1
|
|
51
|
+
else: failed += 1
|
|
52
|
+
|
|
53
|
+
if test("quoted string", parse('msg: "hello world"'), {"msg": "hello world"}): passed += 1
|
|
54
|
+
else: failed += 1
|
|
55
|
+
|
|
56
|
+
# Array
|
|
57
|
+
if test("array semicolon", parse("tags: a; b; c"), {"tags": ["a", "b", "c"]}): passed += 1
|
|
58
|
+
else: failed += 1
|
|
59
|
+
|
|
60
|
+
# Inline object
|
|
61
|
+
if test("inline object", parse("cfg: {host: localhost; port: 80}"), {"cfg": {"host": "localhost", "port": 80}}): passed += 1
|
|
62
|
+
else: failed += 1
|
|
63
|
+
|
|
64
|
+
# Block object
|
|
65
|
+
if test("block object", parse("person:\n name: Budi\n age: 25"), {"person": {"name": "Budi", "age": 25}}): passed += 1
|
|
66
|
+
else: failed += 1
|
|
67
|
+
|
|
68
|
+
# Nested block
|
|
69
|
+
if test("nested block", parse("a:\n b:\n c: 1"), {"a": {"b": {"c": 1}}}): passed += 1
|
|
70
|
+
else: failed += 1
|
|
71
|
+
|
|
72
|
+
# Comments
|
|
73
|
+
if test("line comment", parse("# comment\nname: Budi"), {"name": "Budi"}): passed += 1
|
|
74
|
+
else: failed += 1
|
|
75
|
+
|
|
76
|
+
if test("block comment", parse("/* multi\nline */\nname: Budi"), {"name": "Budi"}): passed += 1
|
|
77
|
+
else: failed += 1
|
|
78
|
+
|
|
79
|
+
# Type hints
|
|
80
|
+
if test("type hint @int", parse("n: @int 42"), {"n": 42}): passed += 1
|
|
81
|
+
else: failed += 1
|
|
82
|
+
|
|
83
|
+
if test("type hint @float", parse("n: @float 3.14"), {"n": 3.14}): passed += 1
|
|
84
|
+
else: failed += 1
|
|
85
|
+
|
|
86
|
+
if test("type hint @bool", parse("b: @bool yes"), {"b": True}): passed += 1
|
|
87
|
+
else: failed += 1
|
|
88
|
+
|
|
89
|
+
# Trailing separators
|
|
90
|
+
if test("trailing semicolon array", parse("arr: a; b;"), {"arr": ["a", "b"]}): passed += 1
|
|
91
|
+
else: failed += 1
|
|
92
|
+
|
|
93
|
+
if test("trailing semicolon inline", parse("obj: {a: 1; b: 2;}"), {"obj": {"a": 1, "b": 2}}): passed += 1
|
|
94
|
+
else: failed += 1
|
|
95
|
+
|
|
96
|
+
# Full document
|
|
97
|
+
full_doc = '''
|
|
98
|
+
app:
|
|
99
|
+
name: "Super App"
|
|
100
|
+
version: 1.2.0
|
|
101
|
+
debug: off
|
|
102
|
+
|
|
103
|
+
database:
|
|
104
|
+
host: localhost
|
|
105
|
+
port: 5432
|
|
106
|
+
ssl: yes
|
|
107
|
+
pool: {min: 5; max: 20}
|
|
108
|
+
'''
|
|
109
|
+
full_result = parse(full_doc)
|
|
110
|
+
full_ok = (
|
|
111
|
+
full_result["app"]["name"] == "Super App" and
|
|
112
|
+
full_result["app"]["debug"] == False and
|
|
113
|
+
full_result["database"]["pool"] == {"min": 5, "max": 20}
|
|
114
|
+
)
|
|
115
|
+
if test("full document", full_ok, True): passed += 1
|
|
116
|
+
else: failed += 1
|
|
117
|
+
|
|
118
|
+
# Stringify roundtrip
|
|
119
|
+
obj = {"name": "Budi", "age": 25, "active": True}
|
|
120
|
+
yan_str = stringify(obj)
|
|
121
|
+
back = parse(yan_str)
|
|
122
|
+
if test("stringify roundtrip", back, obj): passed += 1
|
|
123
|
+
else: failed += 1
|
|
124
|
+
|
|
125
|
+
print(f"\n{passed} passed, {failed} failed")
|
|
126
|
+
sys.exit(1 if failed > 0 else 0)
|