sphinxnotes-data 1.0a8__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.
- sphinxnotes/data/__init__.py +81 -0
- sphinxnotes/data/adhoc.py +88 -0
- sphinxnotes/data/config.py +30 -0
- sphinxnotes/data/data.py +511 -0
- sphinxnotes/data/extra_contexts.py +45 -0
- sphinxnotes/data/meta.py +35 -0
- sphinxnotes/data/preset.py +55 -0
- sphinxnotes/data/py.typed +0 -0
- sphinxnotes/data/render.py +390 -0
- sphinxnotes/data/template.py +118 -0
- sphinxnotes/data/utils/__init__.py +98 -0
- sphinxnotes/data/utils/context_proxy.py +149 -0
- sphinxnotes/data/utils/freestyle.py +93 -0
- sphinxnotes_data-1.0a8.dist-info/METADATA +78 -0
- sphinxnotes_data-1.0a8.dist-info/RECORD +18 -0
- sphinxnotes_data-1.0a8.dist-info/WHEEL +5 -0
- sphinxnotes_data-1.0a8.dist-info/licenses/LICENSE +29 -0
- sphinxnotes_data-1.0a8.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinxnotes.data
|
|
3
|
+
~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
:copyright: Copyright 2025 by the Shengyu Zhang.
|
|
6
|
+
:license: BSD, see LICENSE for details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from sphinx.util import logging
|
|
13
|
+
|
|
14
|
+
from . import meta
|
|
15
|
+
from .data import (
|
|
16
|
+
Registry,
|
|
17
|
+
PlainValue,
|
|
18
|
+
Value,
|
|
19
|
+
ValueWrapper,
|
|
20
|
+
RawData,
|
|
21
|
+
Data,
|
|
22
|
+
Field,
|
|
23
|
+
Schema,
|
|
24
|
+
)
|
|
25
|
+
from .template import Phase, Template
|
|
26
|
+
from .render import (
|
|
27
|
+
Caller,
|
|
28
|
+
pending_node,
|
|
29
|
+
RenderedNode,
|
|
30
|
+
rendered_node,
|
|
31
|
+
rendered_inline_node,
|
|
32
|
+
BaseDataDefiner,
|
|
33
|
+
BaseDataDefineRole,
|
|
34
|
+
BaseDataDefineDirective,
|
|
35
|
+
StrictDataDefineDirective,
|
|
36
|
+
)
|
|
37
|
+
from .config import Config
|
|
38
|
+
|
|
39
|
+
if TYPE_CHECKING:
|
|
40
|
+
from sphinx.application import Sphinx
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
"""Python API for other Sphinx extesions."""
|
|
44
|
+
__all__ = [
|
|
45
|
+
'Config',
|
|
46
|
+
'Registry',
|
|
47
|
+
'PlainValue',
|
|
48
|
+
'Value',
|
|
49
|
+
'ValueWrapper',
|
|
50
|
+
'RawData',
|
|
51
|
+
'Data',
|
|
52
|
+
'Field',
|
|
53
|
+
'Schema',
|
|
54
|
+
'Phase',
|
|
55
|
+
'Template',
|
|
56
|
+
'Caller',
|
|
57
|
+
'pending_node',
|
|
58
|
+
'RenderedNode',
|
|
59
|
+
'rendered_node',
|
|
60
|
+
'rendered_inline_node',
|
|
61
|
+
'BaseDataDefiner',
|
|
62
|
+
'BaseDataDefineRole',
|
|
63
|
+
'BaseDataDefineDirective',
|
|
64
|
+
'BaseDataDefineDirective',
|
|
65
|
+
'StrictDataDefineDirective',
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
logger = logging.getLogger(__name__)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def setup(app: Sphinx):
|
|
72
|
+
meta.pre_setup(app)
|
|
73
|
+
|
|
74
|
+
from . import config, template, render, adhoc
|
|
75
|
+
|
|
76
|
+
config.setup(app)
|
|
77
|
+
template.setup(app)
|
|
78
|
+
render.setup(app)
|
|
79
|
+
adhoc.setup(app)
|
|
80
|
+
|
|
81
|
+
return meta.post_setup(app)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinxnotes.data.adhoc
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
This extension allow user define, validate, and render data in a document
|
|
6
|
+
on the fly
|
|
7
|
+
|
|
8
|
+
(Yes, Use markup language (rst/md) entirely, instead of Python).
|
|
9
|
+
|
|
10
|
+
:copyright: Copyright 2025 by the Shengyu Zhang.
|
|
11
|
+
:license: BSD, see LICENSE for details.
|
|
12
|
+
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
from typing import TYPE_CHECKING, cast, override
|
|
17
|
+
|
|
18
|
+
from docutils import nodes
|
|
19
|
+
from docutils.parsers.rst import directives
|
|
20
|
+
from sphinx.util.docutils import SphinxDirective
|
|
21
|
+
|
|
22
|
+
from .data import Field, Schema
|
|
23
|
+
from .template import Template, Phase
|
|
24
|
+
from .render import BaseDataDefineDirective
|
|
25
|
+
from .utils.freestyle import FreeStyleDirective, FreeStyleOptionSpec
|
|
26
|
+
from . import preset
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from sphinx.application import Sphinx
|
|
30
|
+
|
|
31
|
+
# Keys of env.temp_data.
|
|
32
|
+
TEMPLATE_KEY = 'sphinxnotes-data:template'
|
|
33
|
+
SCHEMA_KEY = 'sphinxnotes-data:schema'
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TemplateDefineDirective(SphinxDirective):
|
|
37
|
+
option_spec = {
|
|
38
|
+
'on': Phase.option_spec,
|
|
39
|
+
'debug': directives.flag,
|
|
40
|
+
}
|
|
41
|
+
has_content = True
|
|
42
|
+
|
|
43
|
+
def run(self) -> list[nodes.Node]:
|
|
44
|
+
self.env.temp_data[TEMPLATE_KEY] = Template(
|
|
45
|
+
text='\n'.join(self.content),
|
|
46
|
+
phase=self.options.get('on', Phase.default()),
|
|
47
|
+
debug='debug' in self.options,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SchemaDefineDirective(FreeStyleDirective):
|
|
54
|
+
optional_arguments = 1
|
|
55
|
+
option_spec = FreeStyleOptionSpec()
|
|
56
|
+
has_content = True
|
|
57
|
+
|
|
58
|
+
def run(self) -> list[nodes.Node]:
|
|
59
|
+
name = Field.from_dsl(self.arguments[0]) if self.arguments else None
|
|
60
|
+
attrs = {}
|
|
61
|
+
for k, v in self.options.items():
|
|
62
|
+
attrs[k] = Field.from_dsl(v)
|
|
63
|
+
content = Field.from_dsl(self.content[0]) if self.content else None
|
|
64
|
+
|
|
65
|
+
self.env.temp_data[SCHEMA_KEY] = Schema(name, attrs, content)
|
|
66
|
+
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FreeDataDefineDirective(BaseDataDefineDirective, FreeStyleDirective):
|
|
71
|
+
optional_arguments = 1
|
|
72
|
+
has_content = True
|
|
73
|
+
|
|
74
|
+
@override
|
|
75
|
+
def current_template(self) -> Template:
|
|
76
|
+
tmpl = self.env.temp_data.get(TEMPLATE_KEY, preset.Directive.template())
|
|
77
|
+
return cast(Template, tmpl)
|
|
78
|
+
|
|
79
|
+
@override
|
|
80
|
+
def current_schema(self) -> Schema:
|
|
81
|
+
schema = self.env.temp_data.get(SCHEMA_KEY, preset.Directive.schema())
|
|
82
|
+
return cast(Schema, schema)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def setup(app: Sphinx):
|
|
86
|
+
app.add_directive('template', TemplateDefineDirective)
|
|
87
|
+
app.add_directive('schema', SchemaDefineDirective)
|
|
88
|
+
app.add_directive('data', FreeDataDefineDirective)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinxnotes.data.config
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
:copyright: Copyright 2025~2026 by the Shengyu Zhang.
|
|
6
|
+
:license: BSD, see LICENSE for details.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sphinx.application import Sphinx
|
|
14
|
+
from sphinx.config import Config as SphinxConfig
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Config:
|
|
18
|
+
"""Global config of extension."""
|
|
19
|
+
|
|
20
|
+
template_debug: bool
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _config_inited(app: Sphinx, config: SphinxConfig) -> None:
|
|
24
|
+
Config.template_debug = config.data_template_debug
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup(app: Sphinx):
|
|
28
|
+
app.add_config_value('data_template_debug', False, '', bool)
|
|
29
|
+
|
|
30
|
+
app.connect('config-inited', _config_inited)
|
sphinxnotes/data/data.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sphinxnotes.data.data
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
Core type definitions.
|
|
6
|
+
|
|
7
|
+
:copyright: Copyright 2025~2026 by the Shengyu Zhang.
|
|
8
|
+
:license: BSD, see LICENSE for details.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, asdict, field as dataclass_field
|
|
15
|
+
from ast import literal_eval
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from typing import Any, Callable, Generator, Self, Literal
|
|
19
|
+
|
|
20
|
+
# ===================================
|
|
21
|
+
# Basic types: Value, Form, Flag, ...
|
|
22
|
+
# ===================================
|
|
23
|
+
|
|
24
|
+
type PlainValue = bool | int | float | str | object
|
|
25
|
+
type Value = None | PlainValue | list[PlainValue]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class ValueWrapper:
|
|
30
|
+
v: Value
|
|
31
|
+
|
|
32
|
+
# TODO: __post_init__ to assert type
|
|
33
|
+
|
|
34
|
+
def as_plain(self) -> PlainValue | None:
|
|
35
|
+
if self.v is None:
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(self.v, list):
|
|
38
|
+
return self.v[0] if len(self.v) else None
|
|
39
|
+
return self.v
|
|
40
|
+
|
|
41
|
+
def as_list(self) -> list[PlainValue]:
|
|
42
|
+
if self.v is None:
|
|
43
|
+
return []
|
|
44
|
+
elif isinstance(self.v, list):
|
|
45
|
+
return [x for x in self.v]
|
|
46
|
+
else:
|
|
47
|
+
return [self.v]
|
|
48
|
+
|
|
49
|
+
def as_str(self) -> str | None:
|
|
50
|
+
v = self.as_plain()
|
|
51
|
+
return self._strify(v) if v is not None else None
|
|
52
|
+
|
|
53
|
+
def as_str_list(self) -> list[str]:
|
|
54
|
+
return [self._strify(x) for x in self.as_list()]
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def _strify(v: PlainValue) -> str:
|
|
58
|
+
return Registry.strifys[type(v)](v)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Form:
|
|
63
|
+
"""Defines how to split the string and the container type."""
|
|
64
|
+
|
|
65
|
+
ctype: type
|
|
66
|
+
sep: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Flag:
|
|
71
|
+
name: str
|
|
72
|
+
default: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
type ByOptionStore = Literal['assign', 'append']
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ByOption:
|
|
80
|
+
name: str
|
|
81
|
+
etype: type
|
|
82
|
+
default: Value
|
|
83
|
+
store: ByOptionStore
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ========
|
|
87
|
+
# Registry
|
|
88
|
+
# ========
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _bool_conv(v: str | None) -> bool:
|
|
92
|
+
v = v.lower().strip() if v is not None else None
|
|
93
|
+
if v in ('true', 'yes', '1', 'on', 'y', ''):
|
|
94
|
+
return True
|
|
95
|
+
if v in ('false', 'no', '0', 'off', 'n', None):
|
|
96
|
+
return False
|
|
97
|
+
# Same to :meth:`directives.flag`.
|
|
98
|
+
raise ValueError(f'no argument is allowed; "{v}" supplied')
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _str_conv(v: str) -> str:
|
|
102
|
+
try:
|
|
103
|
+
vv = literal_eval(v)
|
|
104
|
+
except (ValueError, SyntaxError):
|
|
105
|
+
return v
|
|
106
|
+
return vv if isinstance(vv, str) else v
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Registry:
|
|
110
|
+
"""Stores supported element types and element forms (containers)."""
|
|
111
|
+
|
|
112
|
+
etypes: dict[str, type] = {}
|
|
113
|
+
|
|
114
|
+
ctypes: set[type] = {list, tuple, set}
|
|
115
|
+
|
|
116
|
+
convs: dict[type, Callable[[str], PlainValue]] = {}
|
|
117
|
+
|
|
118
|
+
strifys: dict[type, Callable[[PlainValue], str]] = {}
|
|
119
|
+
|
|
120
|
+
forms: dict[str, Form] = {}
|
|
121
|
+
|
|
122
|
+
flags: dict[str, Flag] = {}
|
|
123
|
+
|
|
124
|
+
byopts: dict[str, ByOption] = {}
|
|
125
|
+
|
|
126
|
+
_sep_by_option: ByOption
|
|
127
|
+
|
|
128
|
+
@classmethod
|
|
129
|
+
def add_type(
|
|
130
|
+
cls,
|
|
131
|
+
name: str,
|
|
132
|
+
etype: type,
|
|
133
|
+
conv: Callable[[str], PlainValue],
|
|
134
|
+
strify: Callable[[PlainValue], str],
|
|
135
|
+
aliases: list[str] = [],
|
|
136
|
+
) -> None:
|
|
137
|
+
cls.etypes[name] = etype
|
|
138
|
+
cls.convs[etype] = conv
|
|
139
|
+
cls.strifys[etype] = strify
|
|
140
|
+
|
|
141
|
+
for alias in aliases:
|
|
142
|
+
cls.etypes[alias] = etype
|
|
143
|
+
|
|
144
|
+
@classmethod
|
|
145
|
+
def add_form(
|
|
146
|
+
cls, name: str, ctype: type, sep: str, aliases: list[str] = []
|
|
147
|
+
) -> None:
|
|
148
|
+
if ctype not in cls.ctypes:
|
|
149
|
+
raise ValueError(f'unsupported type: "{ctype}". available: {cls.ctypes}')
|
|
150
|
+
|
|
151
|
+
form = Form(ctype, sep)
|
|
152
|
+
|
|
153
|
+
cls.forms[name] = form
|
|
154
|
+
for alias in aliases:
|
|
155
|
+
cls.forms[alias] = form
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def add_flag(
|
|
159
|
+
cls, name: str, default: bool = False, aliases: list[str] = []
|
|
160
|
+
) -> None:
|
|
161
|
+
flag = Flag(name, default)
|
|
162
|
+
|
|
163
|
+
cls.flags[flag.name] = flag
|
|
164
|
+
for alias in aliases:
|
|
165
|
+
cls.flags[alias] = flag
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def add_by_option(
|
|
169
|
+
cls,
|
|
170
|
+
name: str,
|
|
171
|
+
etype: type,
|
|
172
|
+
default: Value = None,
|
|
173
|
+
store: ByOptionStore = 'assign',
|
|
174
|
+
aliases: list[str] = [],
|
|
175
|
+
) -> None:
|
|
176
|
+
opt = ByOption(name, etype, default, store)
|
|
177
|
+
|
|
178
|
+
cls.byopts[opt.name] = opt
|
|
179
|
+
for alias in aliases:
|
|
180
|
+
cls.byopts[alias] = opt
|
|
181
|
+
|
|
182
|
+
@classmethod
|
|
183
|
+
def setup(cls) -> None:
|
|
184
|
+
cls.add_type('bool', bool, conv=_bool_conv, strify=str, aliases=['flag'])
|
|
185
|
+
cls.add_type('int', int, conv=int, strify=str, aliases=['integer'])
|
|
186
|
+
cls.add_type('float', float, conv=float, strify=str, aliases=['number', 'num'])
|
|
187
|
+
cls.add_type('str', str, conv=_str_conv, strify=str, aliases=['string'])
|
|
188
|
+
|
|
189
|
+
cls.add_form('list', list, ',')
|
|
190
|
+
cls.add_form('lines', list, '\n')
|
|
191
|
+
cls.add_form('words', list, ' ')
|
|
192
|
+
cls.add_form('set', set, ' ')
|
|
193
|
+
|
|
194
|
+
cls.add_flag('required', False, aliases=['require', 'req'])
|
|
195
|
+
|
|
196
|
+
cls.add_by_option('sep', str, aliases=['separate'])
|
|
197
|
+
# NOTE: the "sep" by-option is a special builtin flag, extract it for
|
|
198
|
+
# later usage.
|
|
199
|
+
cls._sep_by_option = cls.byopts['sep']
|
|
200
|
+
|
|
201
|
+
# from pprint import pprint
|
|
202
|
+
# pprint(cls.__dict__)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
Registry.setup()
|
|
206
|
+
|
|
207
|
+
# ======================
|
|
208
|
+
# Data, Field and Schema
|
|
209
|
+
# ======================
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass
|
|
213
|
+
class RawData:
|
|
214
|
+
name: str | None
|
|
215
|
+
attrs: dict[str, str]
|
|
216
|
+
content: str | None
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@dataclass
|
|
220
|
+
class Data:
|
|
221
|
+
name: Value
|
|
222
|
+
attrs: dict[str, Value]
|
|
223
|
+
content: Value
|
|
224
|
+
|
|
225
|
+
def ascontext(self) -> dict[str, Any]:
|
|
226
|
+
return asdict(self)
|
|
227
|
+
|
|
228
|
+
def title(self) -> str | None:
|
|
229
|
+
return ValueWrapper(self.name).as_str()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class Field:
|
|
234
|
+
#: Type of element.
|
|
235
|
+
etype: type = str
|
|
236
|
+
#: Type of container (if the field holds multiple values).
|
|
237
|
+
ctype: type | None = None
|
|
238
|
+
#: Flags of field.
|
|
239
|
+
flags: dict[str, Value] = dataclass_field(default_factory=dict)
|
|
240
|
+
|
|
241
|
+
# Type hints for builtin flags.
|
|
242
|
+
if TYPE_CHECKING:
|
|
243
|
+
required: bool = False
|
|
244
|
+
sep: str | None = None
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def from_dsl(cls, dsl: str) -> Self:
|
|
248
|
+
self = cls()
|
|
249
|
+
DSLParser(self).parse(dsl)
|
|
250
|
+
return self
|
|
251
|
+
|
|
252
|
+
def __post_init__(self) -> None:
|
|
253
|
+
# Init flags and by flags.
|
|
254
|
+
for flag in Registry.flags.values():
|
|
255
|
+
if flag.name not in self.flags:
|
|
256
|
+
self.flags[flag.name] = flag.default
|
|
257
|
+
for opt in Registry.byopts.values():
|
|
258
|
+
if opt.name in self.flags:
|
|
259
|
+
continue
|
|
260
|
+
if opt.store == 'assign':
|
|
261
|
+
self.flags[opt.name] = opt.default
|
|
262
|
+
elif opt.store == 'append':
|
|
263
|
+
self.flags[opt.name] = lst = []
|
|
264
|
+
if opt.default is not None:
|
|
265
|
+
lst.append(opt.default)
|
|
266
|
+
else:
|
|
267
|
+
raise DSLParser.by_option_store_value_error(opt)
|
|
268
|
+
|
|
269
|
+
def parse(self, rawval: str | None) -> Value:
|
|
270
|
+
"""
|
|
271
|
+
Parses the raw input string into the target Value.
|
|
272
|
+
When a None is passed, which means the field is not supplied.
|
|
273
|
+
"""
|
|
274
|
+
if rawval is None:
|
|
275
|
+
if self.required:
|
|
276
|
+
# Same to :meth:`directives.unchanged_required`.
|
|
277
|
+
raise ValueError('argument required but none supplied')
|
|
278
|
+
if self.ctype:
|
|
279
|
+
# Return a empty container when a None value is optional.
|
|
280
|
+
return self.ctype()
|
|
281
|
+
if self.etype is bool:
|
|
282
|
+
# Special case: A single bool field is valid even when
|
|
283
|
+
# value is not supplied.
|
|
284
|
+
return _bool_conv(rawval)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
# Strip whitespace. TODO: supported unchanged?
|
|
288
|
+
rawval = rawval.strip()
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
conv = Registry.convs[self.etype]
|
|
292
|
+
|
|
293
|
+
if self.ctype is None:
|
|
294
|
+
# Parse as scalar
|
|
295
|
+
return conv(rawval)
|
|
296
|
+
|
|
297
|
+
# Parse as container
|
|
298
|
+
if self.sep == ' ':
|
|
299
|
+
items = rawval.split() # split by arbitrary whitespace
|
|
300
|
+
elif self.sep == '':
|
|
301
|
+
items = list(rawval) # split by char
|
|
302
|
+
else:
|
|
303
|
+
items = rawval.split(self.sep)
|
|
304
|
+
|
|
305
|
+
elems = [conv(x.strip()) for x in items if x.strip() != '']
|
|
306
|
+
|
|
307
|
+
return self.ctype(elems)
|
|
308
|
+
except ValueError as e:
|
|
309
|
+
raise ValueError(f"failed to parse '{rawval}' as {self.etype}: {e}") from e
|
|
310
|
+
|
|
311
|
+
def __getattr__(self, name: str) -> Value:
|
|
312
|
+
if name in self.flags:
|
|
313
|
+
return self.flags[name]
|
|
314
|
+
raise AttributeError(name)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@dataclass
|
|
318
|
+
class DSLParser:
|
|
319
|
+
field: Field
|
|
320
|
+
|
|
321
|
+
def parse(self, dsl: str) -> None:
|
|
322
|
+
"""Parses the DSL string into a Field object."""
|
|
323
|
+
# Initialize form as None, implied scalar unless modifiers change it.
|
|
324
|
+
for mod in self._split_modifiers(dsl):
|
|
325
|
+
if mod.strip():
|
|
326
|
+
self._apply_modifier(mod.strip())
|
|
327
|
+
|
|
328
|
+
def _split_modifiers(self, text: str) -> list[str]:
|
|
329
|
+
"""Splits the DSL string by comma, ignoring commas inside quotes."""
|
|
330
|
+
parts, current, quote_char = [], [], None
|
|
331
|
+
|
|
332
|
+
for ch in text:
|
|
333
|
+
if quote_char:
|
|
334
|
+
current.append(ch)
|
|
335
|
+
if ch == quote_char:
|
|
336
|
+
quote_char = None
|
|
337
|
+
else:
|
|
338
|
+
if ch in ('"', "'"):
|
|
339
|
+
quote_char = ch
|
|
340
|
+
current.append(ch)
|
|
341
|
+
elif ch == ',':
|
|
342
|
+
parts.append(''.join(current))
|
|
343
|
+
current = []
|
|
344
|
+
else:
|
|
345
|
+
current.append(ch)
|
|
346
|
+
|
|
347
|
+
if current:
|
|
348
|
+
parts.append(''.join(current))
|
|
349
|
+
|
|
350
|
+
return parts
|
|
351
|
+
|
|
352
|
+
def _apply_modifier(self, mod: str):
|
|
353
|
+
clean_mod = mod.strip()
|
|
354
|
+
lower_mod = clean_mod.lower()
|
|
355
|
+
|
|
356
|
+
# Match: XXX of XXX (e.g., "list of int")
|
|
357
|
+
if match := re.match(r'^([a-zA-Z_]+)\s+of\s+([a-zA-Z_]+)$', lower_mod):
|
|
358
|
+
form, etype = match.groups()
|
|
359
|
+
|
|
360
|
+
if etype not in Registry.etypes:
|
|
361
|
+
raise ValueError(
|
|
362
|
+
f'unsupported type: "{etype}". '
|
|
363
|
+
f'available: {list(Registry.etypes.keys())}'
|
|
364
|
+
)
|
|
365
|
+
if form not in Registry.forms:
|
|
366
|
+
raise ValueError(
|
|
367
|
+
f'unsupported form: "{form}". '
|
|
368
|
+
f'available: {list(Registry.forms.keys())}'
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
self.field.etype = Registry.etypes[etype]
|
|
372
|
+
self.field.ctype = Registry.forms[form].ctype
|
|
373
|
+
self.field.flags[Registry._sep_by_option.name] = Registry.forms[form].sep
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
# Match: Type only (e.g., "int")
|
|
377
|
+
if lower_mod in Registry.etypes:
|
|
378
|
+
self.field.etype = Registry.etypes[lower_mod]
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
# Match: by-option, "XXX by XXX" (e.g., "sep by '|'")
|
|
382
|
+
if match := re.match(r'^([a-zA-Z_]+)\s+by\s+(.+)$', clean_mod, re.IGNORECASE):
|
|
383
|
+
optname, rawval = match.groups()
|
|
384
|
+
|
|
385
|
+
if optname not in Registry.byopts:
|
|
386
|
+
raise ValueError(
|
|
387
|
+
f'unsupported by-option: "{optname}" by. '
|
|
388
|
+
f'available: {list(Registry.byopts.keys())}'
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
flags = self.field.flags
|
|
392
|
+
opt = Registry.byopts[optname]
|
|
393
|
+
val = Registry.convs[opt.etype](rawval)
|
|
394
|
+
|
|
395
|
+
if opt.store == 'assign':
|
|
396
|
+
flags[opt.name] = val
|
|
397
|
+
elif opt.store == 'append':
|
|
398
|
+
vals = flags[opt.name]
|
|
399
|
+
assert isinstance(vals, list)
|
|
400
|
+
vals.append(val)
|
|
401
|
+
else:
|
|
402
|
+
raise self.by_option_store_value_error(opt)
|
|
403
|
+
|
|
404
|
+
# Deal with special by option.
|
|
405
|
+
if opt == Registry._sep_by_option:
|
|
406
|
+
# ctype default to list if 'sep by' is used without a 'xxx of xxx'.
|
|
407
|
+
if self.field.ctype is None:
|
|
408
|
+
self.field.ctype = list
|
|
409
|
+
|
|
410
|
+
return
|
|
411
|
+
|
|
412
|
+
# Match: flags.
|
|
413
|
+
if lower_mod in Registry.flags:
|
|
414
|
+
opt = Registry.flags[lower_mod]
|
|
415
|
+
self.field.flags[opt.name] = not opt.default
|
|
416
|
+
return
|
|
417
|
+
|
|
418
|
+
raise ValueError(f"unknown modifier: '{mod}'")
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def by_option_store_value_error(opt: ByOption) -> ValueError:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
f'unsupported by-option store: "{opt.store}". '
|
|
424
|
+
f'available: {ByOptionStore}' # FIXME:
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True)
|
|
429
|
+
class Schema(object):
|
|
430
|
+
name: Field | None
|
|
431
|
+
attrs: dict[str, Field] | Field
|
|
432
|
+
content: Field | None
|
|
433
|
+
|
|
434
|
+
@classmethod
|
|
435
|
+
def from_dsl(
|
|
436
|
+
cls,
|
|
437
|
+
name: str | None = None,
|
|
438
|
+
attrs: dict[str, str] = {},
|
|
439
|
+
content: str | None = None,
|
|
440
|
+
) -> Self:
|
|
441
|
+
name_field = Field.from_dsl(name) if name else None
|
|
442
|
+
attrs_field = {k: Field.from_dsl(v) for k, v in attrs.items()}
|
|
443
|
+
cont_field = Field.from_dsl(content) if content else None
|
|
444
|
+
|
|
445
|
+
return cls(name_field, attrs_field, cont_field)
|
|
446
|
+
|
|
447
|
+
def _parse_single(
|
|
448
|
+
self, field: tuple[str, Field | None], rawval: str | None
|
|
449
|
+
) -> Value:
|
|
450
|
+
if field[1] is None and rawval is not None:
|
|
451
|
+
raise ValueError(
|
|
452
|
+
f'parsing {field[0]}: no argument is allowed; "{rawval}" supplied'
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
assert field[1] is not None
|
|
457
|
+
return field[1].parse(rawval)
|
|
458
|
+
except Exception as e:
|
|
459
|
+
raise ValueError(f'parsing {field[0]}: {e}')
|
|
460
|
+
|
|
461
|
+
def parse(self, data: RawData) -> Data:
|
|
462
|
+
if data.name:
|
|
463
|
+
name = self._parse_single(('name', self.name), data.name)
|
|
464
|
+
else:
|
|
465
|
+
name = None
|
|
466
|
+
|
|
467
|
+
attrs = {}
|
|
468
|
+
if isinstance(self.attrs, Field):
|
|
469
|
+
for key, rawval in data.attrs.items():
|
|
470
|
+
attrs[key] = self._parse_single(('attrs.' + key, self.attrs), rawval)
|
|
471
|
+
else:
|
|
472
|
+
rawattrs = data.attrs.copy()
|
|
473
|
+
for key, field in self.attrs.items():
|
|
474
|
+
if rawval := rawattrs.pop(key, None):
|
|
475
|
+
attrs[key] = self._parse_single(('attrs.' + key, field), rawval)
|
|
476
|
+
for key, rawval in rawattrs.items():
|
|
477
|
+
raise ValueError(f'unknown attr: "{key}"')
|
|
478
|
+
|
|
479
|
+
if data.content:
|
|
480
|
+
content = self._parse_single(('content', self.content), data.content)
|
|
481
|
+
else:
|
|
482
|
+
content = None
|
|
483
|
+
|
|
484
|
+
return Data(name, attrs, content)
|
|
485
|
+
|
|
486
|
+
def fields(self) -> Generator[tuple[str, Field]]:
|
|
487
|
+
if self.name:
|
|
488
|
+
yield 'name', self.name
|
|
489
|
+
|
|
490
|
+
if isinstance(self.attrs, Field):
|
|
491
|
+
yield 'attrs', self.attrs
|
|
492
|
+
else:
|
|
493
|
+
for name, field in self.attrs.items():
|
|
494
|
+
yield name, field
|
|
495
|
+
|
|
496
|
+
if self.content:
|
|
497
|
+
yield 'content', self.content
|
|
498
|
+
|
|
499
|
+
def items(self, data: Data) -> Generator[tuple[str, Field, Value]]:
|
|
500
|
+
if self.name:
|
|
501
|
+
yield 'name', self.name, data.name
|
|
502
|
+
|
|
503
|
+
if isinstance(self.attrs, Field):
|
|
504
|
+
for name, val in data.attrs:
|
|
505
|
+
yield name, self.attrs, val
|
|
506
|
+
else:
|
|
507
|
+
for name, field in self.attrs.items():
|
|
508
|
+
yield name, field, data.attrs.get(name)
|
|
509
|
+
|
|
510
|
+
if self.content:
|
|
511
|
+
yield 'content', self.content, data.content
|