From 6a3401e9164b1972c69b18e7848906a88ee93228 Mon Sep 17 00:00:00 2001 From: Shengyu Zhang Date: Wed, 21 Aug 2024 21:59:05 +0800 Subject: [PATCH] feat: Support custom index generation by schema.Classifier (#37) Date Index is supported too now :D --- docs/_schemas/cat.py | 6 +- docs/_schemas/dog1.py | 4 +- docs/_schemas/dog2.py | 4 +- docs/_schemas/tmplvar.py | 2 +- docs/conf.py | 20 +- docs/usage.rst | 8 +- src/sphinxnotes/any/__init__.py | 5 +- src/sphinxnotes/any/domain.py | 47 +++-- src/sphinxnotes/any/indices.py | 147 +++++++++----- src/sphinxnotes/any/roles.py | 1 + src/sphinxnotes/any/schema.py | 343 ++++++++++++++++++++++++-------- 11 files changed, 421 insertions(+), 166 deletions(-) diff --git a/docs/_schemas/cat.py b/docs/_schemas/cat.py index 971d18d..69f31e8 100644 --- a/docs/_schemas/cat.py +++ b/docs/_schemas/cat.py @@ -3,10 +3,10 @@ cat = Schema( 'cat', - name=Field(referenceable=True, form=Field.Form.LINES), + name=Field(ref=True, form=Field.Forms.LINES), attrs={ - 'id': Field(unique=True, referenceable=True, required=True), - 'color': Field(referenceable=True), + 'id': Field(uniq=True, ref=True, required=True), + 'color': Field(ref=True), 'picture': Field(), }, description_template=dedent(""" diff --git a/docs/_schemas/dog1.py b/docs/_schemas/dog1.py index c1ec249..d0c23e7 100644 --- a/docs/_schemas/dog1.py +++ b/docs/_schemas/dog1.py @@ -4,8 +4,8 @@ dog = Schema( 'dog', attrs={ - 'breed': Field(referenceable=True), - 'color': Field(referenceable=True, form=Field.Form.WORDS), + 'breed': Field(ref=True), + 'color': Field(ref=True, form=Field.Forms.WORDS), }, description_template=dedent(""" :Breed: {{ breed }} diff --git a/docs/_schemas/dog2.py b/docs/_schemas/dog2.py index 2710eb5..1d0ecd8 100644 --- a/docs/_schemas/dog2.py +++ b/docs/_schemas/dog2.py @@ -4,8 +4,8 @@ dog = Schema( 'dog', attrs={ - 'breed': Field(referenceable=True), - 'color': Field(referenceable=True, form=Field.Form.WORDS), + 'breed': Field(ref=True), + 'color': Field(ref=True, form=Field.Forms.WORDS), }, description_template=dedent(""" :Breed: :any:dog.breed:`{{ breed }}` diff --git a/docs/_schemas/tmplvar.py b/docs/_schemas/tmplvar.py index f5ea9d5..dde5649 100644 --- a/docs/_schemas/tmplvar.py +++ b/docs/_schemas/tmplvar.py @@ -3,7 +3,7 @@ tmplvar = Schema( 'tmplvar', - name=Field(unique=True, referenceable=True), + name=Field(uniq=True, ref=True), attrs={ 'type': Field(), 'conf': Field(), diff --git a/docs/conf.py b/docs/conf.py index 4aa7a76..72b7e16 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -114,25 +114,27 @@ # # DOG FOOD CONFIGURATION START -from any import Schema, Field as F +from any import Schema, Field as F, DateClassifier sys.path.insert(0, os.path.abspath('.')) version_schema = Schema('version', - name=F(unique=True, referenceable=True, required=True, form=F.Form.LINES), - attrs={'date': F(referenceable=True)}, - content=F(form=F.Form.LINES), + name=F(uniq=True, ref=True, required=True, form=F.Forms.LINES), + attrs={ + 'date': F(ref=True, classifiers=[DateClassifier(['%Y-%m-%d'])]), + }, + content=F(form=F.Forms.LINES), description_template=open('_templates/version.rst', 'r').read(), reference_template='🏷️{{ title }}', missing_reference_template='🏷️{{ title }}', ambiguous_reference_template='🏷️{{ title }}') confval_schema = Schema('confval', - name=F(unique=True, referenceable=True, required=True, form=F.Form.LINES), + name=F(uniq=True, ref=True, required=True, form=F.Forms.LINES), attrs={ 'type': F(), 'default': F(), - 'choice': F(form=F.Form.WORDS), + 'choice': F(form=F.Forms.WORDS), 'versionadded': F(), - 'versionchanged': F(form=F.Form.LINES), + 'versionchanged': F(form=F.Forms.LINES), }, content=F(), description_template=open('_templates/confval.rst', 'r').read(), @@ -140,9 +142,9 @@ missing_reference_template='⚙️{{ title }}', ambiguous_reference_template='⚙️{{ title }}') example_schema = Schema('example', - name=F(referenceable=True), + name=F(ref=True), attrs={'style': F()}, - content=F(form=F.Form.LINES), + content=F(form=F.Forms.LINES), description_template=open('_templates/example.rst', 'r').read(), reference_template='📝{{ title }}', missing_reference_template='📝{{ title }}', diff --git a/docs/usage.rst b/docs/usage.rst index 6df53d5..661fbf7 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -30,11 +30,11 @@ The necessary python classes for writing schema are listed here: | -.. autoclass:: any.Field.Form +.. autoclass:: any.Field.Forms - .. autoattribute:: any.Field.Form.PLAIN - .. autoattribute:: any.Field.Form.WORDS - .. autoattribute:: any.Field.Form.LINES + .. autoattribute:: any.Field.Forms.PLAIN + .. autoattribute:: any.Field.Forms.WORDS + .. autoattribute:: any.Field.Forms.LINES Documenting Object ================== diff --git a/src/sphinxnotes/any/__init__.py b/src/sphinxnotes/any/__init__.py index 9053f1b..2c8022a 100644 --- a/src/sphinxnotes/any/__init__.py +++ b/src/sphinxnotes/any/__init__.py @@ -14,7 +14,7 @@ from .template import Environment as TemplateEnvironment from .domain import AnyDomain, warn_missing_reference -from .schema import Schema, Field +from .schema import Schema, Field, DateClassifier if TYPE_CHECKING: from sphinx.application import Sphinx @@ -24,9 +24,10 @@ logger = logging.getLogger(__name__) -# Export +# Re-Export Field = Field Schema = Schema +DateClassifier = DateClassifier def _config_inited(app: Sphinx, config: Config) -> None: diff --git a/src/sphinxnotes/any/domain.py b/src/sphinxnotes/any/domain.py index 07b0f1e..c119a41 100644 --- a/src/sphinxnotes/any/domain.py +++ b/src/sphinxnotes/any/domain.py @@ -18,7 +18,7 @@ from sphinx.util import logging from sphinx.util.nodes import make_refnode -from .schema import Schema, Object +from .schema import Schema, Object, PlainClassifier from .directives import AnyDirective from .roles import AnyRole from .indices import AnyIndex @@ -176,19 +176,20 @@ def add_schema(cls, schema: Schema) -> None: # Add to schemas dict cls._schemas[schema.objtype] = schema - # Generates reftypes(role names) for all referenceable fields reftypes = [schema.objtype] - for name, field, _ in schema.fields_of(None): - if field.referenceable: - reftypes.append(objtype_and_objfield_to_reftype(schema.objtype, name)) - - # Roles is used for converting role name to corrsponding objtype - cls.object_types[schema.objtype] = ObjType(schema.objtype, *reftypes) - cls.directives[schema.objtype] = AnyDirective.derive(schema) - for r in reftypes: - # Create role for referencing object (by various fields) - _, field = reftype_to_objtype_and_objfield(r) - cls.roles[r] = AnyRole.derive(schema, field)( + for name, field in schema.fields(all=False): + if not field.ref: + continue + + # Generates reftypes for all referenceable fields + # For later use when generating roles and indices. + reftype = objtype_and_objfield_to_reftype(schema.objtype, name) + reftypes.append(reftype) + + for reftype in reftypes: + _, field = reftype_to_objtype_and_objfield(reftype) + # Create role for referencing object by field + cls.roles[reftype] = AnyRole.derive(schema, field)( # Emit warning when missing reference (node['refwarn'] = True) warn_dangling=True, # Inner node (contnode) would be replaced in resolve_xref method, @@ -196,9 +197,23 @@ def add_schema(cls, schema: Schema) -> None: innernodeclass=literal, ) - index = AnyIndex.derive(schema, field) - cls.indices.append(index) - cls._indices_for_reftype[r] = index + # FIXME: name and content can not be index now + if field is not None: + classifiers = schema.attrs[field].classifiers + elif schema.name is not None: + classifiers = schema.name.classifiers + else: + classifiers = [PlainClassifier()] + # Generates index for indexing object by fields + for indexer in classifiers: + index = AnyIndex.derive(schema, field, indexer) + cls.indices.append(index) + cls._indices_for_reftype[reftype] = index # TODO: mulitple catelogers. + + # TODO: document + cls.object_types[schema.objtype] = ObjType(schema.objtype, *reftypes) + # Generates directive for creating object. + cls.directives[schema.objtype] = AnyDirective.derive(schema) def _get_index_anchor(self, reftype: str, refval: str) -> tuple[str, str]: """ diff --git a/src/sphinxnotes/any/indices.py b/src/sphinxnotes/any/indices.py index a09ab4a..6519910 100644 --- a/src/sphinxnotes/any/indices.py +++ b/src/sphinxnotes/any/indices.py @@ -8,15 +8,15 @@ :license: BSD, see LICENSE for details. """ -from typing import Iterable +from typing import Iterable, TypeVar import re -from sphinx.domains import Index, IndexEntry +from sphinx.domains import Domain, Index, IndexEntry from sphinx.util import logging from docutils import core, nodes from docutils.parsers.rst import roles -from .schema import Schema +from .schema import Schema, Value, Classifier, Classif logger = logging.getLogger(__name__) @@ -26,20 +26,20 @@ class AnyIndex(Index): Index subclass to provide the object reference index. """ + domain: Domain # for type hint schema: Schema - # TODO: document field: str | None = None - - name: str - localname: str - shortname: str + classifier: Classifier @classmethod - def derive(cls, schema: Schema, field: str | None = None) -> type['AnyIndex']: + def derive( + cls, schema: Schema, field: str | None, classifier: Classifier + ) -> type['AnyIndex']: """Generate an AnyIndex child class for indexing object.""" + # TODO: add Indexer.name if field: typ = f'Any{schema.objtype.title()}{field.title()}Index' - name = schema.objtype + '.' + field + name = schema.objtype + '.' + field # TOOD: objtype_and_objfield_to_reftype localname = f'{schema.objtype.title()} {field.title()} Reference Index' else: typ = f'Any{schema.objtype.title()}Index' @@ -49,11 +49,12 @@ def derive(cls, schema: Schema, field: str | None = None) -> type['AnyIndex']: typ, (cls,), { - 'schema': schema, - 'field': field, 'name': name, 'localname': localname, 'shortname': 'references', + 'schema': schema, + 'field': field, + 'classifier': classifier, }, ) @@ -61,53 +62,103 @@ def generate( self, docnames: Iterable[str] | None = None ) -> tuple[list[tuple[str, list[IndexEntry]]], bool]: """Override parent method.""" - content = {} # type: dict[str, list[IndexEntry]] - # list of all references - objrefs = sorted(self.domain.data['references'].items()) - # Reference value -> object IDs - objs_with_same_ref: dict[str, set[str]] = {} + # Single index for generating normal entries (subtype=0). + # Category (lv1) → Category (for ordering objids) → objids + singleidx: dict[Classif, dict[Classif, set[str]]] = {} + # Dual index for generating entrie (subtype=1) and its sub-entries (subtype=2). + # Category (lv1) → Category (lv2) → Category (for ordering objids) → objids + dualidx: dict[Classif, dict[Classif, dict[Classif, set[str]]]] = {} + objrefs = sorted(self.domain.data['references'].items()) for (objtype, objfield, objref), objids in objrefs: if objtype != self.schema.objtype: continue if self.field and objfield != self.field: continue - objs = objs_with_same_ref.setdefault(objref, set()) - objs.update(objids) - - for objref, objids in sorted(objs_with_same_ref.items()): - # Add a entry for objref - # 1: Entry with sub-entries. - entries = content.setdefault(objref, []) - for objid in sorted(objids): - docname, anchor, obj = self.domain.data['objects'][ - self.schema.objtype, objid - ] - if docnames and docname not in docnames: - continue - name = self.schema.title_of(obj) or objid - extra = '' if name == objid else objid - objcont = self.schema.content_of(obj) - if isinstance(objcont, str): - desc = objcont - elif isinstance(objcont, list): - desc = '\n'.join(objcont) + + # TODO: pass a real value + for catelog in self.classifier.classify(Value(objref)): + category = catelog.as_category() + entry = catelog.as_entry() + if entry is None: + singleidx.setdefault(category, {}).setdefault( + catelog, set() + ).update(objids) else: - desc = '' - desc = strip_rst_markups(desc) # strip rst markups - desc = ''.join( - [ln for ln in desc.split('\n') if ln.strip()] - ) # strip NEWLINE - desc = desc[:50] + '…' if len(desc) > 50 else desc # shorten - # 0: Normal entry - entries.append(IndexEntry(name, 0, docname, anchor, extra, '', desc)) - - # sort by first letter - sorted_content = sorted(content.items()) + dualidx.setdefault(category, {}).setdefault(entry, {}).setdefault( + catelog, set() + ).update(objids) + + content: dict[Classif, list[IndexEntry]] = {} # category → entries + for category, entries in self._sort_by_catelog(singleidx): + index_entries = content.setdefault(category, []) + for category, objids in self._sort_by_catelog(entries): + for objid in objids: + entry = self._generate_index_entry(objid, docnames, category) + if entry is None: + continue + index_entries.append(entry) + + for category, entries in self._sort_by_catelog(dualidx): + index_entries = content.setdefault(category, []) + for entry, subentries in self._sort_by_catelog(entries): + index_entries.append(self._generate_empty_index_entry(entry)) + for subentry, objids in self._sort_by_catelog(subentries): + for objid in objids: + entry = self._generate_index_entry(objid, docnames, subentry) + if entry is None: + continue + index_entries.append(entry) + + # sort by category, and map classif -> str + sorted_content = [ + (classif.leaf, entries) + for classif, entries in self._sort_by_catelog(content) + ] return sorted_content, False + def _generate_index_entry( + self, objid: str, ignore_docnames: Iterable[str] | None, category: Classif + ) -> IndexEntry | None: + docname, anchor, obj = self.domain.data['objects'][self.schema.objtype, objid] + if ignore_docnames and docname not in ignore_docnames: + return None + name = self.schema.title_of(obj) or objid + subtype = category.index_entry_subtype + extra = category.leaf + objcont = self.schema.content_of(obj) + if isinstance(objcont, str): + desc = objcont + elif isinstance(objcont, list): + desc = '\n'.join(objcont) # FIXME: use schema.Form + else: + desc = '' + desc = strip_rst_markups(desc) # strip rst markups + desc = ''.join([ln for ln in desc.split('\n') if ln.strip()]) # strip NEWLINE + desc = desc[:50] + '…' if len(desc) > 50 else desc # shorten + return IndexEntry( + name, # the name of the index entry to be displayed + subtype, # the sub-entry related type + docname, # docname where the entry is located + anchor, # anchor for the entry within docname + extra, # extra info for the entry + '', # qualifier for the description + desc, # description for the entry + ) + + def _generate_empty_index_entry(self, category: Classif) -> IndexEntry: + return IndexEntry( + category.leaf, category.index_entry_subtype, '', '', '', '', '' + ) + + _T = TypeVar('_T') + + def _sort_by_catelog(self, d: dict[Classif, _T]) -> list[tuple[Classif, _T]]: + """Helper for sorting dict items by Category.""" + return self.classifier.sort(d.items(), lambda x: x[0]) + def strip_rst_markups(rst: str) -> str: """Strip markups and newlines in rST. diff --git a/src/sphinxnotes/any/roles.py b/src/sphinxnotes/any/roles.py index 08f2327..a34c488 100644 --- a/src/sphinxnotes/any/roles.py +++ b/src/sphinxnotes/any/roles.py @@ -32,6 +32,7 @@ class AnyRole(XRefRole): @classmethod def derive(cls, schema: Schema, field: str | None = None) -> type['AnyRole']: """Generate an AnyRole child class for referencing object.""" + # TODO: field: Field return type( 'Any%s%sRole' % (schema.objtype.title(), field.title() if field else ''), (cls,), diff --git a/src/sphinxnotes/any/schema.py b/src/sphinxnotes/any/schema.py index 803ecc3..89aca7c 100644 --- a/src/sphinxnotes/any/schema.py +++ b/src/sphinxnotes/any/schema.py @@ -8,11 +8,12 @@ :license: BSD, see LICENSE for details. """ -from typing import Iterator, Any -from enum import Enum, auto -from dataclasses import dataclass +from typing import Iterator, Any, Iterable, Literal, TypeVar, Callable +import dataclasses import pickle import hashlib +from time import strptime +from abc import ABC, abstractmethod from sphinx.util import logging @@ -31,18 +32,208 @@ class SchemaError(AnyExtensionError): pass -@dataclass(frozen=True) +class Value(object): + """ "An immutable optional :class:`Field`.""" + + type T = None | str | list[str] + _v: T + + def __init__(self, v: T): + # TODO: type checking + self._v = v + + @property + def value(self) -> T: + return self._v + + def as_list(self) -> list[str]: + if isinstance(self._v, str): + return [self._v] + elif isinstance(self._v, list): + return self._v + else: + return [] + + def as_str(self) -> str: + return str(self._v) + + +class Form(ABC): + @abstractmethod + def extract(self, raw: str) -> Value: + """Extract :class:`Value` from field's raw value.""" + raise NotImplementedError + + +class Single(Form): + def __init__(self, strip=False): + self.strip = strip + + def extract(self, raw: str) -> Value: + return Value(raw.strip() if self.strip else raw) + + +class List(Form): + def __init__(self, sep: str, strip=False, max=-1): + self.strip = strip + self.sep = sep + self.max = max + + def extract(self, raw: str) -> Value: + strv = raw.split(self.sep, maxsplit=self.max) + if self.strip: + strv = [x.strip() for x in strv] + return Value(strv) + + +@dataclasses.dataclass +class Classif(object): + """ + Classification and sorting of an object, and generating + py:cls:`sphinx.domain.IndexEntry`. + + :py:meth:`sphinx.domain.Index.generate` returns ``(content, collapse)``, + and type of ``contents`` is a list of ``(letter, IndexEntry list)``. + letter is categoy of index entries, and some of index entries may be followed + by sub-entries (distinguish by :py:attr:`~sphinx.domains.IndexEntry.subtype`). + So Sphinx's index can represent up to 2 levels of indexing: + + :single index: letter -> normal entry + :dual index: letter -> entry with sub-entries -> sub-entry + + Classif can be used to generating all of 3 kinds of IndexEntry: + + :normal entry: Classif(category=X) + :entry with sub-entries: Classif(category=X, entry=Y) + :sub-entry: Classif(category=X, entry=Y, subentry=Z) + + .. hint:: + + In genindex_, the category is usually a single first letter, this is why + category is called "letter" here. + + .. _genindex: https://www.sphinx-doc.org/en/master/genindex.html + + """ + + #: Possible value of :py:attr:`~sphinx.domains.IndexEntry.subtype`. + #: + #: - 0: normal entry + #: - 1: entry with sub-entries + #: - 2: sub-entry + type IndexEntrySubtype = Literal[0, 1, 2] + + category: str # main category + entry: str | None = None # sub category or just for sorting + subentry: str | None = None # just for sorting + + @property + def index_entry_subtype(self) -> IndexEntrySubtype: + assert not (self.entry is None and self.subentry is not None) + if self.subentry is not None: + return 2 + if self.entry is not None: + return 1 + return 0 + + @property + def leaf(self) -> str: + if self.subentry is not None: + return self.subentry + if self.entry is not None: + return self.entry + return self.category + + def as_category(self) -> 'Classif': + return Classif(category=self.category) + + def as_entry(self) -> 'Classif | None': + if self.subentry is None: # TODO: + return None + return Classif(category=self.category, entry=self.entry) + + @property + def _sort_key(self) -> tuple[str, str | None, str | None]: + return (self.category, self.entry, self.subentry) + + def __hash__(self): + return hash(self._sort_key) + + +class Classifier(object): + name = '' + + @abstractmethod + def classify(self, objref: Value) -> list[Classif]: + raise NotImplementedError + + _T = TypeVar('_T') + + @abstractmethod + def sort(self, data: Iterable[_T], key: Callable[[_T], Classif]) -> list[_T]: + # TODO: should have same kind + raise NotImplementedError + + +class PlainClassifier(Classifier): + name = '' + + def classify(self, objref: Value) -> list[Classif]: + entries = [] + for v in objref.as_list(): + entries.append(Classif(category=v)) + return entries + + def sort( + self, data: Iterable[Classifier._T], key: Callable[[Classifier._T], Classif] + ) -> list[Classifier._T]: + # TODO: should have same kind + return sorted(data, key=lambda x: key(x)._sort_key) + + +class DateClassifier(Classifier): + name = 'by-date' + + def __init__(self, datefmts: list[str]): + """datefmt is format used by time.strptime().""" + self.datefmts = datefmts + + def classify(self, objref: Value) -> list[Classif]: + entries = [] + for v in objref.as_list(): + for datefmt in self.datefmts: + try: + t = strptime(v, datefmt) + except ValueError: + continue # try next datefmt + entries.append( + Classif( + category=str(t.tm_year), + entry=str(t.tm_mon), + subentry=str(t.tm_mday), + ) + ) + return entries + + def sort( + self, data: Iterable[Classifier._T], key: Callable[[Classifier._T], Classif] + ) -> list[Classifier._T]: + # From newest to oldest. + return sorted(data, key=lambda x: key(x)._sort_key, reverse=True) + + +@dataclasses.dataclass(frozen=True) class Object(object): objtype: str - name: str + name: str | None attrs: dict[str, str] - content: str + content: str | None def hexdigest(self) -> str: return hashlib.sha1(pickle.dumps(self)).hexdigest()[:7] -@dataclass +@dataclasses.dataclass class Field(object): """ Describes value constraint of field of Object. @@ -50,7 +241,7 @@ class Field(object): The value of field can be single or mulitple string. :param form: The form of value. - :param unique: Whether the field is unique. + :param uniq: Whether the field is unique. If true, the value of field must be unique within the scope of objects with same type. And it will be used as base string of object identifier. @@ -61,7 +252,7 @@ class Field(object): Duplicated value causes a warning when building documentation, and the corresponding object cannot be referenced correctly. - :param referenceable: Whether the field is referenceable. + :param ref: Whether the field is referenceable. If ture, object can be referenced by field value. See :ref:`roles` for more details. @@ -70,51 +261,26 @@ class Field(object): if the value is no given. """ - class Form(Enum): - "An enumeration represents various string forms." - - #: A single string - PLAIN = auto() - - #: Mulitple string separated by whitespace - WORDS = auto() + class Forms: + PLAIN = Single() + STRIPPED = Single(strip=True) + WORDS = List(sep=' ', strip=True) + LINES = List(sep='\n') + STRIPPED_LINES = List(sep='\n', strip=True) - #: Mulitple string separated by newline(``\n``) - LINES = auto() - - form: Form = Form.PLAIN - unique: bool = False - referenceable: bool = False + uniq: bool = False + ref: bool = False required: bool = False + form: Form = Forms.PLAIN + classifiers: list[Classifier] = dataclasses.field( + default_factory=lambda: [PlainClassifier()] + ) - def _as_plain(self, rawval: str) -> str: - assert self.form == self.Form.PLAIN - assert rawval is not None - return rawval - - def _as_words(self, rawval: str) -> list[str]: - assert self.form == self.Form.WORDS - assert rawval is not None - return [x.strip() for x in rawval.split(' ') if x.strip() != ''] - - def _as_lines(self, rawval: str) -> list[str]: - assert self.form == self.Form.LINES - assert rawval is not None - return rawval.split('\n') - - def value_of(self, rawval: str | None) -> None | str | list[str]: + def value_of(self, rawval: str | None) -> Value: if rawval is None: assert not self.required - return None - - if self.form == self.Form.PLAIN: - return self._as_plain(rawval) - elif self.form == self.Form.WORDS: - return self._as_words(rawval) - elif self.form == self.Form.LINES: - return self._as_lines(rawval) - else: - raise NotImplementedError + return Value(None) + return self.form.extract(rawval) class Schema(object): @@ -136,23 +302,11 @@ class Schema(object): # Object type objtype: str - # Object fields - name: Field + # Object fields, use :py:meth:`Schema.fields` + name: Field | None attrs: dict[str, Field] - content: Field - - # Class-wide shared template environment - # FIXME: can not save jinja template environment because the following error:: - # Traceback (most recent call last): - # File "/usr/lib/python3.9/site-packages/sphinx/cmd/build.py", line 280, in build_main - # app.build(args.force_all, filenames) - # File "/usr/lib/python3.9/site-packages/sphinx/application.py", line 350, in build - # self.builder.build_update() - # File "/usr/lib/python3.9/site-packages/sphinx/builders/__init__.py", line 292, in build_update - # self.build(to_build, - # File "/usr/lib/python3.9/site-packages/sphinx/builders/__init__.py", line 323, in build - # pickle.dump(self.env, f, pickle.HIGHEST_PROTOCOL) - # _pickle.PicklingError: Can't pickle : it's not the same object as jinja2.filters.sync_do_first + content: Field | None + description_template: str reference_template: str missing_reference_template: str @@ -161,7 +315,7 @@ class Schema(object): def __init__( self, objtype: str, - name: Field | None = Field(unique=True, referenceable=True), + name: Field | None = Field(uniq=True, ref=True), attrs: dict[str, Field] = {}, content: Field | None = Field(), description_template: str = '{{ content }}', @@ -194,11 +348,30 @@ def __init__( # Check attrs constraint has_unique = False - for field in [self.name, self.content] + list(self.attrs.values()): - if has_unique and field.unique: + all_fields = [self.name, self.content] + list(self.attrs.values()) + for field in all_fields: + if field is None: + continue + if has_unique and field.uniq: raise SchemaError('only one unique field is allowed in schema') else: - has_unique = field.unique + has_unique = field.uniq + + def fields(self, all=True) -> list[tuple[str, Field]]: + """Return all fields of schema, including name and content. + + .. note:: + + Return a tuple list rather than dict to prevent overwrite of fields + with the same name. + """ + + fields = list(self.attrs.items()) + if all and self.content is not None: + fields.insert(0, (self.CONTENT_KEY, self.content)) + if all and self.name is not None: + fields.insert(0, (self.NAME_KEY, self.name)) + return fields def object( self, name: str | None, attrs: dict[str, str], content: str | None @@ -207,7 +380,7 @@ def object( obj = Object(objtype=self.objtype, name=name, attrs=attrs, content=content) for name, field, val in self.fields_of(obj): if field.required and val is None: - raise ObjectError(f'value of field {name} is none while it is required') + raise ObjectError(f'field {name} is required') return obj def fields_of( @@ -222,28 +395,36 @@ def fields_of( yield ( self.NAME_KEY, self.name, - self.name.value_of(obj.name) if obj else None, + self.name.value_of(obj.name).value, ) for name, field in self.attrs.items(): - yield (name, field, field.value_of(obj.attrs.get(name)) if obj else None) + yield ( + name, + field, + field.value_of(obj.attrs.get(name)).value, + ) if self.content: yield ( self.CONTENT_KEY, self.content, - self.content.value_of(obj.content) if obj else None, + self.content.value_of(obj.content).value, ) def name_of(self, obj: Object) -> None | str | list[str]: assert obj - return self.content.value_of(obj.name) + if self.name is None: + return None + return self.name.value_of(obj.name).value def attrs_of(self, obj: Object) -> dict[str, None | str | list[str]]: assert obj - return {k: f.value_of(obj.attrs.get(k)) for k, f in self.attrs.items()} + return {k: f.value_of(obj.attrs.get(k)).value for k, f in self.attrs.items()} def content_of(self, obj: Object) -> None | str | list[str]: assert obj - return self.content.value_of(obj.content) + if self.content is None: + return None + return self.content.value_of(obj.content).value def identifier_of(self, obj: Object) -> tuple[str | None, str]: """ @@ -252,7 +433,7 @@ def identifier_of(self, obj: Object) -> tuple[str | None, str]: """ assert obj for name, field, val in self.fields_of(obj): - if not field.unique: + if not field.uniq: continue if val is None: break @@ -260,13 +441,17 @@ def identifier_of(self, obj: Object) -> tuple[str | None, str]: return name, val elif isinstance(val, list) and len(val) > 0: return name, val[0] - return name, val return None, obj.hexdigest() def title_of(self, obj: Object) -> str | None: """Return title (display name) of object.""" assert obj + if self.name is None: + return None name = self.name.value_of(obj.name) + if name is None: + return None + name = name.value if isinstance(name, str): return name elif isinstance(name, list) and len(name) > 0: @@ -279,7 +464,7 @@ def references_of(self, obj: Object) -> set[tuple[str, str]]: assert obj refs = [] for name, field, val in self.fields_of(obj): - if not field.referenceable: + if not field.ref: continue if val is None: continue