Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
201 changes: 56 additions & 145 deletions dict2css/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,6 @@
# __init__.py
"""
A μ-library for constructing cascasing style sheets from Python dictionaries.

.. latex:vspace:: 10px
.. seealso:: `css-parser <https://github.com/ebook-utils/css-parser>`_, which this library builds upon.
"""
#
# Copyright © 2020-2021 Dominic Davis-Foster <dominic@davis-foster.co.uk>
Expand All @@ -31,25 +28,21 @@

# stdlib
from io import TextIOBase
from typing import IO, Any, Dict, Mapping, MutableMapping, Sequence, Union, cast
from typing import IO, Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Union

# 3rd party
import tinycss2 # type: ignore[import-untyped]
import tinycss2.ast # type: ignore[import-untyped]
from domdf_python_tools.paths import PathPlus
from domdf_python_tools.typing import PathLike
from domdf_python_tools.words import TAB

try:
# 3rd party
import css_parser # type: ignore
except ImportError: # pragma: no cover
import cssutils as css_parser # type: ignore

# this package
from dict2css.helpers import em, px, rem # noqa: F401
from dict2css.serializer import CSSSerializer

__author__: str = "Dominic Davis-Foster"
__copyright__: str = "2020-2021 Dominic Davis-Foster"
__copyright__: str = "2020-2026 Dominic Davis-Foster"
__license__: str = "MIT License"
__version__: str = "0.4.0"
__email__: str = "dominic@davis-foster.co.uk"
Expand All @@ -61,10 +54,10 @@
"dump",
"loads",
"load",
"StyleSheet",
"make_style",
]

# TODO: allow int indent like json.dumps etc.

Check notice on line 59 in dict2css/__init__.py

View check run for this annotation

codefactor.io / CodeFactor

dict2css/__init__.py#L59

TODO: allow int indent like json.dumps etc. (fixme)

IMPORTANT = "important"
"""
The string ``'important'``.
Expand All @@ -73,11 +66,11 @@
"""

# Property = Union[Tuple[Union[str, int, None], str], str, int, None]
Property = Union[Sequence, str, int, None]
Property = Union[Sequence, str, int, float, None]

Style = Mapping[str, Property]
"""
Type annotation representing a style for :func:`~.make_style` and :func:`~.dumps`.
Type annotation representing a style for :func:`~.dumps` and :func:`~.dump`.

The keys are CSS properties.

Expand All @@ -93,9 +86,11 @@
styles: Mapping[str, Union[Style, Mapping]],
*,
indent: str = TAB,
trailing_semicolon: bool = False,
trailing_semicolon: Optional[bool] = None,
indent_closing_brace: bool = False,
minify: bool = False,
sort_keys: bool = False,
check_circular: bool = True,
) -> str:
r"""
Construct a cascading style sheet from a dictionary.
Expand Down Expand Up @@ -139,48 +134,42 @@
: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.

:return: The style sheet as a string.

.. versionchanged:: 0.2.0 Added support for media at-rules.
.. versionchanged:: 0.5.0 New implementation. Output may differ slightly from previous css-parser based one.
"""

serializer = CSSSerializer(
indent=indent,
trailing_semicolon=trailing_semicolon,
indent_closing_brace=indent_closing_brace,
minify=minify,
sort_keys=sort_keys,
check_circular=check_circular,
)

stylesheet: str = ''

with serializer.use():
sheet = StyleSheet()

for selector, style in styles.items():
if selector.startswith("@media"):
sheet.add_media_styles(selector.split("@media")[1].strip(), cast(Mapping[str, Style], style))
elif selector.startswith('@'):
raise NotImplementedError("Only @media at-rules are supported at this time.")
else:
sheet.add_style(selector, cast(Style, style))
css = serializer.encode(styles).rstrip()

stylesheet = sheet.tostring()

if not serializer.minify:
stylesheet = stylesheet.replace('}', "}\n")

return stylesheet
if css:
return css + '\n'
else:
return ''


def dump(
styles: Mapping[str, Union[Style, Mapping]],
fp: Union[PathLike, IO],
*,
indent: str = TAB,
trailing_semicolon: bool = False,
trailing_semicolon: Optional[bool] = None,
indent_closing_brace: bool = False,
minify: bool = False,
sort_keys: bool = False,
check_circular: bool = True,
) -> None:
r"""
Construct a style sheet from a dictionary and write it to ``fp``.
Expand Down Expand Up @@ -225,19 +214,25 @@
: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.

.. versionchanged:: 0.2.0

* ``fp`` now accepts :py:obj:`domdf_python_tools.typing.PathLike` objects,
representing the path of a file to write to.
* Added support for media at-rules.

.. versionchanged:: 0.5.0 New implementation. Output may differ slightly from previous css-parser based one.
"""

css = dumps(
styles,
indent=indent,
trailing_semicolon=trailing_semicolon,
indent_closing_brace=indent_closing_brace,
sort_keys=sort_keys,
check_circular=check_circular,
minify=minify,
)

Expand All @@ -252,55 +247,61 @@
Parse a style sheet and return its dictionary representation.

.. versionadded:: 0.2.0
.. versionchanged:: 0.5.0 New implementation. Output may differ slightly from previous css-parser based one.

:param styles:

:return: The style sheet as a dictionary.

.. latex:clearpage::
"""

parser = css_parser.CSSParser(validate=False)
stylesheet: css_parser.css.CSSStyleSheet = parser.parseString(styles)
stylesheet = tinycss2.parse_blocks_contents(styles, skip_comments=True, skip_whitespace=True)

styles_dict: MutableMapping[str, MutableMapping[str, Any]] = {}

def parse_style(style: css_parser.css.CSSStyleDeclaration) -> MutableMapping[str, Property]:
def parse_style(style: List[tinycss2.ast.Node]) -> MutableMapping[str, Property]:
style_dict: Dict[str, Property] = {}

prop: css_parser.css.Property
for prop in style.children():
if prop.priority:
style_dict[prop.name] = (prop.value, prop.priority)
prop: Union[tinycss2.ast.ParseError, tinycss2.ast.Declaration]
for prop in tinycss2.parse_declaration_list(style, skip_comments=True, skip_whitespace=True):
if isinstance(prop, tinycss2.ast.ParseError):
raise ValueError(prop)

if prop.important:
style_dict[prop.name.strip()] = (_serialize(prop.value), IMPORTANT)
else:
style_dict[prop.name] = prop.value
style_dict[prop.name.strip()] = _serialize(prop.value)

return style_dict

rule: css_parser.css.CSSRule
for rule in stylesheet.cssRules:
if isinstance(rule, css_parser.css.CSSStyleRule):
styles_dict[rule.selectorText] = parse_style(rule.style)

elif isinstance(rule, css_parser.css.CSSMediaRule):
styles_dict[f"@media {rule.media.mediaText}"] = {}
rule: tinycss2.ast.Node
for rule in stylesheet:
if isinstance(rule, tinycss2.ast.QualifiedRule):
styles_dict[_serialize(rule.prelude)] = parse_style(rule.content)

for child in rule.cssRules:
styles_dict[f"@media {rule.media.mediaText}"][child.selectorText] = parse_style(child.style)
elif isinstance(rule, tinycss2.ast.AtRule):
at_rule_styles = styles_dict[f"@{rule.at_keyword} {_serialize(rule.prelude)}"] = {}

elif isinstance(rule, (css_parser.css.CSSComment)): # pragma: no cover
# Ignore these classes
pass
for child in tinycss2.parse_blocks_contents(rule.content, skip_comments=True, skip_whitespace=True):
at_rule_styles[_serialize(child.prelude)] = parse_style(child.content)

else:
raise NotImplementedError(rule)

return styles_dict


def _serialize(nodes: Iterable[tinycss2.ast.Node]) -> str:
return tinycss2.serialize(nodes).strip()


def load(fp: Union[PathLike, IO]) -> MutableMapping[str, MutableMapping[str, Any]]:
r"""
Parse a cascading style sheet from the given file and return its dictionary representation.

.. versionadded:: 0.2.0
.. versionchanged:: 0.5.0 New implementation. Output may differ slightly from previous css-parser based one.

:param fp: An open file handle, or the filename of a file to write to.

Expand All @@ -313,93 +314,3 @@
styles = PathPlus(fp).read_text()

return loads(styles)


class StyleSheet(css_parser.css.CSSStyleSheet):
r"""
Represents a CSS style sheet.

.. raw:: latex

\nopagebreak

.. autosummary-widths:: 7/16

"""

def __init__(self):
super().__init__(validating=False)

def add(self, rule: css_parser.css.CSSRule) -> int:
"""
Add the ``rule`` to the style sheet.

:param rule:
:type rule: :class:`css_parser.css.CSSRule`
"""

return super().add(rule)

def add_style(
self,
selector: str,
styles: Style,
) -> None:
"""
Add a style to the style sheet.

:param selector:
:param styles:
"""

self.add(make_style(selector, styles))

def add_media_styles(
self,
media_query: str,
styles: Mapping[str, Style],
) -> None:
"""
Add a set of styles for a media query to the style sheet.

.. versionadded:: 0.2.0

:param media_query:
:param styles:
"""

media = css_parser.css.CSSMediaRule(media_query)

for selector, style in styles.items():
media.add(make_style(selector, style))

self.add(media)

def tostring(self) -> str:
"""
Returns the style sheet as a string.
"""

return self.cssText.decode("UTF-8")


def make_style(selector: str, styles: Style) -> css_parser.css.CSSStyleRule:
"""
Create a CSS Style Rule from a dictionary.

:param selector:
:param styles:

:rtype: :class:`css_parser.css.CSSStyleRule`
"""

style = css_parser.css.CSSStyleDeclaration()
style.validating = False

for name, properties in styles.items():
if isinstance(properties, Sequence) and not isinstance(properties, str):
style[name] = tuple(str(x) for x in properties)
else:
style[name] = str(properties)

return css_parser.css.CSSStyleRule(selectorText=selector, style=style)
Loading
Loading