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.
@@ -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)
@@ -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