diff --git a/.github/scripts/get_python_versions.py b/.github/scripts/get_python_versions.py deleted file mode 100644 index 8a39145..0000000 --- a/.github/scripts/get_python_versions.py +++ /dev/null @@ -1,22 +0,0 @@ -if __name__ == '__main__': - import json - - import requests - from packaging import version as semver - - stable_versions = requests.get( - 'https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json' - ).json() - - min_version = semver.parse('3.7') - versions = {} - - for version_object in stable_versions: - - version = version_object['version'] - major_and_minor_version = semver.parse('.'.join(version.split('.')[:2])) - - if major_and_minor_version not in versions and major_and_minor_version >= min_version: - versions[major_and_minor_version] = version - - print(json.dumps(list(versions.values()))) # noqa diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 082066e..22b34ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: id: cache-venv with: path: .venv - key: venv-0 + key: venv-1 - run: | python -m venv .venv --upgrade-deps source .venv/bin/activate @@ -28,7 +28,7 @@ jobs: id: pre-commit-cache with: path: ~/.cache/pre-commit - key: key-0 + key: key-1 - run: | source .venv/bin/activate pre-commit run --all-files @@ -38,7 +38,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ "3.7", "3.8", "3.9", "3.10" ] + python-version: [ "3.7.14", "3.8.14", "3.9.15", "3.10.8", "3.11.0", "3.12.0-alpha.1" ] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 @@ -48,20 +48,19 @@ jobs: id: poetry-cache with: path: ~/.local - key: key-0 + key: key-2 - uses: snok/install-poetry@v1 with: virtualenvs-create: false - version: 1.2.0a2 - uses: actions/cache@v2 id: cache-venv with: path: .venv - key: ${{ hashFiles('**/poetry.lock') }}-0 + key: ${{ hashFiles('**/poetry.lock') }}-1 - run: | python -m venv .venv source .venv/bin/activate - pip install -U pip + pip install -U pip wheel poetry install --no-interaction --no-root if: steps.cache-venv.outputs.cache-hit != 'true' - name: Run tests diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3062b26..6493166 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/ambv/black - rev: 21.11b1 + rev: 22.10.0 hooks: - id: black args: [ "--quiet" ] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.3.0 hooks: - id: check-ast - id: check-merge-conflict @@ -18,8 +18,8 @@ repos: - id: trailing-whitespace - id: mixed-line-ending - id: trailing-whitespace - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.9.2 + - repo: https://github.com/pycqa/flake8 + rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [ @@ -34,8 +34,10 @@ repos: 'flake8-printf-formatting', 'flake8-type-checking', ] + args: + - '--allow-star-arg-any' - repo: https://github.com/asottile/pyupgrade - rev: v2.29.1 + rev: v3.2.2 hooks: - id: pyupgrade args: [ "--py36-plus", "--py37-plus",'--keep-runtime-typing' ] @@ -44,7 +46,7 @@ repos: hooks: - id: isort - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910-1 + rev: v0.991 hooks: - id: mypy additional_dependencies: diff --git a/README.md b/README.md index dc43d25..fd610a8 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,7 @@ - - Package version - - - Code coverage - - - Supported Python versions - - - Checked with mypy - +[![pypi](https://img.shields.io/pypi/v/portabletext-html.svg)](https://pypi.org/project/portabletext-html/) +[![test](https://github.com/otovo/python-portabletext-html/actions/workflows/test.yml/badge.svg)](https://github.com/otovo/python-portabletext-html/actions/workflows/test.yml) +[![code coverage](https://codecov.io/gh/otovo/python-portabletext-html/branch/main/graph/badge.svg)](https://codecov.io/gh/otovo/python-portabletext-html) +[![supported python versions](https://img.shields.io/badge/python-3.7%2B-blue)](https://pypi.org/project/python-portabletext-html/) # Portable Text HTML Renderer for Python @@ -168,6 +160,11 @@ class ComicSansEmphasis(MarkerDefinition): def render_suffix(cls, span: Span, marker: str, context: Block) -> str: return f'' + @classmethod + def render_text(cls, span: Span, marker: str, context: Block) -> str: + # custom rendering logic can be placed here + return str(span.text) + @classmethod def render(cls, span: Span, marker: str, context: Block) -> str: result = cls.render_prefix(span, marker, context) @@ -223,4 +220,4 @@ In the meantime, users should be able to serialize image types by passing a cust Contributions are always appreciated 👏 -For details, see the [CONTRIBUTING.md](https://github.com/otovo/python-sanity-html/blob/main/CONTRIBUTING.md). +For details, see the [CONTRIBUTING.md](https://github.com/otovo/python-portabletext-html/blob/main/CONTRIBUTING.md). diff --git a/poetry.lock b/poetry.lock index 268a8e6..b05bfd9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,6 +1,6 @@ [[package]] name = "atomicwrites" -version = "1.4.0" +version = "1.4.1" description = "Atomic file writes." category = "dev" optional = false @@ -8,25 +8,25 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "21.2.0" +version = "22.1.0" description = "Classes Without Boilerplate" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = ">=3.5" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] +dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"] +docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"] +tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"] [[package]] name = "colorama" -version = "0.4.4" +version = "0.4.6" description = "Cross-platform colored terminal text." category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" [[package]] name = "coverage" @@ -55,20 +55,20 @@ pyflakes = ">=2.3.0,<2.4.0" [[package]] name = "importlib-metadata" -version = "4.8.2" +version = "5.0.0" description = "Read metadata from Python packages" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} zipp = ">=0.5" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] perf = ["ipython"] -testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] [[package]] name = "iniconfig" @@ -138,11 +138,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pyparsing" -version = "3.0.6" -description = "Python parsing module" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.6.8" [package.extras] diagrams = ["jinja2", "railroad-diagrams"] @@ -183,7 +183,7 @@ pytest = ">=4.6" toml = "*" [package.extras] -testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] [[package]] name = "toml" @@ -195,23 +195,23 @@ python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" [[package]] name = "typing-extensions" -version = "4.0.0" -description = "Backported and Experimental Type Hints for Python 3.6+" +version = "4.4.0" +description = "Backported and Experimental Type Hints for Python 3.7+" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [[package]] name = "zipp" -version = "3.6.0" +version = "3.10.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.extras] -docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] +testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] [metadata] lock-version = "1.1" @@ -220,16 +220,15 @@ content-hash = "c641d950bccb6ffac52cf3fcd3571b51f5e31d4864c03e763fe2748919bf855b [metadata.files] atomicwrites = [ - {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, - {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, + {file = "atomicwrites-1.4.1.tar.gz", hash = "sha256:81b2c9071a49367a7f770170e5eec8cb66567cfbbc8c73d20ce5ca4a8d71cf11"}, ] attrs = [ - {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, - {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, + {file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"}, + {file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"}, ] colorama = [ - {file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"}, - {file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"}, + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] coverage = [ {file = "coverage-5.5-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:b6d534e4b2ab35c9f93f46229363e17f63c53ad01330df9f2d6bd1187e5eaacf"}, @@ -290,8 +289,8 @@ flake8 = [ {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.8.2-py3-none-any.whl", hash = "sha256:53ccfd5c134223e497627b9815d5030edf77d2ed573922f7a0b8f8bb81a1c100"}, - {file = "importlib_metadata-4.8.2.tar.gz", hash = "sha256:75bdec14c397f528724c1bfd9709d660b33a4d2e77387a3358f20b848bb5e5fb"}, + {file = "importlib_metadata-5.0.0-py3-none-any.whl", hash = "sha256:ddb0e35065e8938f867ed4928d0ae5bf2a53b7773871bfe6bcc7e4fcdc7dea43"}, + {file = "importlib_metadata-5.0.0.tar.gz", hash = "sha256:da31db32b304314d044d3c12c79bd59e307889b287ad12ff387b3500835fc2ab"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -322,8 +321,8 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pyparsing = [ - {file = "pyparsing-3.0.6-py3-none-any.whl", hash = "sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4"}, - {file = "pyparsing-3.0.6.tar.gz", hash = "sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81"}, + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, ] pytest = [ {file = "pytest-6.2.5-py3-none-any.whl", hash = "sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"}, @@ -338,10 +337,10 @@ toml = [ {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, ] typing-extensions = [ - {file = "typing_extensions-4.0.0-py3-none-any.whl", hash = "sha256:829704698b22e13ec9eaf959122315eabb370b0884400e9818334d8b677023d9"}, - {file = "typing_extensions-4.0.0.tar.gz", hash = "sha256:2cdf80e4e04866a9b3689a51869016d36db0814d84b8d8a568d22781d45d27ed"}, + {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, + {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, ] zipp = [ - {file = "zipp-3.6.0-py3-none-any.whl", hash = "sha256:9fe5ea21568a0a70e50f273397638d39b03353731e6cbbb3fd8502a33fec40bc"}, - {file = "zipp-3.6.0.tar.gz", hash = "sha256:71c644c5369f4a6e07636f0aa966270449561fcea2e3d6747b8d23efaa9d7832"}, + {file = "zipp-3.10.0-py3-none-any.whl", hash = "sha256:4fcb6f278987a6605757302a6e40e896257570d11c51628968ccb2a47e80c6c1"}, + {file = "zipp-3.10.0.tar.gz", hash = "sha256:7a7262fd930bd3e36c50b9a64897aec3fafff3dfdeec9623ae22b40e93f99bb8"}, ] diff --git a/portabletext_html/marker_definitions.py b/portabletext_html/marker_definitions.py index 72cc2d2..396f4e1 100644 --- a/portabletext_html/marker_definitions.py +++ b/portabletext_html/marker_definitions.py @@ -37,10 +37,15 @@ def render_suffix(cls: Type[MarkerDefinition], span: Span, marker: str, context: def render(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: """Render the marked span directly with prefix and suffix.""" result = cls.render_prefix(span, marker, context) - result += str(span.text) + result += cls.render_text(span, marker, context) result += cls.render_suffix(span, marker, context) return result + @classmethod + def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + """Render the content part for a marked span.""" + return str(span.text) + # Decorators diff --git a/portabletext_html/renderer.py b/portabletext_html/renderer.py index 614b177..1ca2ce3 100644 --- a/portabletext_html/renderer.py +++ b/portabletext_html/renderer.py @@ -10,7 +10,7 @@ from portabletext_html.utils import get_list_tags, is_block, is_list, is_span if TYPE_CHECKING: - from typing import Callable, Dict, List, Optional, Type, Union + from typing import Any, Callable, Dict, List, Optional, Type, Union from portabletext_html.marker_definitions import MarkerDefinition @@ -38,8 +38,8 @@ class PortableTextRenderer: def __init__( self, blocks: Union[list[dict], dict], - custom_marker_definitions: dict[str, Type[MarkerDefinition]] = None, - custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] = None, + custom_marker_definitions: dict[str, Type[MarkerDefinition]] | None = None, + custom_serializers: dict[str, Callable[[dict, Optional[Block], bool], str]] | None = None, ) -> None: logger.debug('Initializing block renderer') self._wrapper_element: Optional[str] = None @@ -66,7 +66,7 @@ def render(self) -> str: if list_nodes and not is_list(node): tree = self._normalize_list_tree(list_nodes) - result += ''.join([self._render_node(n, Block(**node), list_item=True) for n in tree]) + result += ''.join([self._render_node(n, list_item=True) for n in tree]) list_nodes = [] # reset list_nodes if is_list(node): @@ -106,14 +106,14 @@ def _render_node(self, node: dict, context: Optional[Block] = None, list_item: b elif is_span(node): logger.debug('Rendering node as span') span = Span(**node) - context = cast(Block, context) # context should always be a Block here + context = cast('Block', context) # context should always be a Block here return self._render_span(span, block=context) elif self._custom_serializers.get(node.get('_type', '')): return self._custom_serializers.get(node.get('_type', ''))(node, context, list_item) # type: ignore else: - if hasattr(node, '_type'): + if '_type' in node: raise MissingSerializerError( f'Found unhandled node type: {node["_type"]}. ' 'Most likely this requires a custom serializer.' ) @@ -150,7 +150,19 @@ def _render_span(self, span: Span, block: Block) -> str: marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)() result += marker_callable.render_prefix(span, mark, block) - result += html.escape(span.text).replace('\n', '
') + # to avoid rendering the text multiple times, + # only the first custom mark will be used + custom_mark_text_rendered = False + if sorted_marks: + for mark in sorted_marks: + if custom_mark_text_rendered or mark in prev_marks: + continue + marker_callable = block.marker_definitions.get(mark, DefaultMarkerDefinition)() + result += marker_callable.render_text(span, mark, block) + custom_mark_text_rendered = True + + if not custom_mark_text_rendered: + result += html.escape(span.text).replace('\n', '
') for mark in reversed(sorted_marks): if mark in next_marks: @@ -244,7 +256,7 @@ def _list_from_block(self, block: dict) -> dict: } -def render(blocks: List[Dict], *args, **kwargs) -> str: +def render(blocks: List[Dict], *args: Any, **kwargs: Any) -> str: """Shortcut function inspired by Sanity's own blocksToHtml.h callable.""" renderer = PortableTextRenderer(blocks, *args, **kwargs) return renderer.render() diff --git a/portabletext_html/types.py b/portabletext_html/types.py index 68638cd..898d61e 100644 --- a/portabletext_html/types.py +++ b/portabletext_html/types.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING from portabletext_html.utils import get_default_marker_definitions @@ -53,9 +53,7 @@ def __post_init__(self) -> None: To make handling of span `marks` simpler, we define marker_definitions as a dict, from which we can directly look up both annotation marks or decorator marks. """ - marker_definitions = get_default_marker_definitions(self.markDefs) - marker_definitions.update(self.marker_definitions) - self.marker_definitions = marker_definitions + self.marker_definitions = self._add_custom_marker_definitions() self.marker_frequencies = self._compute_marker_frequencies() def _compute_marker_frequencies(self) -> dict[str, int]: @@ -68,16 +66,24 @@ def _compute_marker_frequencies(self) -> dict[str, int]: counts[mark] = 0 return counts + def _add_custom_marker_definitions(self) -> dict[str, Type[MarkerDefinition]]: + marker_definitions = get_default_marker_definitions(self.markDefs) + marker_definitions.update(self.marker_definitions) + for definition in self.markDefs: + if definition['_type'] in self.marker_definitions: + marker = self.marker_definitions[definition['_type']] + marker_definitions[definition['_key']] = marker + # del marker_definitions[definition['_type']] + return marker_definitions + def get_node_siblings(self, node: Union[dict, Span]) -> Tuple[Optional[dict], Optional[dict]]: """Return the sibling nodes (prev, next) to the given node.""" if not self.children: return None, None try: if type(node) == dict: - node = cast(dict, node) node_idx = self.children.index(node) elif type(node) == Span: - node = cast(Span, node) for index, item in enumerate(self.children): if 'text' in item and node.text == item['text']: # Is it possible to handle several identical texts? @@ -88,11 +94,9 @@ def get_node_siblings(self, node: Union[dict, Span]) -> Tuple[Optional[dict], Op except ValueError: return None, None - prev_node = None next_node = None - if node_idx != 0: - prev_node = self.children[node_idx - 1] + prev_node = self.children[node_idx - 1] if node_idx != 0 else None if node_idx != len(self.children) - 1: next_node = self.children[node_idx + 1] diff --git a/pyproject.toml b/pyproject.toml index cc7ffeb..7dc039a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = 'portabletext-html' -version = '1.0.0b1' +version = '1.1.3' description = "HTML renderer for Sanity's Portable Text format" homepage = 'https://github.com/otovo/python-sanity-html' repository = 'https://github.com/otovo/python-sanity-html' @@ -25,6 +25,7 @@ classifiers = [ 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', 'Typing :: Typed', ] diff --git a/setup.cfg b/setup.cfg index 72028f4..44e5250 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,3 +47,21 @@ exclude = max-complexity = 15 max-line-length = 120 + +[mypy] +show_error_codes = True +warn_unused_ignores = True +strict_optional = True +incremental = True +ignore_missing_imports = True +warn_redundant_casts = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_untyped_calls = True +local_partial_types = True +show_traceback = True +exclude = + .venv/ + +[mypy-tests.*] +ignore_errors = True diff --git a/tests/fixtures/custom_serializer_node_after_list.json b/tests/fixtures/custom_serializer_node_after_list.json new file mode 100644 index 0000000..39386cf --- /dev/null +++ b/tests/fixtures/custom_serializer_node_after_list.json @@ -0,0 +1,20 @@ +[ + { + "_key": "e5b6e416e6e9", + "_type": "block", + "children": [ + { "_key": "3bbbff0f158b", "_type": "span", "marks": [], "text": "resers" } + ], + "level": 1, + "listItem": "bullet", + "markDefs": [], + "style": "normal" + }, + { + "_key": "73405dda68e0", + "_type": "extraInfoBlock", + "extraInfo": "This informations is not supported by Block", + "markDefs": [], + "style": "normal" + } +] diff --git a/tests/fixtures/invalid_node.json b/tests/fixtures/invalid_node.json new file mode 100644 index 0000000..74f09bf --- /dev/null +++ b/tests/fixtures/invalid_node.json @@ -0,0 +1,13 @@ +{ + "_key": "73405dda68e7", + "children": [ + { + "_key": "25a09c61d80a", + "_type": "span", + "marks": [], + "text": "Otovo guarantee is good" + } + ], + "markDefs": [], + "style": "normal" +} diff --git a/tests/fixtures/invalid_type.json b/tests/fixtures/invalid_type.json new file mode 100644 index 0000000..745ac66 --- /dev/null +++ b/tests/fixtures/invalid_type.json @@ -0,0 +1,14 @@ +{ + "_key": "73405dda68e7", + "_type": "invalid_type", + "children": [ + { + "_key": "25a09c61d80a", + "_type": "span", + "marks": [], + "text": "Otovo guarantee is good" + } + ], + "markDefs": [], + "style": "normal" +} diff --git a/tests/test_marker_definitions.py b/tests/test_marker_definitions.py index 6011426..2677218 100644 --- a/tests/test_marker_definitions.py +++ b/tests/test_marker_definitions.py @@ -1,3 +1,6 @@ +# pylint: skip-file +from typing import Type + from portabletext_html import PortableTextRenderer from portabletext_html.marker_definitions import ( CommentMarkerDefinition, @@ -16,6 +19,7 @@ def test_render_emphasis_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert EmphasisMarkerDefinition.render_text(node, 'em', block) == f'{text}' assert EmphasisMarkerDefinition.render(node, 'em', block) == f'{text}' @@ -23,6 +27,7 @@ def test_render_strong_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert StrongMarkerDefinition.render_text(node, 'strong', block) == f'{text}' assert StrongMarkerDefinition.render(node, 'strong', block) == f'{text}' @@ -30,6 +35,7 @@ def test_render_underline_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert UnderlineMarkerDefinition.render_text(node, 'u', block) == f'{text}' assert ( UnderlineMarkerDefinition.render(node, 'u', block) == f'{text}' @@ -40,6 +46,7 @@ def test_render_strikethrough_marker_success(): for text in sample_texts: node = Span(_type='span', text=text) block = Block(_type='block', children=[node.__dict__]) + assert StrikeThroughMarkerDefinition.render_text(node, 'strike', block) == f'{text}' assert StrikeThroughMarkerDefinition.render(node, 'strike', block) == f'{text}' @@ -49,6 +56,7 @@ def test_render_link_marker_success(): block = Block( _type='block', children=[node.__dict__], markDefs=[{'_type': 'link', '_key': 'linkId', 'href': text}] ) + assert LinkMarkerDefinition.render_text(node, 'linkId', block) == f'{text}' assert LinkMarkerDefinition.render(node, 'linkId', block) == f'{text}' @@ -62,19 +70,32 @@ def test_render_comment_marker_success(): def test_custom_marker_definition(): from portabletext_html.marker_definitions import MarkerDefinition - class ComicSansEmphasis(MarkerDefinition): + class ConditionalMarkerDefinition(MarkerDefinition): tag = 'em' @classmethod - def render_prefix(cls, span, marker, context): - return f'<{cls.tag} style="font-family: "Comic Sans MS", "Comic Sans", cursive;">' + def render_prefix(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None) + condition = marker_definition.get('cloudCondition', '') + if not condition: + style = 'display: none' + return f'<{cls.tag} style=\"{style}\">' + else: + return super().render_prefix(span, marker, context) + + @classmethod + def render_text(cls: Type[MarkerDefinition], span: Span, marker: str, context: Block) -> str: + marker_definition = next((md for md in context.markDefs if md['_key'] == marker), None) + condition = marker_definition.get('cloudCondition', '') + return span.text if not condition else '' renderer = PortableTextRenderer( - { + blocks={ '_type': 'block', - 'children': [{'_key': 'a1ph4', '_type': 'span', 'marks': ['em'], 'text': 'Sanity'}], - 'markDefs': [], + 'children': [{'_key': 'a1ph4', '_type': 'span', 'marks': ['some_id'], 'text': 'Sanity'}], + 'markDefs': [{'_key': 'some_id', '_type': 'contractConditional', 'cloudCondition': False}], }, - custom_marker_definitions={'em': ComicSansEmphasis}, + custom_marker_definitions={'contractConditional': ConditionalMarkerDefinition}, ) - assert renderer.render() == '

Sanity

' + result = renderer.render() + assert result == '

Sanity

' diff --git a/tests/test_rendering.py b/tests/test_rendering.py index 3894862..1c82309 100644 --- a/tests/test_rendering.py +++ b/tests/test_rendering.py @@ -1,8 +1,18 @@ import html import json from pathlib import Path +from typing import Optional -from portabletext_html.renderer import render +import pytest + +from portabletext_html.renderer import MissingSerializerError, UnhandledNodeError, render +from portabletext_html.types import Block + + +def extraInfoSerializer(node: dict, context: Optional[Block], list_item: bool) -> str: + extraInfo = node.get('extraInfo') + + return f'

{extraInfo}

' def load_fixture(fixture_name) -> dict: @@ -45,3 +55,22 @@ def test_nested_marks(): fixture = load_fixture('nested_marks.json') output = render(fixture) assert output == '

A word of warning; Sanity is addictive.

' + + +def test_missing_serializer(): + fixture = load_fixture('invalid_type.json') + with pytest.raises(MissingSerializerError): + render(fixture) + + +def test_invalid_node(): + fixture = load_fixture('invalid_node.json') + with pytest.raises(UnhandledNodeError): + render(fixture) + + +def test_custom_serializer_node_after_list(): + fixture = load_fixture('custom_serializer_node_after_list.json') + output = render(fixture, custom_serializers={'extraInfoBlock': extraInfoSerializer}) + + assert output == '

This informations is not supported by Block

'