sphinxnotes.render 1.0a42__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,546 @@
1
+ """
2
+ sphinxnotes.render.data
3
+ ~~~~~~~~~~~~~~~~~~~~~~~
4
+
5
+ Context data 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
+ from .utils import Unpicklable
18
+
19
+ if TYPE_CHECKING:
20
+ from typing import Any, Callable, Generator, Self, Literal
21
+
22
+ # ===================================
23
+ # Basic types: Value, Form, Flag, ...
24
+ # ===================================
25
+
26
+ type PlainValue = bool | int | float | str | object
27
+ type Value = None | PlainValue | list[PlainValue]
28
+
29
+
30
+ @dataclass
31
+ class ValueWrapper:
32
+ v: Value
33
+
34
+ # TODO: __post_init__ to assert type
35
+
36
+ def as_plain(self) -> PlainValue | None:
37
+ if self.v is None:
38
+ return None
39
+ if isinstance(self.v, list):
40
+ return self.v[0] if len(self.v) else None
41
+ return self.v
42
+
43
+ def as_list(self) -> list[PlainValue]:
44
+ if self.v is None:
45
+ return []
46
+ elif isinstance(self.v, list):
47
+ return [x for x in self.v]
48
+ else:
49
+ return [self.v]
50
+
51
+ def as_str(self) -> str | None:
52
+ v = self.as_plain()
53
+ return self._strify(v) if v is not None else None
54
+
55
+ def as_str_list(self) -> list[str]:
56
+ return [self._strify(x) for x in self.as_list()]
57
+
58
+ @staticmethod
59
+ def _strify(v: PlainValue) -> str:
60
+ return REGISTRY.strifys[type(v)](v)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class Form:
65
+ """Defines how to split the string and the container type."""
66
+
67
+ ctype: type
68
+ sep: str
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class Flag:
73
+ name: str
74
+ default: bool
75
+
76
+
77
+ type ByOptionStore = Literal['assign', 'append']
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ByOption:
82
+ name: str
83
+ etype: type
84
+ default: Value
85
+ store: ByOptionStore
86
+
87
+
88
+ # ========
89
+ # Registry
90
+ # ========
91
+
92
+
93
+ def _bool_conv(v: str | None) -> bool:
94
+ v = v.lower().strip() if v is not None else None
95
+ if v in ('true', 'yes', '1', 'on', 'y', ''):
96
+ return True
97
+ if v in ('false', 'no', '0', 'off', 'n', None):
98
+ return False
99
+ # Same to :meth:`directives.flag`.
100
+ raise ValueError(f'No argument is allowed; "{v}" supplied')
101
+
102
+
103
+ def _str_conv(v: str) -> str:
104
+ try:
105
+ vv = literal_eval(v)
106
+ except (ValueError, SyntaxError):
107
+ return v
108
+ return vv if isinstance(vv, str) else v
109
+
110
+
111
+ class Registry:
112
+ """Stores supported element types and element forms (containers)."""
113
+
114
+ etypes: dict[str, type]
115
+ ctypes: set[type] = {list, tuple, set}
116
+ convs: dict[type, Callable[[str], PlainValue]]
117
+ strifys: dict[type, Callable[[PlainValue], str]]
118
+ forms: dict[str, Form]
119
+ flags: dict[str, Flag]
120
+ byopts: dict[str, ByOption]
121
+
122
+ _sep_by_option: ByOption
123
+
124
+ def __init__(self) -> None:
125
+ self.etypes = {}
126
+ self.convs = {}
127
+ self.strifys = {}
128
+ self.forms = {}
129
+ self.flags = {}
130
+ self.byopts = {}
131
+
132
+ # Add builtin types.
133
+ self.add_type('bool', bool, _bool_conv, str, aliases=['flag'])
134
+ self.add_type('int', int, int, strify=str, aliases=['integer'])
135
+ self.add_type('float', float, float, str, aliases=['number', 'num'])
136
+ self.add_type('str', str, _str_conv, str, aliases=['string'])
137
+
138
+ # Add builtin forms.
139
+ self.add_form('list', list, ',')
140
+ self.add_form('lines', list, '\n')
141
+ self.add_form('words', list, ' ')
142
+ self.add_form('set', set, ' ')
143
+
144
+ # Add builtin flags.
145
+ self.add_flag('required', False, aliases=['require', 'req'])
146
+
147
+ # Add builtin by-option.
148
+ self.add_by_option('sep', str, aliases=['separate'])
149
+ # NOTE: the "sep" by-option is a special builtin flag, extract it for
150
+ # later usage.
151
+ self._sep_by_option = self.byopts['sep']
152
+
153
+ # from pprint import pprint
154
+ # pprint(cls.__dict__)
155
+
156
+ def add_type(
157
+ self,
158
+ name: str,
159
+ etype: type,
160
+ conv: Callable[[str], PlainValue],
161
+ strify: Callable[[PlainValue], str],
162
+ aliases: list[str] = [],
163
+ ) -> None:
164
+ self.etypes[name] = etype
165
+ self.convs[etype] = conv
166
+ self.strifys[etype] = strify
167
+
168
+ for alias in aliases:
169
+ self.etypes[alias] = etype
170
+
171
+ def add_form(
172
+ self, name: str, ctype: type, sep: str, aliases: list[str] = []
173
+ ) -> None:
174
+ if ctype not in self.ctypes:
175
+ raise ValueError(f'Unsupported type: "{ctype}". Available: {self.ctypes}')
176
+
177
+ form = Form(ctype, sep)
178
+
179
+ self.forms[name] = form
180
+ for alias in aliases:
181
+ self.forms[alias] = form
182
+
183
+ def add_flag(
184
+ self, name: str, default: bool = False, aliases: list[str] = []
185
+ ) -> None:
186
+ flag = Flag(name, default)
187
+
188
+ self.flags[flag.name] = flag
189
+ for alias in aliases:
190
+ self.flags[alias] = flag
191
+
192
+ def add_by_option(
193
+ self,
194
+ name: str,
195
+ etype: type,
196
+ default: Value = None,
197
+ store: ByOptionStore = 'assign',
198
+ aliases: list[str] = [],
199
+ ) -> None:
200
+ opt = ByOption(name, etype, default, store)
201
+
202
+ self.byopts[opt.name] = opt
203
+ for alias in aliases:
204
+ self.byopts[alias] = opt
205
+
206
+
207
+ REGISTRY = Registry()
208
+
209
+ # ======================
210
+ # Data, Field and Schema
211
+ # ======================
212
+
213
+
214
+ @dataclass
215
+ class RawData:
216
+ name: str | None
217
+ attrs: dict[str, str]
218
+ content: str | None
219
+
220
+ def __hash__(self) -> int:
221
+ return hash((self.name, frozenset(self.attrs.items()), self.content))
222
+
223
+
224
+ @dataclass
225
+ class ParsedData:
226
+ name: Value
227
+ attrs: dict[str, Value]
228
+ content: Value
229
+
230
+ def __hash__(self) -> int:
231
+ return hash((self.name, frozenset(self.attrs.items()), self.content))
232
+
233
+ def asdict(self) -> dict[str, Any]:
234
+ """
235
+ Convert Data to a dict for usage of Jinja2 context.
236
+
237
+ ``self.attrs`` will be automaticlly lifted to top-level context when
238
+ there is no key conflicts. For example:
239
+
240
+ - You can access ``Data.attrs['color']`` by "{{ color }}"" instead
241
+ of "{{ attrs.color }}".
242
+ - You can NOT access ``Data.attrs['name']`` by "{{ name }}" cause
243
+ the variable name is taken by ``Data.name``.
244
+ """
245
+ ctx = asdict(self)
246
+ for k, v in self.attrs.items():
247
+ if k not in ctx:
248
+ ctx[k] = v
249
+ return ctx
250
+
251
+
252
+ @dataclass
253
+ class Field(Unpicklable):
254
+ #: Type of element.
255
+ etype: type = str
256
+ #: Type of container (if the field holds multiple values).
257
+ ctype: type | None = None
258
+ #: Flags of field.
259
+ flags: dict[str, Value] = dataclass_field(default_factory=dict)
260
+ #: The orginal DSL.
261
+ dsl: str | None = None
262
+
263
+ # Type hints for builtin flags.
264
+ if TYPE_CHECKING:
265
+ required: bool = False
266
+ sep: str | None = None
267
+
268
+ def __hash__(self) -> int:
269
+ flags = {
270
+ k: v if not isinstance(v, list) else tuple(v) for k, v in self.flags.items()
271
+ }
272
+ return hash((self.etype, self.ctype, frozenset(flags.items()), self.dsl))
273
+
274
+ @classmethod
275
+ def from_dsl(cls, dsl: str) -> Self:
276
+ self = cls()
277
+ DSLParser(self).parse(dsl)
278
+ return self
279
+
280
+ def __post_init__(self) -> None:
281
+ # Init flags and by flags.
282
+ for flag in REGISTRY.flags.values():
283
+ if flag.name not in self.flags:
284
+ self.flags[flag.name] = flag.default
285
+ for opt in REGISTRY.byopts.values():
286
+ if opt.name in self.flags:
287
+ continue
288
+ if opt.store == 'assign':
289
+ self.flags[opt.name] = opt.default
290
+ elif opt.store == 'append':
291
+ self.flags[opt.name] = lst = []
292
+ if opt.default is not None:
293
+ lst.append(opt.default)
294
+ else:
295
+ raise DSLParser.by_option_store_value_error(opt)
296
+
297
+ def parse(self, rawval: str | None) -> Value:
298
+ """
299
+ Parses the raw input string into the target Value.
300
+ When a None is passed, which means the field is not supplied.
301
+ """
302
+ if rawval is None:
303
+ if self.required:
304
+ # Same to :meth:`directives.unchanged_required`.
305
+ raise ValueError('Argument required but none supplied')
306
+ if self.ctype:
307
+ # Return a empty container when a None value is optional.
308
+ return self.ctype()
309
+ if self.etype is bool:
310
+ # Special case: A single bool field is valid even when
311
+ # value is not supplied.
312
+ return _bool_conv(rawval)
313
+ return None
314
+
315
+ # Strip whitespace. TODO: supported unchanged?
316
+ rawval = rawval.strip()
317
+
318
+ try:
319
+ conv = REGISTRY.convs[self.etype]
320
+
321
+ if self.ctype is None:
322
+ # Parse as scalar
323
+ return conv(rawval)
324
+
325
+ # Parse as container
326
+ if self.sep == ' ':
327
+ items = rawval.split() # split by arbitrary whitespace
328
+ elif self.sep == '':
329
+ items = list(rawval) # split by char
330
+ else:
331
+ items = rawval.split(self.sep)
332
+
333
+ elems = [conv(x.strip()) for x in items if x.strip() != '']
334
+
335
+ return self.ctype(elems)
336
+ except ValueError as e:
337
+ raise ValueError(f"Failed to parse '{rawval}' as {self.etype}: {e}") from e
338
+
339
+ def __getattr__(self, name: str) -> Value:
340
+ if name in self.flags:
341
+ return self.flags[name]
342
+ raise AttributeError(name)
343
+
344
+
345
+ @dataclass
346
+ class DSLParser:
347
+ field: Field
348
+
349
+ def parse(self, dsl: str) -> None:
350
+ """Parses the DSL string into a Field object."""
351
+ self.field.dsl = dsl
352
+ for mod in self._split_modifiers(dsl):
353
+ if mod.strip():
354
+ self._apply_modifier(mod.strip())
355
+
356
+ def _split_modifiers(self, text: str) -> list[str]:
357
+ """Splits the DSL string by comma, ignoring commas inside quotes."""
358
+ parts, current, quote_char = [], [], None
359
+
360
+ for ch in text:
361
+ if quote_char:
362
+ current.append(ch)
363
+ if ch == quote_char:
364
+ quote_char = None
365
+ else:
366
+ if ch in ('"', "'"):
367
+ quote_char = ch
368
+ current.append(ch)
369
+ elif ch == ',':
370
+ parts.append(''.join(current))
371
+ current = []
372
+ else:
373
+ current.append(ch)
374
+
375
+ if current:
376
+ parts.append(''.join(current))
377
+
378
+ return parts
379
+
380
+ def _apply_modifier(self, mod: str):
381
+ clean_mod = mod.strip()
382
+ lower_mod = clean_mod.lower()
383
+
384
+ # Match: XXX of XXX (e.g., "list of int")
385
+ if match := re.match(r'^([a-zA-Z_]+)\s+of\s+([a-zA-Z_]+)$', lower_mod):
386
+ form, etype = match.groups()
387
+
388
+ if etype not in REGISTRY.etypes:
389
+ raise ValueError(
390
+ f'Unsupported type: "{etype}". '
391
+ f'Available: {list(REGISTRY.etypes.keys())}'
392
+ )
393
+ if form not in REGISTRY.forms:
394
+ raise ValueError(
395
+ f'Unsupported form: "{form}". '
396
+ f'Available: {list(REGISTRY.forms.keys())}'
397
+ )
398
+
399
+ self.field.etype = REGISTRY.etypes[etype]
400
+ self.field.ctype = REGISTRY.forms[form].ctype
401
+ self.field.flags[REGISTRY._sep_by_option.name] = REGISTRY.forms[form].sep
402
+ return
403
+
404
+ # Match: Type only (e.g., "int")
405
+ if lower_mod in REGISTRY.etypes:
406
+ self.field.etype = REGISTRY.etypes[lower_mod]
407
+ return
408
+
409
+ # Match: by-option, "XXX by XXX" (e.g., "sep by '|'")
410
+ if match := re.match(r'^([a-zA-Z_]+)\s+by\s+(.+)$', clean_mod, re.IGNORECASE):
411
+ optname, rawval = match.groups()
412
+
413
+ if optname not in REGISTRY.byopts:
414
+ raise ValueError(
415
+ f'Unsupported by-option: "{optname}" by. '
416
+ f'Available: {list(REGISTRY.byopts.keys())}'
417
+ )
418
+
419
+ flags = self.field.flags
420
+ opt = REGISTRY.byopts[optname]
421
+ val = REGISTRY.convs[opt.etype](rawval)
422
+
423
+ if opt.store == 'assign':
424
+ flags[opt.name] = val
425
+ elif opt.store == 'append':
426
+ vals = flags[opt.name]
427
+ assert isinstance(vals, list)
428
+ vals.append(val)
429
+ else:
430
+ raise self.by_option_store_value_error(opt)
431
+
432
+ # Deal with special by option.
433
+ if opt == REGISTRY._sep_by_option:
434
+ # ctype default to list if 'sep by' is used without a 'xxx of xxx'.
435
+ if self.field.ctype is None:
436
+ self.field.ctype = list
437
+
438
+ return
439
+
440
+ # Match: flags.
441
+ if lower_mod in REGISTRY.flags:
442
+ opt = REGISTRY.flags[lower_mod]
443
+ self.field.flags[opt.name] = not opt.default
444
+ return
445
+
446
+ raise ValueError(f"Unknown modifier: '{mod}'")
447
+
448
+ @staticmethod
449
+ def by_option_store_value_error(opt: ByOption) -> ValueError:
450
+ raise ValueError(
451
+ f'Unsupported by-option store: "{opt.store}". '
452
+ f'Available: {ByOptionStore}' # FIXME:
453
+ )
454
+
455
+
456
+ @dataclass(frozen=True)
457
+ class Schema(Unpicklable):
458
+ name: Field | None
459
+ attrs: dict[str, Field] | Field
460
+ content: Field | None
461
+
462
+ def __hash__(self) -> int:
463
+ if isinstance(self.attrs, Field):
464
+ attrs_hash = hash(self.attrs)
465
+ else:
466
+ attrs_hash = hash(frozenset(self.attrs.items()))
467
+ return hash((self.name, attrs_hash, self.content))
468
+
469
+ @classmethod
470
+ def from_dsl(
471
+ cls,
472
+ name: str | None = None,
473
+ attrs: dict[str, str] = {},
474
+ content: str | None = None,
475
+ ) -> Self:
476
+ name_field = Field.from_dsl(name) if name else None
477
+ attrs_field = {k: Field.from_dsl(v) for k, v in attrs.items()}
478
+ cont_field = Field.from_dsl(content) if content else None
479
+
480
+ return cls(name_field, attrs_field, cont_field)
481
+
482
+ def _parse_single(
483
+ self, field: tuple[str, Field | None], rawval: str | None
484
+ ) -> Value:
485
+ if field[1] is None and rawval is not None:
486
+ raise ValueError(
487
+ f'Parsing {field[0]}: no argument is allowed; "{rawval}" supplied'
488
+ )
489
+
490
+ try:
491
+ assert field[1] is not None
492
+ return field[1].parse(rawval)
493
+ except Exception as e:
494
+ raise ValueError(f'Parsing {field[0]}: {e}')
495
+
496
+ def parse(self, data: RawData) -> ParsedData:
497
+ if data.name:
498
+ name = self._parse_single(('name', self.name), data.name)
499
+ else:
500
+ name = None
501
+
502
+ attrs = {}
503
+ if isinstance(self.attrs, Field):
504
+ for key, rawval in data.attrs.items():
505
+ attrs[key] = self._parse_single(('attrs.' + key, self.attrs), rawval)
506
+ else:
507
+ rawattrs = data.attrs.copy()
508
+ for key, field in self.attrs.items():
509
+ rawval = rawattrs.pop(key, None)
510
+ attrs[key] = self._parse_single(('attrs.' + key, field), rawval)
511
+ for key, rawval in rawattrs.items():
512
+ raise ValueError(f'Unknown attr: "{key}"')
513
+
514
+ if data.content:
515
+ content = self._parse_single(('content', self.content), data.content)
516
+ else:
517
+ content = None
518
+
519
+ return ParsedData(name, attrs, content)
520
+
521
+ def fields(self) -> Generator[tuple[str, Field]]:
522
+ if self.name:
523
+ yield 'name', self.name
524
+
525
+ if isinstance(self.attrs, Field):
526
+ yield 'attrs', self.attrs
527
+ else:
528
+ for name, field in self.attrs.items():
529
+ yield name, field
530
+
531
+ if self.content:
532
+ yield 'content', self.content
533
+
534
+ def items(self, data: ParsedData) -> Generator[tuple[str, Field, Value]]:
535
+ if self.name:
536
+ yield 'name', self.name, data.name
537
+
538
+ if isinstance(self.attrs, Field):
539
+ for name, val in data.attrs:
540
+ yield name, self.attrs, val
541
+ else:
542
+ for name, field in self.attrs.items():
543
+ yield name, field, data.attrs.get(name)
544
+
545
+ if self.content:
546
+ yield 'content', self.content, data.content