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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ yan = yan:parse
@@ -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)