diff --git a/README.md b/README.md index 2bb4e46..5732d17 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,100 @@ # PyDOM -See [Seamless](https://seamless.rtfd.io) for a more complete and up-to-date implementation of this idea. +

+ pydom-logo +

+ +

+ Simple to learn, easy to use, fully-featured UI library for Python +

+ +

+ + PyPI version + +

+ +PyDOM is a Python library that allows you to create web pages using a declarative syntax. + +PyDOM provides a set of components that represent HTML elements and can be composed to create complex web pages. + +## Quick Start + +This is a quick start guide to get you up and running with PyDOM. The guide will show you how to setup PyDOM and integrate it with [FastAPI](https://fastapi.tiangolo.com/). + +### Installation + +First, install the PyDOM package. + +```bash +pip install pydom +``` + +### Create Reusable Page + +PyDOM provides a default page component that is the minimal structure for a web page. + +The page can be customized by extending the default page component and overriding the `head` and the `body` methods. + +More information about the default page component can be found [here](#page). + +```python +# app_page.py + +from pydom import Link, Page + +class AppPage(Page): + def head(self): + return ( + *super().head(), + Link( + rel="stylesheet", + href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" + ) + ) +``` + +### Creating the FastAPI app + +Lastly, create the `FastAPI` app and add an endpoint that will render the page when the user accesses the root route. + +```python +# main.py + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from pydom import render, Div, P + +form app_page import AppPage + +app = FastAPI() + +@app.get("/", response_class=HTMLResponse) +async def read_root(): + return render( + AppPage( + Div(classes="container mt-5")( + Div(classes="text-center p-4 rounded")( + Div(classes="display-4")("Hello, World!"), + P(classes="lead")("Welcome to PyDOM"), + ) + ) + ) + ) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="localhost", port=8000) +``` + +That's it! Now you can run the app and access it at [http://localhost:8000/](http://localhost:8000/). + +It should display a page like this: + +

+ Quick Start +

+ +## Documentation + +The full documentation can be found at [our documentation site](https://pydom.dev/). diff --git a/docs/_static/images/logo.svg b/docs/_static/images/logo.svg new file mode 100644 index 0000000..88bb834 --- /dev/null +++ b/docs/_static/images/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/_static/images/quick-start.jpeg b/docs/_static/images/quick-start.jpeg new file mode 100644 index 0000000..f00888e Binary files /dev/null and b/docs/_static/images/quick-start.jpeg differ diff --git a/pyproject.toml b/pyproject.toml index 16f8cd0..b65f5c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,22 @@ [project] -name = "python-dom" +name = "pydom" authors = [{ name = "Xpo Development", email = "dev@xpo.dev" }] description = "A Python package for creating and manipulating reusable HTML components" readme = "README.md" requires-python = ">=3.8" classifiers = [ - "Programming Language :: Python :: 3", + "Development Status :: 4 - Beta", + "Environment :: Web Environment", + "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", ] dynamic = ["dependencies", "version"] @@ -28,3 +37,6 @@ dependencies = { file = ["requirements.txt"] } [tool.setuptools.package-data] pydom = ["py.typed"] + +[project.scripts] +pydom = "pydom.cli:main" diff --git a/src/pydom/__init__.py b/src/pydom/__init__.py index 85abf54..52ea974 100644 --- a/src/pydom/__init__.py +++ b/src/pydom/__init__.py @@ -1,6 +1,7 @@ from .component import Component from .context.context import Context, set_default_context from .html import * +from .page import Page from .svg import * from .rendering import render from .version import version as __version__ @@ -148,5 +149,6 @@ "Component", "Context", "render", + "Page", "__version__", ] diff --git a/src/pydom/context/context.py b/src/pydom/context/context.py index 3946ba2..c9be61c 100644 --- a/src/pydom/context/context.py +++ b/src/pydom/context/context.py @@ -105,13 +105,18 @@ def add_prop_transformer( self._prop_transformers.insert(index, (matcher, self.inject(transformer))) def add_post_render_transformer( - self, transformer: Union[PostRenderTransformerFunction, PostRenderTransformer] + self, + transformer: Union[PostRenderTransformerFunction, PostRenderTransformer], + /, + *, + before: Optional[List[Type[PostRenderTransformer]]] = None, + after: Optional[List[Type[PostRenderTransformer]]] = None, ): try: index = self._find_transformer_insertion_index( self._post_render_transformers, - before=[PostRenderTransformer], - after=[PostRenderTransformer], + before=before, + after=after, ) except Error as e: raise Error( diff --git a/src/pydom/context/standard/transformers/class_transformer.py b/src/pydom/context/standard/transformers/class_transformer.py index 67aaf78..eaa7c12 100644 --- a/src/pydom/context/standard/transformers/class_transformer.py +++ b/src/pydom/context/standard/transformers/class_transformer.py @@ -2,12 +2,15 @@ class ClassTransformer(PropertyTransformer): + def __init__(self, prop_name="classes"): + self.prop_name = prop_name + def match(self, prop_name, _) -> bool: - return prop_name == "class_name" + return prop_name == self.prop_name def transform(self, _, prop_value, element): if not isinstance(prop_value, str): prop_value = " ".join(prop_value) element.props["class"] = " ".join(str(prop_value).split()).strip() - del element.props["class_name"] + del element.props[self.prop_name] diff --git a/src/pydom/context/standard/transformers/style_transformer.py b/src/pydom/context/standard/transformers/style_transformer.py index e5dac3d..c39c126 100644 --- a/src/pydom/context/standard/transformers/style_transformer.py +++ b/src/pydom/context/standard/transformers/style_transformer.py @@ -1,10 +1,10 @@ -from ....styling import StyleObject +from ....styling import StyleSheet from ...transformers import PropertyTransformer class StyleTransformer(PropertyTransformer): def match(self, _, value): - return isinstance(value, StyleObject) + return isinstance(value, StyleSheet) - def transform(self, key: str, value: StyleObject, element): + def transform(self, key: str, value: StyleSheet, element): element.props[key] = value.to_css() diff --git a/src/pydom/rendering/transformers/post_render_transformer.py b/src/pydom/rendering/transformers/post_render_transformer.py index 636c14d..b8c7c4b 100644 --- a/src/pydom/rendering/transformers/post_render_transformer.py +++ b/src/pydom/rendering/transformers/post_render_transformer.py @@ -5,12 +5,10 @@ def post_render_transformer(context: Union[Context, None] = None): """ - A decorator to register a post-render transformer. - - Post-render transformers are functions that take the rendered tree and can modify it in place. + A decorator to register a function as a post-render transformer. Args: - context: The context to register the transformer with. + context: The context to register the transformer with. If not provided, the default context is used. Returns: A decorator that takes a transformer function and registers it. diff --git a/src/pydom/rendering/transformers/property_transformer.py b/src/pydom/rendering/transformers/property_transformer.py index 5cf5213..620879d 100644 --- a/src/pydom/rendering/transformers/property_transformer.py +++ b/src/pydom/rendering/transformers/property_transformer.py @@ -6,17 +6,13 @@ def property_transformer( matcher: Union[Callable[[str, Any], bool], str], context: Optional[Context] = None ): """ - A decorator to register a property transformer. - - Transformers are functions that take a key, a value, and the element node object. - - After handling the key and value, the transformer should update the element node - properties in place. + A decorator to register a function as a property transformer. Args: matcher: A callable that takes a key and a value and returns a boolean indicating whether the transformer should be applied. If a string is provided, it is assumed to be a key that should be matched exactly. + context: The context to register the transformer in. If not provided, the default context is used. Returns: A decorator that takes a transformer function and registers it. diff --git a/src/pydom/styling/__init__.py b/src/pydom/styling/__init__.py index c4cfdec..c442839 100644 --- a/src/pydom/styling/__init__.py +++ b/src/pydom/styling/__init__.py @@ -1,5 +1,5 @@ from .color import Color -from .style_object import StyleObject +from .stylesheet import StyleSheet from .css_modules import CSS -__all__ = ["Color", "StyleObject", "CSS"] \ No newline at end of file +__all__ = ["Color", "StyleSheet", "CSS"] \ No newline at end of file diff --git a/src/pydom/styling/css_modules.py b/src/pydom/styling/css_modules.py index 818223a..77ace1c 100644 --- a/src/pydom/styling/css_modules.py +++ b/src/pydom/styling/css_modules.py @@ -1,4 +1,3 @@ -import inspect import re from os import PathLike @@ -7,7 +6,9 @@ import cssutils -from ..utils.functions import random_string +from ..utils.get_frame import get_frame + +from ..utils.functions import random_string, remove_prefix class CSSClass: @@ -17,10 +18,7 @@ def __init__(self, class_name: str): self.uuid = random_string() def add_rule(self, rule: str, properties: Dict[str, str]): - if rule not in self.sub_rules: - self.sub_rules[rule] = {} - - self.sub_rules[rule].update(properties) + self.sub_rules.setdefault(rule, {}).update(properties) def to_css_string(self, minified=False): rules = [] @@ -67,7 +65,7 @@ def __init__(self, module_name): css_property.name: css_property.value for css_property in rule.style } - base_name = first_selector.seq[0].value.removeprefix(".") + base_name = remove_prefix(first_selector.seq[0].value, ".") css_class = self.classes.get(base_name, CSSClass(base_name)) for selector in selectors: css_class.add_rule( @@ -79,7 +77,7 @@ def __init__(self, module_name): if rule.type == rule.UNKNOWN_RULE: self.raw_css += rule.cssText - def __getattr__(self, __name: str): + def __getattr__(self, __name: str) -> str: if __name not in self.classes: raise AttributeError(f"CSS class {__name} not found in {self.module_name}") return self.classes[__name].uuid @@ -139,14 +137,10 @@ def set_root_folder(cls, folder: Union[PathLike, str]): def _full_path(cls, css_path: Union[PathLike, str]) -> Path: if isinstance(css_path, str): if css_path.startswith("./"): - frame = inspect.stack()[2] - module = inspect.getmodule(frame[0]) - if module is None or module.__file__ is None: - raise ValueError( - "Cannot use relative path in a module without a file" - ) - - css_path = Path(module.__file__).parent / css_path + frame = get_frame(2) + module = frame.f_globals["__name__"] + module_path = Path(module.replace(".", "/")) + css_path = module_path.parent / css_path[2:] css_path = Path(css_path) diff --git a/src/pydom/styling/style_object.py b/src/pydom/styling/stylesheet.py similarity index 78% rename from src/pydom/styling/style_object.py rename to src/pydom/styling/stylesheet.py index 1c1b22a..882c534 100644 --- a/src/pydom/styling/style_object.py +++ b/src/pydom/styling/stylesheet.py @@ -6,9 +6,9 @@ T = TypeVar("T") -class StyleObject: +class StyleSheet: class _StyleProperty(Generic[T]): - def __init__(self, instance: "StyleObject", name: str): + def __init__(self, instance: "StyleSheet", name: str): self.instance = instance self.name = name.replace("_", "-") @@ -18,12 +18,12 @@ def __call__(self, value: T): def __init__( self, - *styles: Union["StyleObject", CSSProperties], + *styles: Union["StyleSheet", CSSProperties], **kwargs: Unpack[CSSProperties], ): self.style: Dict[str, object] = {} for style in styles: - if isinstance(style, StyleObject): + if isinstance(style, StyleSheet): style = style.style self.style.update(style) self.style.update(kwargs) @@ -32,7 +32,7 @@ def __init__( } def copy(self): - return StyleObject(self) + return StyleSheet(self) def to_css(self): return "".join(map(lambda x: f"{x[0]}:{x[1]};", self.style.items())) @@ -41,4 +41,4 @@ def __str__(self): return self.to_css() def __getattr__(self, name: str): - return StyleObject._StyleProperty(self, name) + return StyleSheet._StyleProperty(self, name) diff --git a/src/pydom/types/element.py b/src/pydom/types/element.py index be5a014..575abec 100644 --- a/src/pydom/types/element.py +++ b/src/pydom/types/element.py @@ -4,7 +4,7 @@ class ElementProps(TypedDict, total=False, closed=False): access_key: str auto_capitalize: str - class_name: str + classes: str content_editable: str dangerously_set_inner_html: str dir: str diff --git a/src/pydom/types/html/html_element.py b/src/pydom/types/html/html_element.py index d73bbdc..8d179d8 100644 --- a/src/pydom/types/html/html_element.py +++ b/src/pydom/types/html/html_element.py @@ -3,13 +3,13 @@ from typing_extensions import TypedDict if TYPE_CHECKING: - from pydom.styling import StyleObject + from pydom.styling import StyleSheet class HTMLElement(TypedDict, total=False, closed=False): access_key: Optional[str] auto_capitalize: Optional[str] - class_name: Optional[Union[str, Iterable[str]]] + classes: Optional[Union[str, Iterable[str]]] content_editable: Optional[str] dangerously_set_inner_html: Optional[Dict[Literal["__html"], str]] # data: Dict[str, str] # add this if needed in the future @@ -21,7 +21,7 @@ class HTMLElement(TypedDict, total=False, closed=False): lang: Optional[str] role: Optional[str] spell_check: Optional[str] - style: Optional[Union[str, "StyleObject"]] + style: Optional[Union[str, "StyleSheet"]] tab_index: Optional[str] title: Optional[str] translate: Optional[str] diff --git a/src/pydom/types/svg/svg_element_props.py b/src/pydom/types/svg/svg_element_props.py index f53862c..acb4e3d 100644 --- a/src/pydom/types/svg/svg_element_props.py +++ b/src/pydom/types/svg/svg_element_props.py @@ -6,7 +6,7 @@ class SVGElementProps(TypedDict, total=False): class _SVGElementProps(TypedDict, total=False): # Attributes also defined in HTMLAttributes - class_name: Optional[str] + classes: Optional[str] color: Optional[str] height: Optional[Union[int, str]] id: Optional[str] diff --git a/src/pydom/utils/functions.py b/src/pydom/utils/functions.py index f58f25c..e839a43 100644 --- a/src/pydom/utils/functions.py +++ b/src/pydom/utils/functions.py @@ -31,3 +31,11 @@ def flatten(iterable): yield from flatten(item) else: yield item + + +def remove_prefix(text: str, prefix: str) -> str: + if hasattr(str, "removeprefix"): + return text.removeprefix(prefix) + if text.startswith(prefix): + return text[len(prefix) :] + return text diff --git a/src/pydom/utils/get_frame.py b/src/pydom/utils/get_frame.py new file mode 100644 index 0000000..3809480 --- /dev/null +++ b/src/pydom/utils/get_frame.py @@ -0,0 +1,23 @@ +import sys +from sys import exc_info +from types import FrameType + + +def get_frame_fallback(n: int): + try: + raise Exception + except Exception: + frame: FrameType = exc_info()[2].tb_frame.f_back # type: ignore + for _ in range(n): + frame = frame.f_back # type: ignore + return frame + + +def load_get_frame_function(): + if hasattr(sys, "_getframe"): + return sys._getframe + + return get_frame_fallback + + +get_frame = load_get_frame_function() diff --git a/tests/components/__init__.py b/tests/components/__init__.py index 2aba954..d0e88bc 100644 --- a/tests/components/__init__.py +++ b/tests/components/__init__.py @@ -9,7 +9,7 @@ def __init__(self, name, version) -> None: def render(self): return Div( - class_name="plugin", + classes="plugin", )( f"{self.name} v{self.version}", ) @@ -21,7 +21,7 @@ def __init__(self, plugins=None) -> None: def render(self): return Div( - class_name="plugin-list", + classes="plugin-list", )( *[Plugin(plugin.name, plugin.version) for plugin in self.plugins], ) @@ -30,7 +30,7 @@ def render(self): class Card(Component): def render(self): return Div( - class_name="card", + classes="card", )( *self.children, ) @@ -39,7 +39,7 @@ def render(self): class CardTitle(Component): def render(self): return H3( - class_name="card-title", + classes="card-title", )( *self.children, ) diff --git a/tests/css/styles.css b/tests/css/styles.css new file mode 100644 index 0000000..a8082fe --- /dev/null +++ b/tests/css/styles.css @@ -0,0 +1,13 @@ +.card { + background-color: #fff; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin: 10px; + padding: 20px; +} + +.cardHeader { + font-size: 1.5em; + font-weight: bold; + margin-bottom: 10px; +} \ No newline at end of file diff --git a/tests/simple.py b/tests/simple.py index 4293131..c9e7b6d 100644 --- a/tests/simple.py +++ b/tests/simple.py @@ -6,7 +6,7 @@ def index(): return Page(title="Hello, world!")( - Div(class_name=["a", "b"])("Hello, world!") + Div(classes=["a", "b"])("Hello, world!") ) diff --git a/tests/test_css_modules.py b/tests/test_css_modules.py new file mode 100644 index 0000000..30c8d8f --- /dev/null +++ b/tests/test_css_modules.py @@ -0,0 +1,21 @@ +from pydom.styling import CSS + +from .base import TestCase + + +class CSSModulesTest(TestCase): + @classmethod + def setUpClass(cls) -> None: + styles = CSS.module("tests/css/styles.css") + cls.card_class = styles.card + cls.card_header_class = styles.cardHeader + + def test_class_name(self): + styles = CSS.module("tests/css/styles.css") + self.assertEqual(self.card_class, styles.card) + self.assertEqual(self.card_header_class, styles.cardHeader) + + def test_relative_css(self): + styles = CSS.module("./css/styles.css") + self.assertEqual(styles.card, self.card_class) + self.assertEqual(styles.cardHeader, self.card_header_class) diff --git a/tests/test_json_rendering.py b/tests/test_json_rendering.py index 8b4bf72..72d4f92 100644 --- a/tests/test_json_rendering.py +++ b/tests/test_json_rendering.py @@ -83,14 +83,14 @@ def render(self): "Hello", Div(), id="my-id", - class_name="my-class", + classes="my-class", ) class MyComponent2(Component): def render(self): return Div( MyComponent(), - class_name="my-class", + classes="my-class", ) self.assertRenderJson(