yan-notation 1.0.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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)
yan/parser.py ADDED
@@ -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,7 @@
1
+ yan/__init__.py,sha256=Z-qolGUAHC6A33Fl5neVovsNjyTanm6OQc816oxYzg8,453
2
+ yan/parser.py,sha256=wvVX5_pdVZfNoM6nfrlR8BmERywCNLLvVS9lv-zPoR0,10187
3
+ yan_notation-1.0.0.dist-info/METADATA,sha256=evb4IKvF72n0SLu_8Q-DyU9Ckhskn4j9BR45dIGg_8w,924
4
+ yan_notation-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ yan_notation-1.0.0.dist-info/entry_points.txt,sha256=777-7Q4I-7EM18JaGxppeakTrUgk10S5hmLIpMIn63Y,34
6
+ yan_notation-1.0.0.dist-info/top_level.txt,sha256=vDGQeuZvEYEHGMI6AvN4d9gg7iizzUH-AMIhfIw8lCQ,4
7
+ yan_notation-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ yan = yan:parse
@@ -0,0 +1 @@
1
+ yan