diff --git a/.cruft.json b/.cruft.json index 338f454..6fe7c8f 100644 --- a/.cruft.json +++ b/.cruft.json @@ -1,6 +1,6 @@ { "template": "https://github.com/sphinx-notes/cookiecutter", - "commit": "634c4022e575bd086ea47f3b42feafe24e14a939", + "commit": "62cd96195962da3392cdc34125c95e9144a5f5ca", "checkout": null, "context": { "cookiecutter": { @@ -20,7 +20,7 @@ "sphinx_version": "7.0", "development_status": "3 - Alpha", "_template": "https://github.com/sphinx-notes/cookiecutter", - "_commit": "634c4022e575bd086ea47f3b42feafe24e14a939" + "_commit": "62cd96195962da3392cdc34125c95e9144a5f5ca" } }, "directory": null diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 638156d..7f56f2c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,5 +12,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version-file: 'pyproject.toml' - - run: python3 -m pip install .[dev] + - run: python3 -m pip install .[test] - run: make test + doctest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v5 + with: + python-version-file: 'pyproject.toml' + - run: python3 -m pip install .[docs] + - run: make doctest diff --git a/Makefile b/Makefile index 06a9e9d..6e7539a 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,11 @@ fmt: .PHONY: test test: - $(PY) -m unittest discover -s tests -v + $(PY) -m pytest tests/ -v + +.PHONY: doctest +doctest: + $(MAKE) doctest -C docs/ ################################################################################ # Distribution Package diff --git a/docs/api.rst b/docs/api.rst index 7c6f31b..b153370 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2,6 +2,66 @@ API Reference ============= -.. note:: WIP +Data Types +========== -.. automodule:: sphinxnotes.render.pipeline +.. autotype:: sphinxnotes.render.PlainValue +.. autotype:: sphinxnotes.render.Value + +.. autoclass:: sphinxnotes.render.RawData +.. autoclass:: sphinxnotes.render.ParsedData + +.. autoclass:: sphinxnotes.render.Field +.. autoclass:: sphinxnotes.render.Schema + +.. autoclass:: sphinxnotes.render.data.Registry + + .. automethod:: add_type + .. automethod:: add_form + .. automethod:: add_flag + .. automethod:: add_by_option + + .. autotype:: sphinxnotes.render.data.ByOptionStore + +The Render Pipeline +=================== + +Context +------- + +.. autoclass:: sphinxnotes.render.PendingContext +.. autotype:: sphinxnotes.render.ResolvedContext +.. autoclass:: sphinxnotes.render.UnparsedData + +.. autoclass:: sphinxnotes.render.pending_node + +Extra Context +------------- + +.. autoclass:: sphinxnotes.render.ExtraContextGenerator +.. autoclass:: sphinxnotes.render.ExtraContextRegistry + +Template +-------- + +.. autoclass:: sphinxnotes.render.Template +.. autoclass:: sphinxnotes.render.Phase + +Pipeline +-------- + +.. autoclass:: sphinxnotes.render.BaseContextRole +.. autoclass:: sphinxnotes.render.BaseContextDirective +.. autoclass:: sphinxnotes.render.BaseDataDefineRole +.. autoclass:: sphinxnotes.render.BaseDataDefineDirective +.. autoclass:: sphinxnotes.render.StrictDataDefineDirective + +Registry +======== + +.. autodata:: sphinxnotes.render.REGISTRY + +.. autoclass:: sphinxnotes.render.Registry + + .. autoproperty:: data + .. autoproperty:: extra_context diff --git a/docs/conf.py b/docs/conf.py index a38abc8..893b5a3 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -23,6 +23,7 @@ # ones. extensions = [ 'sphinx.ext.githubpages', + 'sphinx.ext.doctest', 'sphinx_design', 'sphinx_copybutton', 'sphinx_last_updated_by_git', @@ -124,3 +125,5 @@ _ = extensions.pop() # no need to load extension primary_domain = 'py' + +extensions.append('sphinx.ext.doctest') diff --git a/docs/dsl.rst b/docs/dsl.rst new file mode 100644 index 0000000..0c1f945 --- /dev/null +++ b/docs/dsl.rst @@ -0,0 +1,239 @@ +===================== +Field Declaration DSL +===================== + +.. default-domain:: py +.. highlight:: python +.. role:: py(code) + :language: Python + + +The Field Declaration DSL is a Domain Specific Language (DSL) that used to +define the type and structure of field values. A DSL declaration consists of +one or more :term:`modifier`\ s separated by commas (``,``). + +Python API +========== + +User can create a :class:`sphinxnotes.render.Field` from DSL and use it to parse +string to :type:`sphinxnotes.render.Value`: + +>>> from sphinxnotes.render import Field +>>> Field.from_dsl('list of int').parse('1,2,3') +[1, 2, 3] + +Syntax +====== + +.. productionlist:: + dsl : modifier ("," modifier)* + modifier : type_modifier | form_modifier | flag | by_option + +.. glossary:: + + Modifier + There are four categories of modifiers: + + Type modifier + Specifies the element type (scalar value) + + Form modifier + Specifies a container type with element type + + Flag + A boolean flag (either on or off) + + By-Option + A key-value option + +Type +==== + +A type modifier specifies the data type of a single (scalar) value. + +.. list-table:: + :header-rows: 1 + + * - Modifier + - Type + - Aliases + - Description + * - ``bool`` + - :py:class:`bool` + - ``flag`` + - Boolean: ``true``/``yes``/``1``/``on``/``y`` → True, ``false``/``no``/``0``/``off``/``n`` → False + * - ``int`` + - :py:class:`int` + - ``integer`` + - Integer + * - ``float`` + - :py:class:`float` + - ``number``, ``num`` + - Floating-point number + * - ``str`` + - :py:class:`str` + - ``string`` + - String. If looks like a Python literal (e.g., ``"hello"``), it's parsed accordingly. + +Examples: + +======= ========= ============= +DSL Input Result +------- --------- ------------- +``int`` ``42`` :py:`42` +``str`` ``hello`` :py:`"hello"` +======= ========= ============= + +Form +==== + +A form modifier specifies a container type with its element type, using +``
of `` syntax. + +.. list-table:: + :header-rows: 1 + + * - Modifier + - Container + - Separator + - Description + * - ``list of `` + - :py:class:`list` + - ``,`` + - Comma-separated list + * - ``lines of `` + - :py:class:`list` + - ``\n`` + - Newline-separated list + * - ``words of `` + - :py:class:`list` + - whitespace + - Whitespace-separated list + * - ``set of `` + - :py:class:`set` + - whitespace + - Whitespace-separated set (unique values) + +Examples: + +================ =========== ===================== +DSL Input Result +---------------- ----------- --------------------- +``list of int`` ``1, 2, 3`` :py:`[1, 2, 3]` +``lines of str`` ``a\nb`` :py:`['a', 'b']` +``words of str`` ``a b c`` :py:`['a', 'b', 'c']` +================ =========== ===================== + +Flag +==== + +A flag is a boolean modifier that can be either on or off. + +Every flag is available as a attribute of the :class:`Field`. +For example, we have a "required" flag registed, we can access ``Field.required`` +attribute. + +.. list-table:: + :header-rows: 1 + + * - Modifier + - Aliases + - Default + - Description + * - ``required`` + - ``require``, ``req`` + - ``False`` + - Field must have a value + +Examples:: + + int, required + +By-Option +========= + +A by-option is a key-value modifier with the syntax `` by ``. + +Every by-option is available as a attribute of the :class:`Field`. +For example, we have a "sep" flag registed, we can get the value of separator +from ``Field.sep`` attribute. + +Built-in by-options: + +.. list-table:: + :header-rows: 1 + + * - Modifier + - Type + - Description + * - ``sep by ''`` + - :py:class:`str` + - Custom separator for value form. Implies ``list`` if no form specified. + +Examples: + +=================== ========= ================ +DSL Input Result +------------------- --------- ---------------- +``str, sep by '|'`` ``a|b`` :py:`['a', 'b']` +``int, sep by ':'`` ``1:2:3`` :py:`[1, 2, 3]` +=================== ========= ================ + +Extending the DSL +================= + +You can extend the DSL by registering custom types, flags, and by-options +through the :attr:`~sphinxnotes.render.Registry.data` attribute of +:data:`sphinxnotes.render.REGISTRY`. + +.. _add-custom-types: + +Adding Custom Types +------------------- + +Use :meth:`~sphinxnotes.render.data.REGISTRY.add_type` method of +:data:`sphinxnotes.render.REGISTRY` to add a new type: + +>>> from sphinxnotes.render import REGISTRY +>>> +>>> def parse_color(v: str): +... return tuple(int(x) for x in v.split(';')) +... +>>> def color_to_str(v): +... return ';'.join(str(x) for x in v) +... +>>> REGISTRY.data.add_type('color', tuple, parse_color, color_to_str) +>>> Field.from_dsl('color').parse('255;0;0') +(255, 0, 0) + +.. _add-custom-flags: + +Adding Custom Flags +------------------- + +Use :meth:`~sphinxnotes.render.data.Registry.add_flag` method of +:data:`sphinxnotes.render.REGISTRY` to add a new type: + +>>> from sphinxnotes.render import REGISTRY +>>> REGISTRY.data.add_flag('unique', default=False) +>>> field = Field.from_dsl('int, unique') +>>> field.unique +True + +.. _add-custom-by-options: + +Adding Custom By-Options +------------------------ + +Use :meth:`~sphinxnotes.render.data.Registry.add_by_option` method of +:data:`sphinxnotes.render.REGISTRY` to add a new by-option: + +>>> from sphinxnotes.render import REGISTRY +>>> REGISTRY.data.add_by_option('group', str) +>>> field = Field.from_dsl('str, group by size') +>>> field.group +'size' +>>> REGISTRY.data.add_by_option('index', str, store='append') +>>> field = Field.from_dsl('str, index by month, index by year') +>>> field.index +['month', 'year'] diff --git a/docs/index.rst b/docs/index.rst index dbedb6c..750248c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -43,7 +43,7 @@ Getting Started .. ADDITIONAL CONTENT START This extension is not intended to be used directly by Sphinx user. -It is for Sphinx extension developer, please refer to :doc:`api`. +It is for Sphinx extension developer, please refer to :doc:`dsl` and :doc:`api`. .. ADDITIONAL CONTENT END @@ -53,6 +53,7 @@ Contents .. toctree:: :caption: Contents + dsl api changelog diff --git a/src/sphinxnotes/render/ctx.py b/src/sphinxnotes/render/ctx.py index e8d7372..2a4cdc4 100644 --- a/src/sphinxnotes/render/ctx.py +++ b/src/sphinxnotes/render/ctx.py @@ -8,17 +8,15 @@ This module wraps the :mod:`data` into context for rendering the template. """ -from typing import TYPE_CHECKING +from __future__ import annotations +from typing import Any from abc import ABC, abstractmethod from collections.abc import Hashable from dataclasses import dataclass +from .data import ParsedData from .utils import Unpicklable -if TYPE_CHECKING: - from typing import Any - from .data import ParsedData - type ResolvedContext = ParsedData | dict[str, Any] diff --git a/src/sphinxnotes/render/data.py b/src/sphinxnotes/render/data.py index 9770a01..a8cb19f 100644 --- a/src/sphinxnotes/render/data.py +++ b/src/sphinxnotes/render/data.py @@ -9,7 +9,7 @@ """ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import re from dataclasses import dataclass, asdict, field as dataclass_field from ast import literal_eval @@ -17,7 +17,7 @@ from .utils import Unpicklable if TYPE_CHECKING: - from typing import Any, Callable, Generator, Self, Literal + from typing import Any, Callable, Generator, Self # =================================== # Basic types: Value, Form, Flag, ... @@ -150,9 +150,6 @@ def __init__(self) -> None: # later usage. self._sep_by_option = self.byopts['sep'] - # from pprint import pprint - # pprint(cls.__dict__) - def add_type( self, name: str, @@ -161,6 +158,17 @@ def add_type( strify: Callable[[PlainValue], str], aliases: list[str] = [], ) -> None: + """Register a value type for :class:`PlainValue`. + + :param name: The name for this scalar type, + available as a :term:`Type modifier` in the DSL + :param etype: The Python type object + :param conv: A callable that converts a string to the *etype* + :param strify: A callable that converts the *etype* to a string + :param aliases: Alternative names for this type + + .. seealso:: :ref:`add-custom-types` + """ self.etypes[name] = etype self.convs[etype] = conv self.strifys[etype] = strify @@ -171,6 +179,16 @@ def add_type( def add_form( self, name: str, ctype: type, sep: str, aliases: list[str] = [] ) -> None: + """Register an value form with its container type and separator for + :class:`Value`. + + :param name: The name for this form, available as a :term:`Form modifier` + in the DSL + :param ctype: The container type. + (for now, it is :class:`list`, :class:`tuple`, or :class:`set`) + :param sep: The separator string used to split/join values + :param aliases: Alternative names for this form + """ if ctype not in self.ctypes: raise ValueError(f'Unsupported type: "{ctype}". Available: {self.ctypes}') @@ -183,6 +201,14 @@ def add_form( def add_flag( self, name: str, default: bool = False, aliases: list[str] = [] ) -> None: + """Register a flag. + + :param name: The name for this flag, available as a :term:`Flag` in the DSL + :param default: The default value for this flag + :param aliases: Alternative names for this flag + + .. seealso:: :ref:`add-custom-flags` + """ flag = Flag(name, default) self.flags[flag.name] = flag @@ -197,6 +223,17 @@ def add_by_option( store: ByOptionStore = 'assign', aliases: list[str] = [], ) -> None: + """Register a by-option. + + :param name: The name for this option, available as a :ref:`By-Option` + in the DSL + :param etype: The value type for this option + :param default: The default value for this option + :param store: How to store multiple values + :param aliases: Alternative names for this option + + .. seealso:: :ref:`add-custom-by-options` + """ opt = ByOption(name, etype, default, store) self.byopts[opt.name] = opt diff --git a/tests/test_always_pass.py b/tests/test_always_pass.py new file mode 100644 index 0000000..9a05eb7 --- /dev/null +++ b/tests/test_always_pass.py @@ -0,0 +1,9 @@ +# This file is generated from sphinx-notes/cookiecutter. +# DO NOT EDIT. + +import unittest + + +class TestAlwaysPass(unittest.TestCase): + def test_dummy(self): + self.assertTrue(True)