sphinxnotes-data 1.0a0__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,32 @@
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
+
16
+ if TYPE_CHECKING:
17
+ from sphinx.application import Sphinx
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ def setup(app: Sphinx):
22
+ meta.pre_setup(app)
23
+
24
+ from . import template
25
+ from . import render
26
+ from . import adhoc
27
+
28
+ template.setup(app)
29
+ render.setup(app)
30
+ adhoc.setup(app)
31
+
32
+ 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,37 @@
1
+ """
2
+ sphinxnotes.data.api
3
+ ~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Python API for other Sphinx extesions.
6
+
7
+ :copyright: Copyright 2025 by the Shengyu Zhang.
8
+ :license: BSD, see LICENSE for details.
9
+ """
10
+
11
+ from .data import RawData, Value, ValueWrapper, Data, Field, Schema
12
+
13
+ RawData = RawData
14
+ Value = Value
15
+ ValueWrapper = ValueWrapper
16
+ Data = Data
17
+ Field = Field
18
+ Schema = Schema
19
+
20
+ from .template import Phase, Template
21
+
22
+ Phase = Phase
23
+ Template = Template
24
+
25
+ from .render import Caller, pending_node, RenderedNode, rendered_node, rendered_inline_node, BaseDataDefiner, BaseDataDefineRole, BaseDataDefineDirective, StrictDataDefineDirective
26
+
27
+ Caller = Caller
28
+ pending_node = pending_node
29
+ RenderedNode = RenderedNode
30
+ rendered_node = rendered_node
31
+ rendered_inline_node = rendered_inline_node
32
+ BaseDataDefiner = BaseDataDefiner
33
+ BaseDataDefineRole = BaseDataDefineRole
34
+ BaseDataDefineDirective = BaseDataDefineDirective
35
+ BaseDataDefineDirective = BaseDataDefineDirective
36
+ StrictDataDefineDirective = StrictDataDefineDirective
37
+
@@ -0,0 +1,447 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ import re
4
+ from dataclasses import dataclass, asdict, field as dataclass_field
5
+ from ast import literal_eval
6
+
7
+ if TYPE_CHECKING:
8
+ from typing import Any, Callable, Generator, Self, Literal as Lit
9
+
10
+
11
+ #########################################
12
+ # Basic classes: Value, Form, Flag, ... #
13
+ #########################################
14
+
15
+ type PlainValue = bool | int | float | str
16
+ type Value = None | PlainValue | list[PlainValue]
17
+
18
+
19
+ @dataclass
20
+ class ValueWrapper:
21
+ v: Value
22
+
23
+ def as_plain(self) -> PlainValue | None:
24
+ if self.v is None:
25
+ return None
26
+ if isinstance(self.v, list):
27
+ if len(self.v) == 0:
28
+ return None
29
+ return self.v[0]
30
+ return self.v
31
+
32
+ def as_str(self) -> str | None:
33
+ return str(self.as_plain())
34
+
35
+ def as_list(self) -> list[PlainValue]:
36
+ if self.v is None:
37
+ return []
38
+ elif isinstance(self.v, list):
39
+ return [x for x in self.v]
40
+ else:
41
+ return [self.v]
42
+
43
+ def as_str_list(self) -> list[str]:
44
+ if self.v is None:
45
+ return []
46
+ elif isinstance(self.v, list):
47
+ return [str(x) for x in self.v]
48
+ else:
49
+ return [str(self.v)]
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Form:
54
+ """Defines how to split the string and the container type."""
55
+
56
+ ctype: type
57
+ sep: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Flag:
62
+ name: str
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class BoolFlag(Flag):
67
+ default: bool = False
68
+
69
+
70
+ type FlagStore = Lit['assign'] | Lit['append']
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class OperFlag(Flag):
75
+ etype: type = str
76
+ default: Value = None
77
+ store: FlagStore = 'assign'
78
+
79
+
80
+ ############
81
+ # Registry #
82
+ ############
83
+
84
+
85
+ class Registry:
86
+ """Stores supported element types and element forms (containers)."""
87
+
88
+ etypes: dict[str, type] = {
89
+ 'bool': bool,
90
+ 'flag': bool,
91
+ 'int': int,
92
+ 'integer': int,
93
+ 'float': float,
94
+ 'number': float,
95
+ 'num': float,
96
+ 'str': str,
97
+ 'string': str,
98
+ }
99
+
100
+ """Internal type converters."""
101
+
102
+ @staticmethod
103
+ def _bool_conv(v: str | None) -> bool:
104
+ v = v.lower().strip() if v is not None else None
105
+ if v in ('true', 'yes', '1', 'on', 'y', ''):
106
+ return True
107
+ if v in ('false', 'no', '0', 'off', 'n', None):
108
+ return False
109
+ # Same to :meth:`directives.flag`.
110
+ raise ValueError(f'no argument is allowed; "{v}" supplied')
111
+
112
+ @staticmethod
113
+ def _str_conv(v: str) -> str:
114
+ try:
115
+ vv = literal_eval(v)
116
+ except (ValueError, SyntaxError):
117
+ return v
118
+ return vv if isinstance(vv, str) else v
119
+
120
+ convs: dict[type, Callable[[str], Any]] = {
121
+ bool: _bool_conv,
122
+ int: int,
123
+ float: float,
124
+ str: _str_conv,
125
+ }
126
+
127
+ forms: dict[str, Form] = {
128
+ 'list': Form(ctype=list, sep=','),
129
+ 'lines': Form(ctype=list, sep='\n'),
130
+ 'words': Form(ctype=list, sep=' '),
131
+ }
132
+
133
+ """Builtin flags."""
134
+
135
+ _required_flag = BoolFlag('required')
136
+ _sep_flag = OperFlag('sep', etype=str)
137
+
138
+ flags: dict[str, BoolFlag] = {
139
+ 'required': _required_flag,
140
+ 'require': _required_flag,
141
+ 'req': _required_flag,
142
+ }
143
+
144
+ byflags: dict[str, OperFlag] = {
145
+ 'separate': _sep_flag,
146
+ 'sep': _sep_flag,
147
+ }
148
+
149
+
150
+ ##########################
151
+ # Data, Field and Schema #
152
+ ##########################
153
+
154
+
155
+ @dataclass
156
+ class RawData:
157
+ name: str | None
158
+ attrs: dict[str, str]
159
+ content: str | None
160
+
161
+
162
+ @dataclass
163
+ class Data:
164
+ name: Value
165
+ attrs: dict[str, Value]
166
+ content: Value
167
+
168
+ def ascontext(self) -> dict[str, Any]:
169
+ return asdict(self)
170
+
171
+ def title(self) -> str | None:
172
+ return ValueWrapper(self.name).as_str()
173
+
174
+ @dataclass
175
+ class Field:
176
+ #: Type of element.
177
+ etype: type = str
178
+ #: Type of container (if the field holds multiple values).
179
+ ctype: type | None = None
180
+ #: Flags of field.
181
+ flags: dict[str, Value] = dataclass_field(default_factory=dict)
182
+
183
+ # Type hints for builtin flags.
184
+ if TYPE_CHECKING:
185
+ required: bool = False
186
+ sep: str | None = None
187
+
188
+ @classmethod
189
+ def from_dsl(cls, dsl: str) -> Self:
190
+ self = cls()
191
+ DSLParser(self).parse(dsl)
192
+ return self
193
+
194
+ def __post_init__(self) -> None:
195
+ # Init flags and by flags.
196
+ for flag in Registry.flags.values():
197
+ if flag.name not in self.flags:
198
+ self.flags[flag.name] = flag.default
199
+ for flag in Registry.byflags.values():
200
+ if flag.name not in self.flags:
201
+ self.flags[flag.name] = flag.default
202
+
203
+ def parse(self, rawval: str | None) -> Value:
204
+ """
205
+ Parses the raw input string into the target Value.
206
+ When a None is passed, which means the field is not supplied.
207
+ """
208
+ if rawval is None:
209
+ if self.required:
210
+ # Same to :meth:`directives.unchanged_required`.
211
+ raise ValueError('argument required but none supplied')
212
+ if self.ctype:
213
+ # Return a empty container when a None value is optional.
214
+ return self.ctype()
215
+ if self.etype is bool:
216
+ # Special case: A single bool field is valid even when
217
+ # value is not supplied.
218
+ return Registry._bool_conv(rawval)
219
+ return None
220
+
221
+ # Strip whitespace. TODO: supported unchanged?
222
+ rawval = rawval.strip()
223
+
224
+ try:
225
+ conv = Registry.convs[self.etype]
226
+
227
+ if self.ctype is None:
228
+ # Parse as scalar
229
+ return conv(rawval)
230
+
231
+ # Parse as container
232
+ if self.sep == ' ':
233
+ items = rawval.split() # split by arbitrary whitespace
234
+ elif self.sep == '':
235
+ items = list(rawval) # split by char
236
+ else:
237
+ items = rawval.split(self.sep)
238
+
239
+ elems = [conv(x.strip()) for x in items if x.strip() != '']
240
+
241
+ return self.ctype(elems)
242
+ except ValueError as e:
243
+ raise ValueError(f"failed to parse '{rawval}' as {self.etype}: {e}")
244
+
245
+ def __getattr__(self, name: str) -> Value:
246
+ if name in self.flags:
247
+ return self.flags[name]
248
+ raise AttributeError(name)
249
+
250
+
251
+ @dataclass
252
+ class DSLParser:
253
+ field: Field
254
+
255
+ def parse(self, dsl: str) -> None:
256
+ """Parses the DSL string into a Field object."""
257
+ # Initialize form as None, implied scalar unless modifiers change it.
258
+ for mod in self._split_modifiers(dsl):
259
+ if mod.strip():
260
+ self._apply_modifier(mod.strip())
261
+
262
+ def _split_modifiers(self, text: str) -> list[str]:
263
+ """Splits the DSL string by comma, ignoring commas inside quotes."""
264
+ parts, current, quote_char = [], [], None
265
+
266
+ for ch in text:
267
+ if quote_char:
268
+ current.append(ch)
269
+ if ch == quote_char:
270
+ quote_char = None
271
+ else:
272
+ if ch in ('"', "'"):
273
+ quote_char = ch
274
+ current.append(ch)
275
+ elif ch == ',':
276
+ parts.append(''.join(current))
277
+ current = []
278
+ else:
279
+ current.append(ch)
280
+
281
+ if current:
282
+ parts.append(''.join(current))
283
+
284
+ return parts
285
+
286
+ def _apply_modifier(self, mod: str):
287
+ clean_mod = mod.strip()
288
+ lower_mod = clean_mod.lower()
289
+
290
+ # Match: XXX of XXX (e.g., "list of int")
291
+ if match := re.match(r'^([a-zA-Z_]+)\s+of\s+([a-zA-Z_]+)$', lower_mod):
292
+ form, etype = match.groups()
293
+
294
+ if etype not in Registry.etypes:
295
+ raise ValueError(
296
+ f'unsupported type: "{etype}". '
297
+ f'available: {list(Registry.etypes.keys())}'
298
+ )
299
+ if form not in Registry.forms:
300
+ raise ValueError(
301
+ f'unsupported form: "{form}". '
302
+ f'available: {list(Registry.forms.keys())}'
303
+ )
304
+
305
+ self.field.etype = Registry.etypes[etype]
306
+ self.field.ctype = Registry.forms[form].ctype
307
+ self.field.flags[Registry._sep_flag.name] = Registry.forms[form].sep
308
+ return
309
+
310
+ # Match: Type only (e.g., "int")
311
+ if lower_mod in Registry.etypes:
312
+ self.field.etype = Registry.etypes[lower_mod]
313
+ return
314
+
315
+ # Match: XXX by XXX (e.g., "sep by '|'")
316
+ if match := re.match(r'^([a-zA-Z_]+)\s+by\s+(.+)$', clean_mod, re.IGNORECASE):
317
+ flagname, rawval = match.groups()
318
+
319
+ if flagname not in Registry.byflags:
320
+ raise ValueError(
321
+ f'unsupported flag: "{flagname}" by. '
322
+ f'available: {list(Registry.byflags.keys())}'
323
+ )
324
+
325
+ flags = self.field.flags
326
+ flag = Registry.byflags[flagname]
327
+ val = Registry.convs[flag.etype](rawval)
328
+
329
+ if flag.store == 'assign':
330
+ flags[flag.name] = val
331
+ elif flag.store == 'append':
332
+ if flags[flag.name] is None:
333
+ flags[flag.name] = []
334
+ vals = flags[flag.name]
335
+ assert isinstance(vals, list)
336
+ vals.append(val)
337
+ else:
338
+ raise ValueError(
339
+ f'unsupported flag store: "{flag.store}". available: {FlagStore}'
340
+ )
341
+
342
+ # Deal with builtin flags.
343
+ if flag == Registry._sep_flag:
344
+ # ctype default to list if 'sep by' is used without a 'xxx of xxx'.
345
+ if self.field.ctype is None:
346
+ self.field.ctype = list
347
+
348
+ return
349
+
350
+ # Match: bool flags.
351
+ if lower_mod in Registry.flags:
352
+ flag = Registry.flags[lower_mod]
353
+ self.field.flags[flag.name] = not flag.default
354
+ return
355
+
356
+ raise ValueError(f"unknown modifier: '{mod}'")
357
+
358
+
359
+ @dataclass(frozen=True)
360
+ class Schema(object):
361
+ name: Field | None
362
+ attrs: dict[str, Field]
363
+ content: Field | None
364
+
365
+ @classmethod
366
+ def from_dsl(
367
+ cls,
368
+ name: str | None = None,
369
+ attrs: dict[str, str] = {},
370
+ content: str | None = None,
371
+ ) -> Self:
372
+ name_field = Field.from_dsl(name) if name else None
373
+ attrs_field = {k: Field.from_dsl(v) for k, v in attrs.items()}
374
+ cont_field = Field.from_dsl(content) if content else None
375
+
376
+ return cls(name_field, attrs_field, cont_field)
377
+
378
+ def _parse_single(
379
+ self, field: tuple[str, Field | None], rawval: str | None
380
+ ) -> Value:
381
+ if field[1] is None and rawval is not None:
382
+ raise ValueError(
383
+ f'parsing {field[0]}: no argument is allowed; "{rawval}" supplied'
384
+ )
385
+
386
+ try:
387
+ assert field[1] is not None
388
+ return field[1].parse(rawval)
389
+ except Exception as e:
390
+ raise ValueError(f'parsing {field[0]}: {e}')
391
+
392
+ def parse(self, data: RawData) -> Data:
393
+ if data.name:
394
+ name = self._parse_single(('name', self.name), data.name)
395
+ else:
396
+ name = None
397
+
398
+ attrs = {}
399
+ if isinstance(self.attrs, Field):
400
+ for key, rawval in data.attrs.items():
401
+ attrs[key] = self._parse_single(('attrs.' + key, self.attrs), rawval)
402
+ else:
403
+ rawattrs = data.attrs.copy()
404
+ for key, field in self.attrs.items():
405
+ rawval = rawattrs.pop(key)
406
+ attrs[key] = self._parse_single(('attrs.' + key, field), rawval)
407
+ for key, rawval in rawattrs.items():
408
+ raise ValueError(f'unknown attr: "{key}"')
409
+
410
+ if data.content:
411
+ content = self._parse_single(('content', self.content), data.content)
412
+ else:
413
+ content = None
414
+
415
+ return Data(name, attrs, content)
416
+
417
+ def fields(self, pred: Callable[[Field], bool] | None = None) -> Generator[tuple[str, Field]]:
418
+ def ok(f: Field) -> bool:
419
+ return not pred or pred(f)
420
+
421
+ if self.name and ok(self.name):
422
+ yield 'name', self.name
423
+
424
+ for name, field in self.attrs.items():
425
+ if ok(field):
426
+ yield name, field
427
+
428
+ if self.content and ok(self.content):
429
+ yield 'content', self.content
430
+
431
+
432
+ def items(
433
+ self, data: Data, pred: Callable[[Field], bool] | None = None
434
+ ) -> Generator[tuple[str, Field, Value]]:
435
+ def ok(f: Field) -> bool:
436
+ return not pred or pred(f)
437
+
438
+ if self.name and ok(self.name):
439
+ yield 'name', self.name, data.name
440
+
441
+ for name, field in self.attrs.items():
442
+ if not ok(field):
443
+ continue
444
+ yield name, field, data.attrs.get(name)
445
+
446
+ if self.content and ok(self.content):
447
+ yield 'content', self.content, data.content
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+ from typing import Any
3
+
4
+ from docutils import nodes
5
+ from sphinx.util.docutils import SphinxDirective, SphinxRole
6
+ from sphinx.transforms import SphinxTransform
7
+
8
+ from .utils import find_current_document, find_current_section
9
+ from .utils.context_proxy import proxy
10
+
11
+
12
+ def markup(v: SphinxDirective | SphinxRole | nodes.Element) -> dict[str, Any]:
13
+ ctx = {}
14
+
15
+ if isinstance(v, nodes.Element):
16
+ ctx['_markup'] = {}
17
+ else:
18
+ isdir = isinstance(v, SphinxDirective)
19
+ ctx['_markup'] = {
20
+ 'type': 'directive' if isdir else 'role',
21
+ 'name': v.name,
22
+ 'lineno': v.lineno,
23
+ 'rawtext': v.block_text if isdir else v.rawtext,
24
+ }
25
+ return ctx
26
+
27
+
28
+ def doctree(v: SphinxDirective | SphinxRole | nodes.Node) -> dict[str, Any]:
29
+ ctx = {}
30
+ if isinstance(v, nodes.Node):
31
+ ctx['_doc'] = proxy(find_current_document(v))
32
+ ctx['_section'] = proxy(find_current_section(v))
33
+ else:
34
+ isdir = isinstance(v, SphinxDirective)
35
+ state = v.state if isdir else v.inliner
36
+ ctx['_doc'] = proxy(state.document)
37
+ ctx['_section'] = proxy(find_current_section(state.parent))
38
+ return ctx
39
+
40
+
41
+ def sphinx(v: SphinxDirective | SphinxRole | SphinxTransform) -> dict[str, Any]:
42
+ ctx = {}
43
+ ctx['_env'] = proxy(v.env)
44
+ ctx['_config'] = proxy(v.config)
45
+ return ctx
@@ -0,0 +1,35 @@
1
+ # This file is generated from sphinx-notes/cookiecutter.
2
+ # DO NOT EDIT!!!
3
+
4
+ ################################################################################
5
+ # Project meta infos.
6
+ ################################################################################
7
+
8
+ from __future__ import annotations
9
+ from importlib import metadata
10
+
11
+ __project__ = 'sphinxnotes-dataview'
12
+ __author__ = 'Shengyu Zhang'
13
+ __desc__ = 'Create and view data in Sphinx documentation'
14
+
15
+ try:
16
+ __version__ = metadata.version('sphinxnotes-dataview')
17
+ except metadata.PackageNotFoundError:
18
+ __version__ = 'unknown'
19
+
20
+
21
+ ################################################################################
22
+ # Sphinx extension utils.
23
+ ################################################################################
24
+
25
+
26
+ def pre_setup(app):
27
+ app.require_sphinx('7.0')
28
+
29
+
30
+ def post_setup(app):
31
+ return {
32
+ 'version': __version__,
33
+ 'parallel_read_safe': True,
34
+ 'parallel_write_safe': True,
35
+ }