Source code for dict2css.serializer

#!/usr/bin/env python3
#
#  serializer.py
"""
Serializer for cascading style sheets.

.. versionadded:: 0.2.0
"""
#
#  Copyright © 2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
#  Adapted from https://github.com/austinyu/ujson5
#  Copyright (c) 2025 Sir Austin
#
#  Permission is hereby granted, free of charge, to any person obtaining a copy
#  of this software and associated documentation files (the "Software"), to deal
#  in the Software without restriction, including without limitation the rights
#  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
#  copies of the Software, and to permit persons to whom the Software is
#  furnished to do so, subject to the following conditions:
#
#  The above copyright notice and this permission notice shall be included in all
#  copies or substantial portions of the Software.
#
#  THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
#  EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
#  MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
#  IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
#  DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
#  OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
#  OR OTHER DEALINGS IN THE SOFTWARE.
#

# stdlib
import re
from contextlib import contextmanager
from typing import Any, Callable, Dict, Iterable, Iterator, Literal, Mapping, Optional, Union

# 3rd party
from domdf_python_tools.words import TAB

__all__ = ["CSSSerializer"]

#: Python objects that can be serialized to CSS
Serializable = Union[dict, list, tuple, int, float, str, bool, None]

#: A callable that takes in an object that is not serializable and returns a serializable object
DefaultInterface = Union[
	Callable[[Any], dict],
	Callable[[Any], list],
	Callable[[Any], tuple],
	Callable[[Any], int],
	Callable[[Any], float],
	Callable[[Any], str],
	Callable[[Any], bool],
	Callable[[Any], Serializable]
	]

ESCAPE = re.compile(r'[\x00-\x1f\\\b\f\n\r\t]')
ESCAPE_DCT = {
		'\\': "\\\\",
		'\x08': "\\b",
		'\x0c': "\\f",
		'\n': "\\n",
		'\r': "\\r",
		'\t': "\\t",
		}
for i in range(0x20):
	ESCAPE_DCT.setdefault(chr(i), f"\\u{i:04x}")


[docs]class CSSSerializer: r""" Serializes a dictionary to CSS. This controls the formatting of the style sheet. :param indent: The indent to use, such as a tab (``\t``), two spaces or four spaces. :param trailing_semicolon: Whether to add a semicolon to the end of the final property. :param indent_closing_brace: :param minify: Minify the CSS. Overrides all other options. :param sort_keys: Sort dictionary keys alphabetically. :param check_circular: Check for circular references. :param none_style: Whether to represent :py:obj:`None` as ``None`` or ``none``. .. versionchanged:: 0.5.0 New implementation. Output may differ slightly from previous css-parser based one. .. versionchanged:: 0.6.0 Added ``none_style`` option. .. autosummary-widths:: 1/4 """ # noqa: SXL001 def __init__( self, *, indent: str = TAB, trailing_semicolon: Optional[bool] = None, indent_closing_brace: bool = False, minify: bool = False, sort_keys: bool = False, check_circular: bool = True, # default: Optional[DefaultInterface] = None, none_style: Union[Literal["none"], Literal["None"]] = "none", ) -> None: self._set_options( indent=indent, trailing_semicolon=trailing_semicolon, indent_closing_brace=indent_closing_brace, minify=minify, sort_keys=sort_keys, none_style=none_style, ) if check_circular: self._markers: Optional[Dict[int, Any]] = {} else: self._markers = None # TODO self._default: Optional[DefaultInterface] = default self._default: Optional[DefaultInterface] = None def _set_options( self, indent: str = TAB, trailing_semicolon: Optional[bool] = None, indent_closing_brace: bool = False, minify: bool = False, sort_keys: bool = False, none_style: Union[Literal["none"], Literal["None"]] = "none", ) -> None: self._sort_keys: bool = sort_keys self._indent_str: str = (None if minify else indent) or '' self.none_style = none_style if minify: self._key_separator: str = ':' else: self._key_separator = ": " if not indent and not minify: self._item_separator: str = "; " else: self._item_separator = ';' if minify: self._trailing_semicolon = False elif trailing_semicolon is None: self._trailing_semicolon = bool(self._indent_str) else: self._trailing_semicolon = trailing_semicolon if indent_closing_brace and indent is not None: self._indent_closing_brace = True else: self._indent_closing_brace = False self._minify = minify # TODO: deprecate
[docs] def reset_style(self) -> None: # pragma: no cover """ Reset the serializer to its default style. """ self._set_options()
[docs] def encode(self, obj: Mapping) -> str: """ Return a CSS representation of a Python dictionary. :param obj: The Python dictionary to be serialized :returns: The CSS string representation of the Python dictionary """ return ''.join(self.iterencode(obj))
[docs] def iterencode(self, obj: Mapping) -> Iterable[str]: """ Encode the given dictionary and yield each part of the CSS string representation. :param obj: The Python dictionary to be serialized :returns: An iterable of strings representing the CSS serialization of the Python dictionary. """ if not isinstance(obj, Mapping): raise TypeError(f"Cannot convert {type(obj)} to CSS") return self._iterencode_dict(obj, indent_level=-1)
[docs] def default(self, obj: Any) -> Serializable: """ Override this method in a subclass to implement custom serialization for objects that are not serializable by default. This method should return a serializable object. If this method is not overridden, the encoder will raise a :exc:`ValueError` when trying to encode an unsupported object. :param obj: The object to be serialized that is not supported by default. :returns: A serializable object :raises: ValueError: If the object cannot be serialized """ if self._default is not None: # pragma no cover # TODO return self._default(obj) raise ValueError(f"Object of type {obj.__class__.__name__} cannot be represented in CSS")
def _encode_float(self, obj: float) -> str: if obj != obj or obj == float("inf") or obj == float("-inf"): raise ValueError(f"Out of range float values are not allowed: {repr(obj)}") else: return repr(obj) def _encode_str(self, obj: str) -> str: def replace_unicode(match: re.Match) -> str: # pragma: no cover # TODO return ESCAPE_DCT[match.group(0)] return ESCAPE.sub(replace_unicode, obj) def _iterencode(self, obj: Any, indent_level: int) -> Iterable[str]: if isinstance(obj, str): yield self._encode_str(obj) elif obj is True: yield "true" elif obj is False: yield "false" elif obj is None: yield self.none_style # either none (default; preferred) or None elif isinstance(obj, int): yield repr(obj) elif isinstance(obj, float): yield self._encode_float(obj) elif isinstance(obj, (list, tuple)): # pragma: no cover # TODO (needs default exposed) yield from self._iterencode_list(obj, indent_level) elif isinstance(obj, dict): # pragma: no cover # TODO (needs default exposed) yield from self._iterencode_dict(obj, indent_level) else: if self._markers is not None: marker_id: Optional[int] = id(obj) if marker_id in self._markers: # pragma: no cover # TODO (super edge case) raise ValueError("Circular reference detected") assert marker_id is not None self._markers[marker_id] = obj else: marker_id = None obj_user = self.default(obj) yield from self._iterencode(obj_user, indent_level) # pragma: no cover # TODO (needs default exposed) if self._markers is not None and marker_id is not None: # pragma: no cover # TODO (needs default exposed) del self._markers[marker_id] def _iterencode_list( self, obj: Union[list, tuple, None], indent_level: int, ) -> Iterable[str]: # this package from dict2css import IMPORTANT if not obj: raise ValueError("Property cannot be empty") if self._markers is not None: marker_id: Optional[int] = id(obj) if marker_id in self._markers: # pragma: no cover # TODO (super edge case) raise ValueError("Circular reference detected") assert marker_id is not None self._markers[marker_id] = obj else: marker_id = None first: bool = True for value in obj: if first: yield '' first = False else: yield ' ' if value == IMPORTANT: yield "!important" else: yield from self._iterencode(value, indent_level) if self._markers is not None and marker_id is not None: del self._markers[marker_id] def _iterencode_dict( self, obj: Mapping[str, Any], indent_level: int, ) -> Iterable[str]: if not obj: if indent_level >= 0: yield "{}" return if self._markers is not None: marker_id: Optional[int] = id(obj) if marker_id in self._markers: raise ValueError("Circular reference detected") assert marker_id is not None self._markers[marker_id] = obj else: marker_id = None if indent_level >= 0: yield '{' minify_indent = False if self._minify or not self._indent_str: # No indent newline_indent: Optional[str] = None if indent_level == -1: indent_level += 1 minify_indent = True else: indent_level += 1 newline_indent = '\n' + self._indent_str * indent_level assert newline_indent is not None if indent_level > 0: yield newline_indent first = True if self._sort_keys: items: Any = sorted(obj.items()) else: items = obj.items() total_items: int = len(items) for idx, (key, value) in enumerate(items): if not isinstance(key, str): raise TypeError(f"keys must be strings, not {key.__class__.__name__}") if first: first = False elif newline_indent is not None: yield newline_indent # we do not need to yield anything if indent == 0 yield self._encode_str(key) if isinstance(value, dict): if not self._minify: yield ' ' yield from self._iterencode_dict(value, indent_level) else: yield self._key_separator if isinstance(value, (list, tuple)): yield from self._iterencode_list(value, indent_level) else: yield from self._iterencode(value, indent_level) if idx != total_items - 1 or self._trailing_semicolon: yield self._item_separator if self._indent_str: indent_level -= 1 yield '\n' + self._indent_str * indent_level elif minify_indent: indent_level -= 1 if indent_level >= 0: if self._indent_closing_brace: yield self._indent_str yield '}' if not self._minify: yield '\n' if self._markers is not None and marker_id is not None: del self._markers[marker_id] # TODO: deprecate
[docs] @contextmanager def use(self) -> Iterator: # pragma: no cover """ No-op. Deprecated. """ yield