syml 0.5__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.
- syml/__init__.py +16 -0
- syml/basetypes.py +117 -0
- syml/exceptions.py +9 -0
- syml/grammars.py +47 -0
- syml/nodes.py +298 -0
- syml/parsers.py +134 -0
- syml/utils.py +17 -0
- syml-0.5.dist-info/LICENSE +21 -0
- syml-0.5.dist-info/METADATA +69 -0
- syml-0.5.dist-info/RECORD +11 -0
- syml-0.5.dist-info/WHEEL +4 -0
syml/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""SYML (Simple YAML-like Markup Language) is a simple markup language with similar structure to YAML, but without all the gewgaws and folderol."""
|
|
2
|
+
|
|
3
|
+
from io import TextIOBase
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import parsers
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def loads(document: str, **kwargs: Any) -> Any: # noqa: ANN401
|
|
10
|
+
"""Load a SYML document from a string."""
|
|
11
|
+
return parsers.parse(document, **kwargs)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load(file_obj: TextIOBase, **kwargs: Any) -> Any: # noqa: ANN401
|
|
15
|
+
"""Load a SYML document from a file-like object."""
|
|
16
|
+
return loads(file_obj.read(), **kwargs)
|
syml/basetypes.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""SYML base types"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from attrs import define
|
|
10
|
+
|
|
11
|
+
from syml import utils
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
14
|
+
from parsimonious.nodes import Node as PNode
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
StrPath = str | Path
|
|
18
|
+
|
|
19
|
+
StrBool = str | bool
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@define(slots=True, frozen=True)
|
|
23
|
+
class Pos:
|
|
24
|
+
"""A position within a source file"""
|
|
25
|
+
|
|
26
|
+
index: int
|
|
27
|
+
line: int
|
|
28
|
+
column: int
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def from_str_index(cls, text: str, index: int) -> Pos:
|
|
32
|
+
"""Get (line_number, col) of `index` in `string`.
|
|
33
|
+
|
|
34
|
+
Based on http://stackoverflow.com/a/24495900
|
|
35
|
+
"""
|
|
36
|
+
lines = utils.split_lines(text, keepends=True)
|
|
37
|
+
curr_pos = 0
|
|
38
|
+
for linenum, line in enumerate(lines):
|
|
39
|
+
if curr_pos + len(line) > index:
|
|
40
|
+
return cls(index, linenum + 1, index - curr_pos)
|
|
41
|
+
curr_pos += len(line)
|
|
42
|
+
return cls(len(text), linenum + 1, 0)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@define(slots=True, repr=False, frozen=True)
|
|
46
|
+
class Source:
|
|
47
|
+
"""A line within a source file"""
|
|
48
|
+
|
|
49
|
+
filename: str | Path
|
|
50
|
+
start: Pos
|
|
51
|
+
end: Pos
|
|
52
|
+
text: str
|
|
53
|
+
# value corresponds to a line of text
|
|
54
|
+
value: StrBool
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_node(cls, pnode: PNode, filename: StrPath = '', value: StrBool | None = None) -> Source:
|
|
58
|
+
"""Build a Source from the given PNode, filename, and line value."""
|
|
59
|
+
return cls(
|
|
60
|
+
filename=filename,
|
|
61
|
+
start=Pos.from_str_index(pnode.full_text, pnode.start),
|
|
62
|
+
end=Pos.from_str_index(pnode.full_text, pnode.end),
|
|
63
|
+
text=pnode.text,
|
|
64
|
+
value=value if value is not None else pnode.text,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_text(
|
|
69
|
+
cls,
|
|
70
|
+
text: str,
|
|
71
|
+
substring: str | None = None,
|
|
72
|
+
source_text: str | None = None,
|
|
73
|
+
value: bool | str | None = None, # noqa: FBT001
|
|
74
|
+
filename: StrPath = '',
|
|
75
|
+
) -> Source:
|
|
76
|
+
"""Build a Source from the given components."""
|
|
77
|
+
if substring is None:
|
|
78
|
+
substring = text
|
|
79
|
+
match = re.search(substring, text)
|
|
80
|
+
if match is None:
|
|
81
|
+
raise ValueError('No match found', substring)
|
|
82
|
+
source_text = match.group() if source_text is None else source_text
|
|
83
|
+
return Source(
|
|
84
|
+
filename=filename,
|
|
85
|
+
start=Pos.from_str_index(text, match.start()),
|
|
86
|
+
end=Pos.from_str_index(text, match.end()),
|
|
87
|
+
text=source_text,
|
|
88
|
+
value=value if value is not None else source_text,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def __repr__(self) -> str:
|
|
92
|
+
filename = f'{self.filename}, ' if self.filename else ''
|
|
93
|
+
return f'{filename}Line {self.start.line}, Column {self.start.column} (index {self.start.index}): {self.text!r} ({self.value!r})'
|
|
94
|
+
|
|
95
|
+
def __str__(self) -> str:
|
|
96
|
+
return self.text
|
|
97
|
+
|
|
98
|
+
def __add__(self, other: SourceStr) -> Source:
|
|
99
|
+
if isinstance(other, str):
|
|
100
|
+
lines = utils.split_lines(other, keepends=True)
|
|
101
|
+
return Source(
|
|
102
|
+
filename=self.filename,
|
|
103
|
+
start=self.start,
|
|
104
|
+
end=Pos(index=self.end.index + len(other), line=self.end.line + len(lines), column=len(lines[-1])),
|
|
105
|
+
text=self.text + other,
|
|
106
|
+
value=str(self.value) + other,
|
|
107
|
+
)
|
|
108
|
+
if isinstance(other, Source):
|
|
109
|
+
return self + other.text
|
|
110
|
+
|
|
111
|
+
raise TypeError('Tried to add invalid type to Source', type(other))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
SourceStr = Source | str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
SourceStrBool = SourceStr | bool
|
syml/exceptions.py
ADDED
syml/grammars.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Parsimonious grammars for parsing SYML"""
|
|
2
|
+
|
|
3
|
+
import textwrap
|
|
4
|
+
|
|
5
|
+
syml_base_grammar = textwrap.dedent(
|
|
6
|
+
"""
|
|
7
|
+
lines = line*
|
|
8
|
+
line = indent (comment / blank / structure / value) &eol
|
|
9
|
+
structure = list_item / key_value / section
|
|
10
|
+
|
|
11
|
+
indent = ~"\\s*"
|
|
12
|
+
|
|
13
|
+
blank = &eol
|
|
14
|
+
comment = ~"(#|//+)+" text?
|
|
15
|
+
|
|
16
|
+
list_item = "-" ws value
|
|
17
|
+
|
|
18
|
+
key_value = section ws data
|
|
19
|
+
section = key ":"
|
|
20
|
+
key = ~"[^\\s:]+"
|
|
21
|
+
|
|
22
|
+
eol = "\\n" / ~"$"
|
|
23
|
+
ws = ~"[ \\t]+"
|
|
24
|
+
text = ~".+"
|
|
25
|
+
|
|
26
|
+
value = structure / data
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
text_only_syml_grammar = syml_base_grammar + textwrap.dedent(
|
|
33
|
+
"""
|
|
34
|
+
data = text
|
|
35
|
+
"""
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# If we want mare YAML-like booleans:
|
|
39
|
+
boolean_syml_grammar = syml_base_grammar + textwrap.dedent(
|
|
40
|
+
"""
|
|
41
|
+
truthy = "true" / "True" / "TRUE"
|
|
42
|
+
falsey = "false" / "False" / "FALSE"
|
|
43
|
+
bool = truthy / falsey
|
|
44
|
+
|
|
45
|
+
data = bool / text
|
|
46
|
+
"""
|
|
47
|
+
)
|
syml/nodes.py
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Node implementations for parsing SYML documents"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import OrderedDict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
10
|
+
from parsimonious.nodes import Node as PNode
|
|
11
|
+
|
|
12
|
+
from .basetypes import Pos, Source, SourceStrBool, StrBool, StrPath
|
|
13
|
+
from .exceptions import OutOfContextNodeError
|
|
14
|
+
from .utils import get_line
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(kw_only=True)
|
|
18
|
+
class SymlNode:
|
|
19
|
+
"""A generic node in a SYML document."""
|
|
20
|
+
|
|
21
|
+
pnode: PNode
|
|
22
|
+
level: int | None = field(default=None)
|
|
23
|
+
parent: SymlNode | None = field(default=None)
|
|
24
|
+
comments: list[Comment] = field(default_factory=list)
|
|
25
|
+
children: list[SymlNode] | tuple[SymlNode] = field(default_factory=list)
|
|
26
|
+
value: Any = field(default=None)
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if self.level is not None:
|
|
30
|
+
self.set_level(self.level)
|
|
31
|
+
|
|
32
|
+
def set_level(self, level: int) -> None:
|
|
33
|
+
"""Set this node's level."""
|
|
34
|
+
self.level = level
|
|
35
|
+
|
|
36
|
+
def as_data(self, filename: StrPath = '', raw: bool = False) -> Any: # noqa: FBT001, FBT002, ANN401 # pragma: nocover
|
|
37
|
+
"""Return this node as primitive data types."""
|
|
38
|
+
raise NotImplementedError
|
|
39
|
+
|
|
40
|
+
def get_value(self) -> Any: # noqa: ANN401 # pragma: nocover
|
|
41
|
+
"""Return the value of this node."""
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
def get_tip(self) -> SymlNode:
|
|
45
|
+
"""Return the tip of this branch."""
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def can_add_node(self, node: SymlNode) -> bool: # noqa: ARG002 # pragma: nocover
|
|
49
|
+
"""Check if this node can add a child node."""
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
def add_node(self, node: SymlNode) -> SymlNode: # pragma: nocover
|
|
53
|
+
"""Add a child node."""
|
|
54
|
+
raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def incorporate_node(self, node: SymlNode) -> SymlNode:
|
|
57
|
+
"""Incorporate the given node into the tree somewhere nearby."""
|
|
58
|
+
if self.can_add_node(node):
|
|
59
|
+
return self.add_node(node)
|
|
60
|
+
if self.parent is not None:
|
|
61
|
+
return self.parent.incorporate_node(node)
|
|
62
|
+
else: # pragma: nocover # noqa: RET505
|
|
63
|
+
# Shouldn't ever get here:
|
|
64
|
+
self.fail_to_incorporate_node(node)
|
|
65
|
+
return self # pragma: nocover
|
|
66
|
+
|
|
67
|
+
def fail_to_incorporate_node(self, node: SymlNode) -> None:
|
|
68
|
+
"""Report a failure to incorporate a node."""
|
|
69
|
+
pnode = node.pnode
|
|
70
|
+
pos = Pos.from_str_index(pnode.full_text, pnode.start)
|
|
71
|
+
line = get_line(pnode.full_text, pos.line)
|
|
72
|
+
raise OutOfContextNodeError('Failed to incorporate a node', pos, line)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
SymlNodes = list[SymlNode]
|
|
76
|
+
OptionalSymlNodes = list[SymlNode | None]
|
|
77
|
+
|
|
78
|
+
NodeOrNodes = SymlNodes | SymlNode | StrBool
|
|
79
|
+
|
|
80
|
+
OptionalNodes = NodeOrNodes | None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(kw_only=True)
|
|
84
|
+
class ContainerNode(SymlNode):
|
|
85
|
+
"""A container node that may contain a child value."""
|
|
86
|
+
|
|
87
|
+
value: SymlNode | StrBool | None = field(default=None)
|
|
88
|
+
|
|
89
|
+
def set_level(self, level: int) -> None:
|
|
90
|
+
"""Set the level on this node and its child value."""
|
|
91
|
+
self.level = level
|
|
92
|
+
if isinstance(self.value, SymlNode):
|
|
93
|
+
self.value.set_level(level)
|
|
94
|
+
|
|
95
|
+
def as_data(self, filename: StrPath = '', raw: bool = False) -> StrBool | None: # noqa: FBT001, FBT002
|
|
96
|
+
"""Return the container as primitive data types."""
|
|
97
|
+
if isinstance(self.value, SymlNode):
|
|
98
|
+
return self.value.as_data(filename, raw=raw)
|
|
99
|
+
else: # pragma: nocover # noqa: RET505
|
|
100
|
+
# Shouldn't ever get here.
|
|
101
|
+
return self.value
|
|
102
|
+
|
|
103
|
+
def get_value(self) -> Any: # noqa: ANN401 # pragma: nocover
|
|
104
|
+
"""Return the value of this node."""
|
|
105
|
+
return self.value
|
|
106
|
+
|
|
107
|
+
def get_tip(self) -> SymlNode:
|
|
108
|
+
"""Get the tip of this branch."""
|
|
109
|
+
if self.value is not None and isinstance(self.value, SymlNode):
|
|
110
|
+
return self.value.get_tip()
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def incorporate_node(self, node: SymlNode) -> SymlNode:
|
|
114
|
+
"""Incorporate the given node into this branch."""
|
|
115
|
+
if self.can_add_node(node):
|
|
116
|
+
intermediary: SymlNode | None = None
|
|
117
|
+
if isinstance(node, KeyValue):
|
|
118
|
+
intermediary = Mapping(pnode=node.pnode, level=self.level)
|
|
119
|
+
elif isinstance(node, ListItem):
|
|
120
|
+
intermediary = List(pnode=node.pnode, level=self.level)
|
|
121
|
+
|
|
122
|
+
if intermediary is not None:
|
|
123
|
+
intermediary.set_level(node.level) # type: ignore[arg-type]
|
|
124
|
+
intermediary = self.incorporate_node(intermediary)
|
|
125
|
+
return intermediary.incorporate_node(node)
|
|
126
|
+
return super().incorporate_node(node)
|
|
127
|
+
if self.parent is not None:
|
|
128
|
+
return self.parent.incorporate_node(node)
|
|
129
|
+
else: # pragma: nocover # noqa: RET505
|
|
130
|
+
self.fail_to_incorporate_node(node)
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
134
|
+
"""Check if this container can add a child node."""
|
|
135
|
+
return self.value is None and (node.level is None or (self.level is not None and node.level > self.level))
|
|
136
|
+
|
|
137
|
+
def add_node(self, node: SymlNode) -> SymlNode:
|
|
138
|
+
"""Add a child node to this container."""
|
|
139
|
+
self.value = node
|
|
140
|
+
node.parent = self
|
|
141
|
+
if node.level is None:
|
|
142
|
+
node.set_level(self.level) # type: ignore[arg-type]
|
|
143
|
+
return node.get_tip()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(kw_only=True)
|
|
147
|
+
class ParentNode(SymlNode):
|
|
148
|
+
"""A parent node that con have multiple children"""
|
|
149
|
+
|
|
150
|
+
children: list[SymlNode] = field(default_factory=list)
|
|
151
|
+
|
|
152
|
+
def __post_init__(self) -> None:
|
|
153
|
+
super().__post_init__()
|
|
154
|
+
self.children = list(self.children)
|
|
155
|
+
|
|
156
|
+
def set_level(self, level: int) -> None:
|
|
157
|
+
"""Set the level of this node and its children."""
|
|
158
|
+
self.level = level
|
|
159
|
+
for child in self.children:
|
|
160
|
+
if isinstance(child, SymlNode): # pragma: nobranch
|
|
161
|
+
child.set_level(level)
|
|
162
|
+
|
|
163
|
+
def get_tip(self) -> SymlNode:
|
|
164
|
+
"""Return the tip of this branch."""
|
|
165
|
+
if self.children and isinstance(self.children[-1], SymlNode):
|
|
166
|
+
return self.children[-1].get_tip()
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
170
|
+
"""Check if a child node can be added."""
|
|
171
|
+
return node.level is None or (self.level is not None and node.level >= self.level)
|
|
172
|
+
|
|
173
|
+
def add_node(self, node: SymlNode) -> SymlNode:
|
|
174
|
+
"""Add a child node."""
|
|
175
|
+
self.children.append(node)
|
|
176
|
+
node.parent = self
|
|
177
|
+
if node.level is None:
|
|
178
|
+
node.set_level(self.level) # type: ignore[arg-type]
|
|
179
|
+
return node.get_tip()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@dataclass(kw_only=True)
|
|
183
|
+
class Root(ContainerNode):
|
|
184
|
+
"""A root container node for a SYML document"""
|
|
185
|
+
|
|
186
|
+
value: SymlNode | None = field(default=None)
|
|
187
|
+
level: int = field(default=0)
|
|
188
|
+
|
|
189
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
190
|
+
"""Check if a child node may be added."""
|
|
191
|
+
return self.value is None and (node.level is None or (self.level is not None and node.level >= self.level))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Comment(ContainerNode):
|
|
195
|
+
"""A comment node"""
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class List(ParentNode):
|
|
199
|
+
"""A list node"""
|
|
200
|
+
|
|
201
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
202
|
+
"""Check if a child node can be added."""
|
|
203
|
+
return super().can_add_node(node) and isinstance(node, ListItem)
|
|
204
|
+
|
|
205
|
+
def as_data(self, filename: StrPath = '', raw: bool = False) -> list[StrBool]: # noqa: FBT001, FBT002
|
|
206
|
+
"""Return this node as primitive data types."""
|
|
207
|
+
return [c.as_data(filename, raw=raw) for c in self.children]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ListItem(ContainerNode):
|
|
211
|
+
"""A list item within a list."""
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Mapping(ParentNode):
|
|
215
|
+
"""A mapping of keys to values"""
|
|
216
|
+
|
|
217
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
218
|
+
"""Check if a child node may be added."""
|
|
219
|
+
return super().can_add_node(node) and isinstance(node, KeyValue)
|
|
220
|
+
|
|
221
|
+
def as_data(self, filename: StrPath = '', raw: bool = False) -> OrderedDict[StrBool, StrBool]: # noqa: FBT001, FBT002
|
|
222
|
+
"""Return this node as primitive data types."""
|
|
223
|
+
return OrderedDict([(c.key.as_data(filename, raw=raw), c.as_data(filename, raw=raw)) for c in self.children]) # type: ignore[attr-defined]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(kw_only=True)
|
|
227
|
+
class KeyValue(ContainerNode):
|
|
228
|
+
"""A key-value item within a mapping"""
|
|
229
|
+
|
|
230
|
+
key: SymlNode | StrBool | None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(kw_only=True)
|
|
234
|
+
class LeafNode(SymlNode):
|
|
235
|
+
"""The tip of a branch"""
|
|
236
|
+
|
|
237
|
+
source_text: Source
|
|
238
|
+
value: StrBool | None = field(default=None)
|
|
239
|
+
|
|
240
|
+
def __post_init__(self) -> None:
|
|
241
|
+
super().__post_init__()
|
|
242
|
+
if self.value is not None:
|
|
243
|
+
self.value = [(self.pnode, self.value)] # type: ignore[assignment]
|
|
244
|
+
else:
|
|
245
|
+
self.value = [(self.pnode, self.source_text.value)] # type: ignore[assignment]
|
|
246
|
+
|
|
247
|
+
def get_value(self) -> SourceStrBool:
|
|
248
|
+
"""Return this node as a primitive value."""
|
|
249
|
+
return self.value[0][1] # type: ignore[index] # pragma: nocover
|
|
250
|
+
|
|
251
|
+
def as_data(self, filename: StrPath = '', raw: bool = False) -> SourceStrBool | None: # noqa: FBT001, FBT002
|
|
252
|
+
"""Return this node as primitive types."""
|
|
253
|
+
if raw:
|
|
254
|
+
return self.get_value()
|
|
255
|
+
start_pnode = self.value[0][0] # type: ignore[index]
|
|
256
|
+
end_pnode = self.value[-1][0] # type: ignore[index]
|
|
257
|
+
start = Pos.from_str_index(start_pnode.full_text, start_pnode.start) # type: ignore[union-attr]
|
|
258
|
+
end = Pos.from_str_index(end_pnode.full_text, end_pnode.end) # type: ignore[union-attr]
|
|
259
|
+
|
|
260
|
+
value = self.get_value()
|
|
261
|
+
|
|
262
|
+
return Source(
|
|
263
|
+
filename=filename,
|
|
264
|
+
start=start,
|
|
265
|
+
end=end,
|
|
266
|
+
text=str(self.source_text) if isinstance(self.source_text, Source) else self.source_text,
|
|
267
|
+
# Values correspond to lines of text
|
|
268
|
+
value=value.value if isinstance(value, Source) else value,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def can_add_node(self, node: SymlNode) -> bool:
|
|
272
|
+
"""Check if a child node can be added."""
|
|
273
|
+
return isinstance(node, LeafNode) and (
|
|
274
|
+
node.level is None or (self.level is not None and node.level >= self.level)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class TextLeafNode(LeafNode):
|
|
279
|
+
"""A leaf node containing a text value."""
|
|
280
|
+
|
|
281
|
+
def add_node(self, node: SymlNode) -> SymlNode:
|
|
282
|
+
"""Add a node's text value to this node's text."""
|
|
283
|
+
self.source_text += '\n' + str(node.get_value())
|
|
284
|
+
self.value.append((node.pnode, node.get_value())) # type: ignore[union-attr]
|
|
285
|
+
node.parent = self
|
|
286
|
+
return self.get_tip()
|
|
287
|
+
|
|
288
|
+
def get_value(self) -> str:
|
|
289
|
+
"""Return this node as a primitive value."""
|
|
290
|
+
return '\n'.join([str(v[1]) for v in self.value]) # type: ignore[union-attr]
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class RawValueLeafNode(LeafNode):
|
|
294
|
+
"""A leaf node containing a raw value"""
|
|
295
|
+
|
|
296
|
+
def get_value(self) -> StrBool:
|
|
297
|
+
"""Return this node as a primitive value."""
|
|
298
|
+
return self.value[0][1] # type: ignore[index]
|
syml/parsers.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""SYML parsers"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from parsimonious import Grammar, NodeVisitor, VisitationError
|
|
8
|
+
|
|
9
|
+
from . import basetypes as types
|
|
10
|
+
from . import grammars, nodes
|
|
11
|
+
from .exceptions import OutOfContextNodeError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING: # pragma: nocover
|
|
14
|
+
from parsimonious.nodes import Node as PNode
|
|
15
|
+
|
|
16
|
+
from .basetypes import StrPath
|
|
17
|
+
from .nodes import OptionalNodes, OptionalSymlNodes, SymlNode, SymlNodes
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TextOnlySymlParser(NodeVisitor): # type: ignore[type-arg]
|
|
21
|
+
"""Parser for the SYML variant that allows only text leaf nodes"""
|
|
22
|
+
|
|
23
|
+
grammar = Grammar(grammars.text_only_syml_grammar)
|
|
24
|
+
|
|
25
|
+
def reduce_children(self, children: OptionalSymlNodes) -> SymlNodes:
|
|
26
|
+
"""Return all non-null children."""
|
|
27
|
+
return [c for c in children if c is not None]
|
|
28
|
+
|
|
29
|
+
def visit_blank(self, node: PNode, children: OptionalSymlNodes) -> None: # noqa: ARG002
|
|
30
|
+
"""Visit a blank."""
|
|
31
|
+
return
|
|
32
|
+
|
|
33
|
+
def visit_line(self, node: PNode, children: OptionalSymlNodes) -> OptionalNodes: # noqa: ARG002
|
|
34
|
+
"""Visit a line."""
|
|
35
|
+
indent, value, _ = children
|
|
36
|
+
if value is not None:
|
|
37
|
+
value.set_level(indent) # type: ignore[arg-type]
|
|
38
|
+
return value
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
def generic_visit(self, node: PNode, children: OptionalSymlNodes) -> SymlNodes | SymlNode | None: # type: ignore[override] # noqa: ARG002
|
|
42
|
+
"""Visit a generic node."""
|
|
43
|
+
nodes = self.reduce_children(children)
|
|
44
|
+
if not nodes:
|
|
45
|
+
return None
|
|
46
|
+
if len(nodes) == 1:
|
|
47
|
+
return nodes[0]
|
|
48
|
+
else: # pragma: nocover # noqa: RET505
|
|
49
|
+
return nodes
|
|
50
|
+
|
|
51
|
+
def get_text(self, node: PNode, children: OptionalSymlNodes) -> nodes.TextLeafNode: # noqa: ARG002
|
|
52
|
+
"""Return a text leaf node."""
|
|
53
|
+
return nodes.TextLeafNode(pnode=node, source_text=types.Source.from_node(node))
|
|
54
|
+
|
|
55
|
+
def visit_comment(self, node: PNode, children: OptionalSymlNodes) -> nodes.Comment:
|
|
56
|
+
"""Visit a comment node."""
|
|
57
|
+
_, text = children
|
|
58
|
+
return nodes.Comment(pnode=node, value=text)
|
|
59
|
+
|
|
60
|
+
visit_text = get_text
|
|
61
|
+
visit_key = get_text
|
|
62
|
+
|
|
63
|
+
def visit_indent(self, node: PNode, children: OptionalSymlNodes) -> int: # noqa: ARG002
|
|
64
|
+
"""Visit an indentation token."""
|
|
65
|
+
return len(node.text.replace('\t', ' ' * 4).strip('\n'))
|
|
66
|
+
|
|
67
|
+
def visit_key_value(self, node: PNode, children: SymlNodes) -> OptionalNodes: # noqa: ARG002
|
|
68
|
+
"""Visit a mapping value."""
|
|
69
|
+
section, _, value = children
|
|
70
|
+
section.incorporate_node(value)
|
|
71
|
+
return section
|
|
72
|
+
|
|
73
|
+
def visit_section(self, node: PNode, children: OptionalSymlNodes) -> nodes.KeyValue:
|
|
74
|
+
"""Visit a key/value section."""
|
|
75
|
+
key, _ = children
|
|
76
|
+
return nodes.KeyValue(pnode=node, key=key)
|
|
77
|
+
|
|
78
|
+
def visit_list_item(self, node: PNode, children: OptionalSymlNodes) -> nodes.ListItem:
|
|
79
|
+
"""Visit a list item."""
|
|
80
|
+
_, _, value = children
|
|
81
|
+
li = nodes.ListItem(pnode=node)
|
|
82
|
+
if value is not None: # pragma: nobranch
|
|
83
|
+
li.incorporate_node(value)
|
|
84
|
+
return li
|
|
85
|
+
|
|
86
|
+
def visit_lines(self, node: PNode, children: OptionalSymlNodes) -> nodes.Root:
|
|
87
|
+
"""Visit the lines within a SYML document."""
|
|
88
|
+
root = nodes.Root(pnode=node)
|
|
89
|
+
current: SymlNode = root
|
|
90
|
+
|
|
91
|
+
for child in self.reduce_children(children):
|
|
92
|
+
if isinstance(child, nodes.Comment):
|
|
93
|
+
current.comments.append(child)
|
|
94
|
+
else:
|
|
95
|
+
current = current.incorporate_node(child)
|
|
96
|
+
return root
|
|
97
|
+
|
|
98
|
+
def parse(self, *args: Any, **kwargs: Any) -> SymlNode: # noqa: ANN401
|
|
99
|
+
"""Parse a SYML document."""
|
|
100
|
+
try:
|
|
101
|
+
return super().parse(*args, **kwargs)
|
|
102
|
+
except VisitationError as exc:
|
|
103
|
+
# Parsimonious swallows errors inside of `visit_` handlers and
|
|
104
|
+
# wraps them in VisitationError cruft.
|
|
105
|
+
if exc.args[0].startswith('OutOfContextNodeError'):
|
|
106
|
+
# Extract the original error message, ignoring the cruft.
|
|
107
|
+
msg = exc.args[0].split('\n\n\n')[0].split(':', 1)[1]
|
|
108
|
+
raise OutOfContextNodeError(msg) from exc
|
|
109
|
+
raise # pragma: no cover
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BooleanSymlParser(TextOnlySymlParser):
|
|
113
|
+
"""Syml with support for YAML-like boolean values."""
|
|
114
|
+
|
|
115
|
+
grammar = Grammar(grammars.boolean_syml_grammar)
|
|
116
|
+
|
|
117
|
+
def visit_truthy(self, node: PNode, children: OptionalSymlNodes) -> nodes.RawValueLeafNode: # noqa: ARG002
|
|
118
|
+
"""Visit a truthy value."""
|
|
119
|
+
return nodes.RawValueLeafNode(pnode=node, source_text=types.Source.from_node(node), value=True)
|
|
120
|
+
|
|
121
|
+
def visit_falsey(self, node: PNode, children: OptionalSymlNodes) -> nodes.RawValueLeafNode: # noqa: ARG002
|
|
122
|
+
"""Visit a falsey value."""
|
|
123
|
+
return nodes.RawValueLeafNode(pnode=node, source_text=types.Source.from_node(node), value=False)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def parse(
|
|
127
|
+
source_syml: str,
|
|
128
|
+
filename: StrPath = '',
|
|
129
|
+
raw: bool = True, # noqa: FBT001, FBT002
|
|
130
|
+
booleans: bool = False, # noqa: FBT001, FBT002
|
|
131
|
+
) -> list[Any] | dict[str, Any] | str | bool:
|
|
132
|
+
"""Parse a SYML document."""
|
|
133
|
+
parser = BooleanSymlParser if booleans else TextOnlySymlParser
|
|
134
|
+
return parser().parse(source_syml).as_data(filename, raw=raw)
|
syml/utils.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Utility functions for SYML"""
|
|
2
|
+
|
|
3
|
+
from funcy import memoize
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@memoize
|
|
7
|
+
def split_lines(text: str, keepends: bool = False) -> list[str]: # noqa: FBT001, FBT002
|
|
8
|
+
"""Memoized version of str.splitlines()"""
|
|
9
|
+
return text.splitlines(keepends=keepends)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_line(text: str, line_number: int) -> str:
|
|
13
|
+
"""Return the contents of the specified line number from the given text."""
|
|
14
|
+
try:
|
|
15
|
+
return split_lines(text, keepends=True)[line_number - 1]
|
|
16
|
+
except IndexError:
|
|
17
|
+
return ''
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2016 David Eyk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: syml
|
|
3
|
+
Version: 0.5
|
|
4
|
+
Summary: SYML (Simple YAML-like Markup Language) is a simple markup language with similar structure to YAML, but without all the gewgaws and folderol.
|
|
5
|
+
Home-page: https://github.com/eykd/syml/
|
|
6
|
+
Author: David Eyk
|
|
7
|
+
Author-email: david@worldsenoughstudios.com
|
|
8
|
+
Requires-Python: >=3.12,<4.0
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Requires-Dist: attrs (>=24.3.0,<25.0.0)
|
|
12
|
+
Requires-Dist: funcy (>=2.0,<3.0)
|
|
13
|
+
Requires-Dist: parsimonious (>=0.10.0,<0.11.0)
|
|
14
|
+
Requires-Dist: regex (>=2024.11.6,<2025.0.0)
|
|
15
|
+
Project-URL: Repository, https://github.com/eykd/syml
|
|
16
|
+
Description-Content-Type: text/x-rst
|
|
17
|
+
|
|
18
|
+
----
|
|
19
|
+
SYML
|
|
20
|
+
----
|
|
21
|
+
|
|
22
|
+
SYML (Simple YAML-like Markup Language) is a simple markup language with
|
|
23
|
+
similar structure to YAML, but without all the gewgaws and folderol.
|
|
24
|
+
|
|
25
|
+
.. image:: https://travis-ci.org/eykd/syml.svg?branch=master
|
|
26
|
+
:target: https://travis-ci.org/eykd/syml
|
|
27
|
+
|
|
28
|
+
.. image:: https://coveralls.io/repos/github/eykd/syml/badge.svg?branch=master
|
|
29
|
+
:target: https://coveralls.io/github/eykd/syml?branch=master
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Example
|
|
33
|
+
=======
|
|
34
|
+
|
|
35
|
+
Here's a simple SYML document::
|
|
36
|
+
|
|
37
|
+
>>> document = """
|
|
38
|
+
foo:
|
|
39
|
+
- bar
|
|
40
|
+
- baz
|
|
41
|
+
- blah
|
|
42
|
+
boo
|
|
43
|
+
baloon
|
|
44
|
+
|
|
45
|
+
booleans?:
|
|
46
|
+
- True
|
|
47
|
+
- False
|
|
48
|
+
- true
|
|
49
|
+
- false
|
|
50
|
+
- TRUE
|
|
51
|
+
- FALSE
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
And the resulting data structure::
|
|
55
|
+
|
|
56
|
+
>>> import syml
|
|
57
|
+
>>> syml.loads(document)
|
|
58
|
+
OrderedDict([('foo', ['bar', 'baz', 'blah\nboo']),
|
|
59
|
+
('booleans?', ['True', 'False', 'true', 'false', 'TRUE', 'FALSE'])])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
All values in SYML are just plain ol' text. But let's face it, sometimes you
|
|
63
|
+
really do want YAML-like booleans::
|
|
64
|
+
|
|
65
|
+
>>> import syml
|
|
66
|
+
>>> syml.loads(document, booleans=True)
|
|
67
|
+
OrderedDict([('foo', ['bar', 'baz', 'blah\nboo']),
|
|
68
|
+
('booleans?', [True, False, True, False, True, False])])
|
|
69
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
syml/__init__.py,sha256=xsy4SRFFMCPxHuwnPEI34sQCWrz7va_d1orDbhaFLQA,550
|
|
2
|
+
syml/basetypes.py,sha256=UxTnG2yrv3srtjvsqK6KFV3uZOQFJFwYoGK3rnbT1ZY,3478
|
|
3
|
+
syml/exceptions.py,sha256=kpuBpSUezugFwC30C3Sei4E_c-ZUa-C8uG4fBhvnwT8,215
|
|
4
|
+
syml/grammars.py,sha256=A-rcGlwQNz3--gOLSXeNOrv_fK2nt7L2IjP86sbh-2c,968
|
|
5
|
+
syml/nodes.py,sha256=sWwZGWRg0U-ekaeAnqSoMYST0th51WK_SHjAccRSJDc,10850
|
|
6
|
+
syml/parsers.py,sha256=yq9vYFeqyrT_-F4oBiIcb091vd5rCDZjagvTn2KiN_4,5256
|
|
7
|
+
syml/utils.py,sha256=A8WnE0mNTW6sBX9v42YOUaGiFLYb91o6QOONiMPO1RU,500
|
|
8
|
+
syml-0.5.dist-info/LICENSE,sha256=XwO9yoKd4N5dwRGc8d2P54n_NS3gPXCtzewkBAjXy18,1066
|
|
9
|
+
syml-0.5.dist-info/METADATA,sha256=fzugjRXOqz1PE8V3f5N2lLabnGUvZJ8mAHZnb5FZ7Uw,1840
|
|
10
|
+
syml-0.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
11
|
+
syml-0.5.dist-info/RECORD,,
|
syml-0.5.dist-info/WHEEL
ADDED