diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index da7f95e..dc53fb7 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -14,7 +14,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: '3.10' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -23,9 +23,12 @@ jobs: - name: Run test run: | PYTHONPATH=src uv run pytest . + - name: Run lint + run: | + uv run ruff check . - name: Build and publish run: | git fetch --unshallow --tags uv version $(git describe --tags --abbrev=0) uv build - uv publish --username __token__ --password ${{ secrets.PYPI_TOKEN }} \ No newline at end of file + uv publish --username __token__ --password ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c3965d..c7dc22a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,17 +8,24 @@ on: jobs: run-tests: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] steps: - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v6 with: - python-version: '3.9' + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install uv uv sync + - name: Run lint + run: | + uv run ruff check . - name: Run test run: | - PYTHONPATH=src uv run pytest . \ No newline at end of file + PYTHONPATH=src uv run pytest . diff --git a/.gitignore b/.gitignore index fc0fffe..89bb4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -165,4 +165,7 @@ Pipfile *.html node_modules/ -.DS_Store \ No newline at end of file +.DS_Store + +.opencode/ +AGENTS.md \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index fff7b06..d8bfe51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,4 +102,20 @@ ignore = [ "D102", "EM101", "PLR0913", + "SIM117", + "D100", + "D103", + "D104", + "D105", + "D107", + "PGH004", + "INP001", + "ANN204", + "G004", + "B008", + "EM102", + "E501", ] + +[tool.ruff.lint.per-file-ignores] +"tests/**/test_*.py" = ["S101", "ANN201"] diff --git a/src/uiwiz/app.py b/src/uiwiz/app.py index f89d771..3e1764b 100644 --- a/src/uiwiz/app.py +++ b/src/uiwiz/app.py @@ -79,9 +79,6 @@ def __init__( self.add_middleware(AsgiRequestMiddleware) self.add_middleware(GZipMiddleware) self.add_middleware(AsgiTtlMiddleware, cache_age=cache_age) - # self.add_middleware(StripHiddenFormFieldMiddleware) - self.extensions: dict[str, Path] = {} - self.app_paths: dict[str, Path] = {} self.exception_handler(RequestValidationError)(self.handle_validation_error) @@ -91,7 +88,7 @@ def get_extension(extension: str, filename: str) -> Response: if resource_key not in resources: return Response(status_code=404) - with open(resources[resource_key], encoding="utf-8") as f: + with resources[resource_key].open(encoding="utf-8") as f: content = f.read() content_type, _ = guess_type(resource_key) @@ -104,26 +101,36 @@ def add_static_files(self, url_path: str, local_directory: str | Path) -> None: def page( self, path: str, - *args, # noqa: ANN002 title: str | None = None, favicon: str | None = None, **kwargs, # noqa: ANN003 ) -> PageRouter: return PageRouter(page_definition_class=self.page_definition_class).page( path, - *args, title=title, favicon=favicon, router=self.router, **kwargs, ) - def ui(self, path: str, *args, include_js: bool = True, include_css: bool = True, **kwargs) -> PageRouter: + def ui(self, path: str, *, include_js: bool = True, include_css: bool = True, **kwargs: dict) -> PageRouter: return PageRouter().ui(path=path, include_js=include_js, include_css=include_css, router=self.router, **kwargs) - async def handle_validation_error(self, request: Request, exc: RequestValidationError) -> Response: - fields_with_errors = [item.get("loc")[1] for item in exc.errors()] - ok_fields = [item for item in exc.body if item not in fields_with_errors] + async def handle_validation_error(self, _: Request, exc: RequestValidationError) -> Response: + error_details = exc.errors() + fields_with_errors: list[str] = [] + for item in error_details: + loc = item.get("loc") or () + if len(loc) > 1: + field = str(loc[1]) + elif len(loc) == 1: + field = str(loc[0]) + else: + field = "body" + fields_with_errors.append(field) + + body = exc.body if isinstance(exc.body, dict) else {} + ok_fields = [item for item in body if item not in fields_with_errors] Frame.get_stack().del_stack() Frame.get_stack() @@ -133,11 +140,7 @@ async def handle_validation_error(self, request: Request, exc: RequestValidation toast.attributes["hx-swap-oob"] = "afterbegin" toast.attributes["hx-toast-data"] = json.dumps( jsonable_encoder( - { - "detail": exc.errors(), - "fieldErrors": fields_with_errors, - "fieldOk": ok_fields, - }, + {"detail": error_details, "fieldErrors": fields_with_errors, "fieldOk": ok_fields}, ), ) html = Html("").classes("alert alert-error relative") @@ -146,8 +149,10 @@ async def handle_validation_error(self, request: Request, exc: RequestValidation html.attributes["hx-toast-delete-button"] = lambda: btn.id with html: with Col(gap="").classes("relative"): - for item in exc.errors(): - Element(content=f"{item.get('loc')[1]}: {item.get('msg')}") + for item in error_details: + loc = item.get("loc") or () + loc_text = str(loc[1]) if len(loc) > 1 else str(loc[0]) if len(loc) == 1 else "body" + Element(content=f"{loc_text}: {item.get('msg')}") if not self.auto_close_toast_error: btn = Button("✕").classes("btn btn-sm btn-circle btn-ghost absolute right-2 top-2") diff --git a/src/uiwiz/docs/layout.py b/src/uiwiz/docs/layout.py index 0cbb2f9..4882d4c 100644 --- a/src/uiwiz/docs/layout.py +++ b/src/uiwiz/docs/layout.py @@ -1,13 +1,16 @@ from __future__ import annotations from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING from typing_extensions import override from uiwiz import PageDefinition, ui from uiwiz.svg.svg_handler import get_svg +if TYPE_CHECKING: + from collections.abc import Callable + parent = Path(__file__).parent pages = [] @@ -27,7 +30,7 @@ def __init__(self, path: str, title: str, file: Path | str | Callable[[], str] | if path not in [page.path for page in pages]: pages.append(self) - async def render(self): + async def render(self) -> None: """Render the page content.""" with ui.container(padding="p-4"): if callable(self.content): @@ -41,7 +44,7 @@ async def render(self): class Layout(PageDefinition): def __init__(self) -> None: - """Layout + """Layout. A layout element that provides a consistent structure for the application. """ @@ -67,13 +70,13 @@ def content(self, _: ui.element) -> ui.element | None: return content @override - def footer(self, _: ui.element): + def footer(self, _: ui.element) -> None: with ui.footer().classes("footer mx-auto footer-center p-4 text-base-content"): with ui.element("div").classes("flex flex-col items-center justify-center"): ui.label("Made with ❤️ by Uiwiz").classes("text-sm") ui.link("GitHub", "https://github.com/declow/uiwizard") - def nav(self, drawer): + def nav(self, drawer: ui.drawer) -> None: with ui.element().classes( "sticky top-0 flex h-16 justify-center bg-opacity-90 backdrop-blur transition-shadow duration-100 [transform:translate3d(0,0,0)] shadow-sm z-40", ): diff --git a/src/uiwiz/docs/main.py b/src/uiwiz/docs/main.py index b5ec7b7..c30e6b9 100644 --- a/src/uiwiz/docs/main.py +++ b/src/uiwiz/docs/main.py @@ -1,11 +1,12 @@ +from collections.abc import AsyncGenerator from contextlib import asynccontextmanager import uvicorn +from docs.layout import Layout, Page, pages +from docs.page_docs import docs_router from fastapi import Request from fastapi.responses import HTMLResponse -from docs.layout import Layout, Page, pages -from docs.page_docs import docs_router from uiwiz import PageDefinition, PageRouter, UiwizApp, ui from uiwiz.frame import Frame @@ -14,9 +15,8 @@ @asynccontextmanager -async def lifespan(app: UiwizApp): +async def lifespan(app: UiwizApp) -> AsyncGenerator[None, None]: """Lifespan event handler for the application.""" - for page in pages: """ Register each page with the application. @@ -34,8 +34,8 @@ async def lifespan(app: UiwizApp): app: UiwizApp = UiwizApp(lifespan=lifespan, page_definition_class=Layout) -async def render_md(request: Request): - page = page_dict.get(request.url.path, None) +async def render_md(request: Request) -> None: + page = page_dict.get(request.url.path) if page: await page.render() else: @@ -43,7 +43,7 @@ async def render_md(request: Request): ui.markdown("Page not found.") -async def not_found(): +async def not_found() -> None: with ui.container(padding="p-4"): ui.markdown("Page not found. Please check the URL or return to the home page.") for page in pages: @@ -51,7 +51,7 @@ async def not_found(): @app.exception_handler(404) -async def not_found_exception_handler(request: Request, exc: Exception): +async def not_found_exception_handler(request: Request, _: Exception) -> HTMLResponse: await app.page_definition_class().render(not_found, request, title="Not Found") return HTMLResponse( content=Frame.get_stack().render(), @@ -61,4 +61,4 @@ async def not_found_exception_handler(request: Request, exc: Exception): if __name__ == "__main__": - uvicorn.run("docs.main:app", host="0.0.0.0", port=8080, reload=True) + uvicorn.run("docs.main:app", host="0.0.0.0", port=8080, reload=True) # noqa: S104 diff --git a/src/uiwiz/docs/page_docs.py b/src/uiwiz/docs/page_docs.py index 4dce528..c4caa92 100644 --- a/src/uiwiz/docs/page_docs.py +++ b/src/uiwiz/docs/page_docs.py @@ -2,30 +2,32 @@ from docs.layout import Layout, Page from docs.pages.docs.elements import create_docs_element, create_elements +from fastapi import Request + from uiwiz import PageRouter, ui parent = Path(__file__).parent class LayoutDocs(Layout): - def after_render(self, request): - self.drawer.always_open(True) + def after_render(self, request: Request) -> None: # noqa: ARG002 + self.drawer.always_open(value=True) docs_router = PageRouter(prefix="/reference") @docs_router.page("/element/{name}", title="Elements") -async def docs_element(name: str): +async def docs_element(name: str) -> None: create_docs_element(getattr(ui, name), docs_router) @docs_router.page("/elements", title="Elements") -async def docs_elements(): +async def docs_elements() -> None: create_elements(docs_router) -async def reference(): +async def reference() -> None: with ui.container(padding="p-4"): ui.markdown("This is the reference page. It will contain links to all the elements and their documentation.") for value in dir(ui): diff --git a/src/uiwiz/docs/pages/docs/elements.py b/src/uiwiz/docs/pages/docs/elements.py index 6e2297d..1f17383 100644 --- a/src/uiwiz/docs/pages/docs/elements.py +++ b/src/uiwiz/docs/pages/docs/elements.py @@ -1,12 +1,13 @@ +import contextlib import inspect import typing -from pathlib import Path from docs.pages.docs.extract_doc import extract_text -from uiwiz import PageRouter, elements, ui +from uiwiz import PageRouter, ui -def get_class_properties(cls): + +def get_class_properties(cls: object) -> list[tuple[str, object]]: # Returns a list of (name, property) tuples for all properties in the class return [ (name, prop) @@ -16,19 +17,18 @@ def get_class_properties(cls): and not isinstance(prop, (property, staticmethod, classmethod)) ] -def get_clean_annotation_name(annotation): - import typing + +def get_clean_annotation_name(annotation: object) -> str: if hasattr(annotation, "__name__"): return annotation.__name__ - elif hasattr(annotation, "_name") and annotation._name: - return annotation._name - elif hasattr(typing, "ForwardRef") and isinstance(annotation, typing.ForwardRef): + if hasattr(annotation, "_name") and annotation._name: # noqa: SLF001 + return annotation._name # noqa: SLF001 + if hasattr(typing, "ForwardRef") and isinstance(annotation, typing.ForwardRef): return annotation.__forward_arg__ - else: - return str(annotation).replace("typing.", "") + return str(annotation).replace("typing.", "") -def extract_param_annotations(cls): +def extract_param_annotations(cls: object) -> dict[str, dict[str, str]]: sig = inspect.signature(cls.__init__) annotations = {} for name, param in sig.parameters.items(): @@ -42,8 +42,7 @@ def extract_param_annotations(cls): return annotations -def create_elements(router: PageRouter): - print(Path(elements.__file__).parent) +def create_elements(router: PageRouter) -> None: for element_name in dir(ui): if element_name.startswith("_"): continue @@ -51,8 +50,8 @@ def create_elements(router: PageRouter): create_docs_element(element, router) -def create_docs_element(element: ui.element, router: PageRouter): - app = router # noqa +def create_docs_element(element: ui.element, router: PageRouter) -> None: # noqa: C901, PLR0912 + app = router # noqa with ui.container(space_y="").classes("prose rounded-lg"): with ui.element().classes("flex flex-row"): ui.element("h2", f"ui.{element.__name__.lower()}") @@ -60,15 +59,16 @@ def create_docs_element(element: ui.element, router: PageRouter): des, cb, _ = extract_text(element.__init__.__doc__) ui.markdown(des).classes("text-content") with ui.element().classes("not-prose"): - try: - ui.markdown("""```python -""" + cb + " " + """```""") - except Exception: - pass - try: - exec(cb) - except Exception: - pass + with contextlib.suppress(Exception): + ui.markdown( + """```python +""" + + cb + + " " + + """```""", + ) + with contextlib.suppress(Exception): + exec(cb) # noqa: S102 ui.element("h3", "Constructor").classes("mt-4") anno = extract_param_annotations(element) @@ -80,8 +80,12 @@ def create_docs_element(element: ui.element, router: PageRouter): ui.element("span", f"= {details['default']}").classes("text-gray-500 ml-2") else: ui.element("span", "No default required argument").classes("text-gray-500 ml-2") - - methods = [method for method in inspect.getmembers(element, predicate=inspect.isfunction) if not method[0].startswith("_")] + + methods = [ + method + for method in inspect.getmembers(element, predicate=inspect.isfunction) + if not method[0].startswith("_") + ] if methods: with ui.element("h3").classes("mt-4"): @@ -91,20 +95,30 @@ def create_docs_element(element: ui.element, router: PageRouter): sig = inspect.signature(method) # Use get_type_hints to resolve forward references try: - type_hints = typing.get_type_hints(method, globalns=method.__globals__, localns=vars(element)) - except Exception: + type_hints = typing.get_type_hints( + method, + globalns=method.__globals__, + localns=vars(element), + ) + except Exception: # noqa: BLE001 type_hints = {} params = [] for name, param in sig.parameters.items(): if name == "self": continue annotation = type_hints.get(name, param.annotation) - param_type = get_clean_annotation_name(annotation) if annotation is not inspect.Parameter.empty else 'Any' + param_type = ( + get_clean_annotation_name(annotation) + if annotation is not inspect.Parameter.empty + else "Any" + ) params.append(f"{name}: {param_type}") param_str = ", ".join(params) # Return type - ret_anno = type_hints.get('return', sig.return_annotation) - return_type = get_clean_annotation_name(ret_anno) if ret_anno is not inspect.Signature.empty else "Any" + ret_anno = type_hints.get("return", sig.return_annotation) + return_type = ( + get_clean_annotation_name(ret_anno) if ret_anno is not inspect.Signature.empty else "Any" + ) # Method name in bold, params in blue, return type in green ui.element("span", f"{method_name}(").classes("font-bold pl-4") if param_str: diff --git a/src/uiwiz/docs/pages/docs/extract_doc.py b/src/uiwiz/docs/pages/docs/extract_doc.py index e47a131..65943f1 100644 --- a/src/uiwiz/docs/pages/docs/extract_doc.py +++ b/src/uiwiz/docs/pages/docs/extract_doc.py @@ -2,7 +2,7 @@ import textwrap -def extract_text(docstring: str) -> tuple[str, str, dict[str, str]]: +def extract_text(docstring: str) -> tuple[str, str, dict[str, str]]: # noqa: C901, PLR0912, PLR0915 docstring = textwrap.dedent(docstring or "").strip() lines = docstring.splitlines() @@ -54,7 +54,7 @@ def extract_text(docstring: str) -> tuple[str, str, dict[str, str]]: # Build the description by excluding the code block and param lines description_lines = [] in_code_block = False - for i, line in enumerate(lines): + for line in lines: stripped = line.strip() if re.match(r"\..\s*code-block", stripped, re.IGNORECASE): in_code_block = True @@ -65,8 +65,7 @@ def extract_text(docstring: str) -> tuple[str, str, dict[str, str]]: indent = len(line) - len(line.lstrip()) if indent >= (code_block_indent or 1): continue - else: - in_code_block = False + in_code_block = False if re.match(r":param(?:\s+\w+)?\s+\w+\s*:", stripped): continue if not in_code_block: diff --git a/src/uiwiz/element.py b/src/uiwiz/element.py index c8ca713..f1412d7 100644 --- a/src/uiwiz/element.py +++ b/src/uiwiz/element.py @@ -17,7 +17,7 @@ class _Attributes(dict): - def __setitem__(self, key: Any, value: Any, escape: bool = True) -> None: + def __setitem__(self, key: Any, value: Any, escape: bool = True) -> None: # noqa: ANN401 if escape: if isinstance(value, str): value = html.escape(value) @@ -31,6 +31,7 @@ def __init__( self, tag: ELEMENT_TYPES = "div", content: str = "", + *, render_html: bool = True, oob: bool = False, **kwargs: dict[str, str] | None, @@ -113,14 +114,14 @@ def __enter__(self) -> Self: return self - def __exit__(self, *_) -> None: + def __exit__(self, *_: object) -> None: if self.external_tree_element: self.stack.current_element = self.external_tree_element self.external_tree_element = None else: self.stack.current_element = self.parent_element - def __init_subclass__(cls, extensions: list[Path] | None = None, **kwargs) -> None: + def __init_subclass__(cls, extensions: list[Path] | None = None, **kwargs: dict) -> None: super().__init_subclass__(**kwargs) cls.extensions = extensions if extensions: @@ -141,7 +142,7 @@ def value(self) -> str: return self.attributes.get("value") @value.setter - def value(self, value) -> None: + def value(self, value: str) -> None: self.attributes["value"] = value @property @@ -149,7 +150,7 @@ def content(self) -> str: return self.__content__ @content.setter - def content(self, content) -> None: + def content(self, content: str) -> None: self.__content__ = html.escape(str(content)) @property @@ -164,7 +165,7 @@ def get_classes(self) -> str: """ return self.attributes["class"] - def classes(self, input: str = "") -> Self: + def classes(self, _input: str = "") -> Element: """Set tailwind classes for the element. :param input: The tailwind classes to apply to the element. @@ -176,9 +177,9 @@ def classes(self, input: str = "") -> Self: else getattr(self.__class__, "root_class", "") ) if clazz == "": - clazz = input - elif input: - clazz += f" {input}" + clazz = _input + elif _input: + clazz += f" {_input}" if clazz: self.attributes["class"] = clazz self.size(self._size) @@ -207,7 +208,7 @@ def size(self, size: ELEMENT_SIZE) -> Self: self._size = size return self - def render(self, render_script: bool = True) -> str: + def render(self, *, render_script: bool = True) -> str: """Render the element as HTML. :param render_script: If any element has a javascript script, it will be rendered as well. @@ -246,14 +247,14 @@ def __render_self__(self) -> str: html = "".join(lst) return self.after_render(html) - def before_render(self): - """This method is called before the element is rendered.""" + def before_render(self) -> None: + """This method is called before the element is rendered.""" # noqa: D401, D404 def after_render(self, html: str) -> str: - """This method is called after the element is rendered. + """Method is called after the element is rendered. :param html: The rendered HTML of the element. - """ + """ # noqa: D401 return html def __add_event_to_attributes__(self) -> None: diff --git a/src/uiwiz/elements/ace/ace.py b/src/uiwiz/elements/ace/ace.py index e64e494..0802fee 100644 --- a/src/uiwiz/elements/ace/ace.py +++ b/src/uiwiz/elements/ace/ace.py @@ -55,7 +55,7 @@ def __init__( sql_options: SqlOptions | None = None, ace_options: AceOptions | None = None, ) -> None: - """Ace Editor + """Ace Editor. Use the Ace Editor to edit code in a textarea element. diff --git a/src/uiwiz/elements/aggrid/aggrid.py b/src/uiwiz/elements/aggrid/aggrid.py index e728363..ecd4d72 100644 --- a/src/uiwiz/elements/aggrid/aggrid.py +++ b/src/uiwiz/elements/aggrid/aggrid.py @@ -19,15 +19,15 @@ class OPTIONS(str, Enum): - autoSizeColumn = "autoSizeAll" - fitColumnContent = "sizeToFit" + autoSizeColumn = "autoSizeAll" # noqa: N815 + fitColumnContent = "sizeToFit" # noqa: N815 class Aggrid(Element, extensions=[CSS_PATH, LIB_PATH, JS_PATH]): _classes: str = "ag-theme-quartz ag-theme-uiwiz w-full" def __init__(self, df: pl.DataFrame | None) -> None: - """Aggrid + """Aggrid. Use aggrid to display a DataFrame in a grid format. Can be used anywhere in a @page_router.ui("") or @app.ui("") @@ -54,13 +54,14 @@ def __init__(self, df: pl.DataFrame | None) -> None: cols, rows = Aggrid.create_cols_and_rows(df) self.attributes["hx-ext"] = "hx-aggrid" - self.attributes.__setitem__("hx-aggrid-cols", cols, False) - self.attributes.__setitem__("hx-aggrid-rows", rows, False) + self.attributes.__setitem__("hx-aggrid-cols", cols, False) # noqa: FBT003 + self.attributes.__setitem__("hx-aggrid-rows", rows, False) # noqa: FBT003 self.attributes["hx-aggrid"] = "/data" @staticmethod def create_cols_and_rows( df: pl.DataFrame | None, + *, escape: bool = True, ) -> tuple[list[Any] | str, list[Any] | str]: cols = [] @@ -76,7 +77,9 @@ def create_cols_and_rows( return cols, rows @staticmethod - def response(df: pl.DataFrame | None, headers: dict[str, str] = {}) -> JSONResponse: + def response(df: pl.DataFrame | None, headers: dict[str, str] | None = None) -> JSONResponse: + if headers is None: + headers = {} _headers = {"HX-Trigger": "aggridUpdate"} | headers - cols, rows = Aggrid.create_cols_and_rows(df, False) + cols, rows = Aggrid.create_cols_and_rows(df, escape=False) return JSONResponse({"cols": cols, "rows": rows}, headers=_headers) diff --git a/src/uiwiz/elements/avatar.py b/src/uiwiz/elements/avatar.py index 2743687..ca42560 100644 --- a/src/uiwiz/elements/avatar.py +++ b/src/uiwiz/elements/avatar.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from uiwiz.element import Element @@ -6,8 +8,7 @@ class Avatar(Element): _classes_inner: str = "w-{size} rounded-full" def __init__(self, path: str, size: int = 12) -> None: - """ - Display an avatar image. + """Display an avatar image. :param path: The path to the image to display. :type path: str @@ -21,7 +22,7 @@ def __init__(self, path: str, size: int = 12) -> None: img = Element("img") img.attributes["src"] = path - def classes(self, input: str = ""): + def classes(self, _input: str = "") -> Avatar: if hasattr(self, "container"): - self.container.classes(input) + self.container.classes(_input) return self diff --git a/src/uiwiz/elements/button.py b/src/uiwiz/elements/button.py index ce97bf3..999c25e 100644 --- a/src/uiwiz/elements/button.py +++ b/src/uiwiz/elements/button.py @@ -14,6 +14,7 @@ class Button(OnEvent): def __init__(self, title: str) -> None: """Create a button element, with the given title. + Can be used to trigger events. Example: diff --git a/src/uiwiz/elements/checkbox.py b/src/uiwiz/elements/checkbox.py index a5ecab9..8a031e8 100644 --- a/src/uiwiz/elements/checkbox.py +++ b/src/uiwiz/elements/checkbox.py @@ -5,8 +5,8 @@ class Checkbox(OnEvent): root_class: str = "checkbox" root_size: str = "checkbox-{size}" - def __init__(self, name: str, checked: bool = False) -> None: - """Checkbox element + def __init__(self, name: str, *, checked: bool = False) -> None: + """Checkbox element. Used for forms where a user can select one or more options. .. code-block:: python diff --git a/src/uiwiz/elements/col.py b/src/uiwiz/elements/col.py index 1fc80e7..126ec63 100644 --- a/src/uiwiz/elements/col.py +++ b/src/uiwiz/elements/col.py @@ -10,7 +10,7 @@ def __init__( gap: str = "gap-4", padding: str = "p-4", ) -> None: - """Col + """Col. Align children elements vertically in a column layout. @@ -28,6 +28,7 @@ def __init__( :type gap: str, optional :param padding: The padding of the column. :type padding: str, optional + """ super().__init__() self.__root_class__ = Col.root_class.format(item_position=item_position, gap=gap, padding=padding) diff --git a/src/uiwiz/elements/container.py b/src/uiwiz/elements/container.py index f2d5e4d..6674916 100644 --- a/src/uiwiz/elements/container.py +++ b/src/uiwiz/elements/container.py @@ -5,7 +5,7 @@ class Container(Element): root_class: str = "container flex flex-col mx-auto {max_w} {padding} {space_y} grow" def __init__(self, max_w: str = "max-w-[960px]", padding: str = "pt-4 pb-4", space_y: str = "space-y-4") -> None: - """Container + """Container. A container element that centers a box in the middle of the screen. """ diff --git a/src/uiwiz/elements/datepicker.py b/src/uiwiz/elements/datepicker.py index a9ea165..e1d7436 100644 --- a/src/uiwiz/elements/datepicker.py +++ b/src/uiwiz/elements/datepicker.py @@ -16,7 +16,7 @@ def __init__( name: str, value: datetime | None = None, ) -> None: - """Datepicker element + """Datepicker element. This element is used for date inputs diff --git a/src/uiwiz/elements/dict/dict.py b/src/uiwiz/elements/dict/dict.py index e10574d..07fa4d9 100644 --- a/src/uiwiz/elements/dict/dict.py +++ b/src/uiwiz/elements/dict/dict.py @@ -14,8 +14,8 @@ class Dict(Element, extensions=[JS_PATH]): - def __init__(self, data: Iterable[dict] | dict, copy_to_clipboard: bool = False) -> None: - """Dict element + def __init__(self, data: Iterable[dict] | dict, *, copy_to_clipboard: bool = False) -> None: + """Element. Will render a dict or list data as a formatted json in the browser @@ -40,59 +40,62 @@ def __init__(self, data: Iterable[dict] | dict, copy_to_clipboard: bool = False) self._border_classes = "border border-base-content rounded-lg shadow-lg w-96 shadow-md w-full mb-5" self.copy_to_clipboard = copy_to_clipboard - def key_classes(self, classes: str): + def key_classes(self, classes: str) -> Dict: self.key_class = classes return self - def value_classes(self, classes: str): + def value_classes(self, classes: str) -> Dict: self.value_class = classes return self - def border_classes(self, classes: str): + def border_classes(self, classes: str) -> Dict: self._border_classes = classes return self - def before_render(self): + def before_render(self) -> None: super().before_render() if not self.did_render: self.did_render = True self.generate(self.data) - def generate(self, data: Iterable[dict] | dict): - def format_data( + def generate(self, data: Iterable[dict] | dict) -> None: # noqa: C901, PLR0915 + def format_data( # noqa: C901, PLR0912 data: dict | list, depth: int = 0, + *, is_last_item: bool = False, do_indent: bool = False, obj: bool = False, - ): + ) -> None: indent = " " * depth if isinstance(data, list): - last_item = data[-1] Element("pre", content=indent + "[") - for item in data: - is_last = item == last_item + for idx, item in enumerate(data): + is_last = idx == len(data) - 1 with Element().classes("flex flex-col flex-wrap"): format_data(item, depth=depth + 2, is_last_item=is_last, do_indent=True) Element("pre", content=indent + "]") return if isinstance(data, dict): - last_item = list(data.values())[-1] - is_last = data == last_item - if not obj: Element("pre", content=indent + "{") - format_data(data, depth=depth + 2, is_last_item=is_last, obj=True) + format_data(data, depth=depth + 2, is_last_item=is_last_item, obj=True) else: - for key, value in data.items(): - is_last = last_item == value + items = list(data.items()) + for idx, (key, value) in enumerate(items): + is_last = idx == len(items) - 1 key_content = indent + f'"{key}"' + ":" if isinstance(value, list): key_content += " [" with Element(tag="pre", content=key_content).classes("flex flex-col flex-wrap"): - for _item in value: - format_data(_item, depth=depth + 2, is_last_item=_item == value[-1], do_indent=True) + for inner_idx, _item in enumerate(value): + format_data( + _item, + depth=depth + 2, + is_last_item=inner_idx == len(value) - 1, + do_indent=True, + ) Element(tag="pre", content=indent + "]" + ("," if not is_last else "")) elif isinstance(value, dict): key_content += " {" @@ -121,6 +124,8 @@ def format_data( Element(content=str(data)).classes(self.value_class) Element(tag="div", content="," if not is_last_item else "") + render_data: dict | list = data if isinstance(data, dict) else list(data) + with self.classes(f"{self._border_classes} {self._border_position}"): if self.copy_to_clipboard: with Button("").classes("absolute top-2 right-2 wiz-copy-content") as btn: @@ -128,5 +133,5 @@ def format_data( icon = Html(content=get_svg("copy")).classes("w-6 h-6") icon.attributes["style"] = "fill: var(--color-base-content);" - btn.attributes["data-copy-data"] = json.dumps(data, indent=2) - format_data(data, is_last_item=True) + btn.attributes["data-copy-data"] = json.dumps(render_data, indent=2) + format_data(render_data, is_last_item=True) diff --git a/src/uiwiz/elements/divider.py b/src/uiwiz/elements/divider.py index b2beac9..d08b03a 100644 --- a/src/uiwiz/elements/divider.py +++ b/src/uiwiz/elements/divider.py @@ -6,7 +6,7 @@ class Divider(Element): _classes_hor = "divider-horizontal" def __init__(self, text: str = "") -> None: - """Divider + """Divider. Display a divider line between elements. @@ -22,6 +22,7 @@ def __init__(self, text: str = "") -> None: :param text: The text to display in the divider. :type text: str, optional + """ super().__init__("div") self.content = text diff --git a/src/uiwiz/elements/drawer.py b/src/uiwiz/elements/drawer.py index 4daa134..b6ba3c3 100644 --- a/src/uiwiz/elements/drawer.py +++ b/src/uiwiz/elements/drawer.py @@ -32,8 +32,11 @@ def __enter__(self): if not hasattr(self, "setup"): self.setup = DrawerSetup() self.setup.__enter__() + return self - def __exit__(self, *_): + def __exit__(self, *_: object) -> bool: + if hasattr(self, "setup"): + self.setup.__exit__(*_) return super().__exit__(*_) @@ -48,8 +51,8 @@ def __init__(self) -> None: class Drawer(Element): _classes: str = "drawer bg-base-100" - def __init__(self, always_open: bool = False, right: bool = False) -> None: - """Drawer + def __init__(self, *, always_open: bool = False, right: bool = False) -> None: + """Drawer. A drawer is a panel that slides in from the side of the screen. It can be used to display additional content or controls. @@ -100,14 +103,14 @@ def drawer_button(self) -> Element: with self.__drawer_button_menu__: Html(get_svg("menu")) - def always_open(self, value: bool) -> None: + def always_open(self, value: bool) -> None: # noqa: FBT001 """Set the drawer to always open until the screen size is too small.""" if value: self.classes(self.attributes["class"] + " lg:drawer-open") else: self.classes(self.attributes["class"].replace("lg:drawer-open", "")) - def right(self, value: bool) -> None: + def right(self, value: bool) -> None: # noqa: FBT001 """Set the drawer to open from the right side of the screen.""" if value: self.classes(self.attributes["class"] + " drawer-end") diff --git a/src/uiwiz/elements/dropdown.py b/src/uiwiz/elements/dropdown.py index a6f3b59..0be095b 100644 --- a/src/uiwiz/elements/dropdown.py +++ b/src/uiwiz/elements/dropdown.py @@ -1,11 +1,14 @@ from __future__ import annotations -from collections import namedtuple +from typing import NamedTuple from uiwiz.element import Element from uiwiz.elements.extensions.on_event import OnEvent -DropdownItem = namedtuple("DropdownItem", ["name", "value"]) + +class DropdownItem(NamedTuple): + name: str + value: str class Dropdown(OnEvent): @@ -19,7 +22,7 @@ def __init__( items: list[str] | list[DropdownItem], placeholder: str | None = None, ) -> None: - """Dropdown + """Dropdown. A dropdown is a list in which the selected item is always visible, and the others are visible on demand. @@ -33,7 +36,7 @@ def __init__( :param name: The name of the dropdown :type name: str :param items: List of items in the dropdown - :type items: Union[list[str], list[DropdownItem]] + :type items: list[str] | list[DropdownItem] :param placeholder: Placeholder text :type placeholder: str, optional """ diff --git a/src/uiwiz/elements/echart/echart.py b/src/uiwiz/elements/echart/echart.py index 0e27f91..efaec23 100644 --- a/src/uiwiz/elements/echart/echart.py +++ b/src/uiwiz/elements/echart/echart.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from pathlib import Path @@ -15,7 +17,7 @@ class EChart(Element, extensions=[LIB_PATH, THEME_PATH, JS_PATH]): name: str = "data-wz-echart" def __init__(self, options: dict, height: str = "h-80") -> None: - """EChart element + """EChart element. See https://echarts.apache.org/examples/en/index.html for examples on how to use ECharts @@ -48,11 +50,13 @@ def __init__(self, options: dict, height: str = "h-80") -> None: self.attributes["hx-ext"] = EChart.name self.classes("w-full h-full") - def container_classes(self, input: str) -> "EChart": - self.parent_element.classes(input) + def container_classes(self, _input: str) -> EChart: + self.parent_element.classes(_input) return self @staticmethod - def response(data: dict, headers: dict[str, str] = {}) -> JSONResponse: + def response(data: dict, headers: dict[str, str] | None = None) -> JSONResponse: + if headers is None: + headers = {} _headers = {"HX-Trigger": json.dumps({"uiwizUpdateEChart": data})} | headers return JSONResponse(data, headers=_headers) diff --git a/src/uiwiz/elements/extensions/bindable.py b/src/uiwiz/elements/extensions/bindable.py index 1aaaabe..f989cba 100644 --- a/src/uiwiz/elements/extensions/bindable.py +++ b/src/uiwiz/elements/extensions/bindable.py @@ -10,16 +10,23 @@ def bind_text_from( element: Element, trigger: ON_EVENTS = "input delay:20ms", swap: SWAP_EVENTS = "outerHTML", - ): - """ - Bind the text of this element to data of another element. + ) -> "Bindable": + """Bind the text of this element to data of another element. + Requires the other element to have a name attribute. :param element: Element to bind to + :type element: Element :param trigger: Event trigger to bind to + :type trigger: ON_EVENTS, optional + :param swap: Swap method to use + :type swap: SWAP_EVENTS, optional + :return: The bindable element + :rtype: Bindable """ - assert element.attributes.get("name") is not None + if element.attributes.get("name") is None: + raise ValueError("Element must have a name attribute to bind to") - async def bind_value(request: Request): + async def bind_value(request: Request) -> Bindable: data = await request.json() self._set_frame_and_root() diff --git a/src/uiwiz/elements/footer.py b/src/uiwiz/elements/footer.py index 38d6c9e..344cff2 100644 --- a/src/uiwiz/elements/footer.py +++ b/src/uiwiz/elements/footer.py @@ -6,7 +6,7 @@ class Footer(Element): _classes: str = "items-center p-4 bg-neutral text-neutral-content" def __init__(self) -> None: - """Footer + """Footer. A footer is a section at the bottom of a page that contains information about the page. diff --git a/src/uiwiz/elements/form.py b/src/uiwiz/elements/form.py index 9f258e4..7a7d3b5 100644 --- a/src/uiwiz/elements/form.py +++ b/src/uiwiz/elements/form.py @@ -12,7 +12,7 @@ class Form(Element): root_class: str = "flex flex-col items-start gap-4 p-4 " def __init__(self) -> None: - """Form + """Form. A form is a section of a document containing input elements. @@ -35,6 +35,6 @@ def on_submit( target: TARGET_TYPE = None, swap: SWAP_EVENTS = "none", params: dict[str, str] | None = None, - ): + ) -> Form: self.event = {"func": func, "trigger": "submit", "target": target, "swap": swap, "params": params} return self diff --git a/src/uiwiz/elements/full_width.py b/src/uiwiz/elements/full_width.py index 9bfa005..816c17b 100644 --- a/src/uiwiz/elements/full_width.py +++ b/src/uiwiz/elements/full_width.py @@ -5,7 +5,7 @@ class FullWidth(Element): root_class = "w-full" def __init__(self) -> None: - """FullWidth + """FullWidth. This element is used to create a full-width container. diff --git a/src/uiwiz/elements/hidden_input.py b/src/uiwiz/elements/hidden_input.py index c4f96c0..ff2033a 100644 --- a/src/uiwiz/elements/hidden_input.py +++ b/src/uiwiz/elements/hidden_input.py @@ -11,9 +11,9 @@ class HiddenInput(Element): def __init__( self, name: str, - value: Any | None = None, + value: Any | None = None, # noqa: ANN401 ) -> None: - """HiddenInput + """HiddenInput. This element is used for hidden input data that should not be visible to the user. But it will be sent back to the server when the form is submitted. diff --git a/src/uiwiz/elements/html.py b/src/uiwiz/elements/html.py index dbe74c9..41ce371 100644 --- a/src/uiwiz/elements/html.py +++ b/src/uiwiz/elements/html.py @@ -3,7 +3,7 @@ class Html(Element): def __init__(self, content: str) -> None: - """Html element + """Html element. Will render a raw htlm string. This is useful for embedding custom HTML content directly into the UI. @@ -23,5 +23,5 @@ def content(self) -> str: return self.__content__ @content.setter - def content(self, content: str): + def content(self, content: str) -> None: self.__content__ = content diff --git a/src/uiwiz/elements/input.py b/src/uiwiz/elements/input.py index e3d03c2..ca1c644 100644 --- a/src/uiwiz/elements/input.py +++ b/src/uiwiz/elements/input.py @@ -15,7 +15,7 @@ def __init__( value: str | None = None, placeholder: str | None = None, ) -> None: - """Input + """Input. This element is used for input data @@ -44,10 +44,9 @@ def placeholder(self) -> str | None: return self.attributes.get("placeholder") @placeholder.setter - def placeholder(self, value: str | None): + def placeholder(self, value: str | None) -> None: if value: self.attributes["placeholder"] = value - return self def set_placeholder(self, value: str) -> Input: self.placeholder = value @@ -59,7 +58,7 @@ def set_floating_label(self, label: str | None = None) -> Input: self.parent_element.children.remove(self) with Element("label").classes("floating-label") as container: - value = label if label else self.placeholder + value = label or self.placeholder self.label_text = Element("span", content=value) container.children.append(self) self.parent_element = container diff --git a/src/uiwiz/elements/label.py b/src/uiwiz/elements/label.py index 67a2aa4..31546eb 100644 --- a/src/uiwiz/elements/label.py +++ b/src/uiwiz/elements/label.py @@ -9,7 +9,7 @@ class Label(Bindable): root_size: str = "label-{size}" def __init__(self, text: str | None = None, for_: Element | None = None) -> None: - """Label + """Label. This element is used for labels that can be bound to form elements. @@ -51,8 +51,8 @@ def set_for(self, for_: Element) -> Label: self.attributes["for"] = for_.id return self - def _combined_label(self, text: str, for_: Element, label_first: bool = True) -> Label: - """Helper to create a label with text and bind it to an element, order controlled by label_first.""" + def _combined_label(self, text: str, for_: Element, *, label_first: bool = True) -> Label: + """Create a label with text and bind it to an element, order controlled by label_first.""" if text: self.content = text # Remove from previous parents diff --git a/src/uiwiz/elements/link.py b/src/uiwiz/elements/link.py index 7f1e40c..c810e70 100644 --- a/src/uiwiz/elements/link.py +++ b/src/uiwiz/elements/link.py @@ -1,16 +1,19 @@ from __future__ import annotations -from typing import Callable +from typing import TYPE_CHECKING from uiwiz.element import Element +if TYPE_CHECKING: + from collections.abc import Callable + class Link(Element): root_class: str = "link " _classes: str = "link-hover" def __init__(self, text: str, link: Callable[[], str] | str) -> None: - """Link element + """Link element. Link element that can be used to navigate to a different page. Can be used as a button or a link. diff --git a/src/uiwiz/elements/markdown/markdown.py b/src/uiwiz/elements/markdown/markdown.py index 0c0e95b..2df9216 100644 --- a/src/uiwiz/elements/markdown/markdown.py +++ b/src/uiwiz/elements/markdown/markdown.py @@ -13,7 +13,7 @@ class Markdown(Element, extensions=[MARKDOWN, CODE_HIGHLIGHT]): def __init__(self, content: str = "", extras: list[str] | None = None) -> None: - """Markdown + """Markdown. This element is used to render markdown content. diff --git a/src/uiwiz/elements/nav.py b/src/uiwiz/elements/nav.py index 57ee148..99a8215 100644 --- a/src/uiwiz/elements/nav.py +++ b/src/uiwiz/elements/nav.py @@ -6,8 +6,8 @@ class Nav(Element): root_size: str = "navbar-{size}" def __init__(self) -> None: - """Nav - + """Nav. + This element is used for navigation bars .. code-block:: python @@ -16,6 +16,6 @@ def __init__(self) -> None: with ui.nav(): ui.link("Home", "/") ui.link("Docs", "/docs") - + """ super().__init__() diff --git a/src/uiwiz/elements/number.py b/src/uiwiz/elements/number.py index c217d8b..374acdc 100644 --- a/src/uiwiz/elements/number.py +++ b/src/uiwiz/elements/number.py @@ -10,12 +10,12 @@ def __init__( self, name: str, value: int, - min: int, - max: int, + min: int, # noqa: A002 + max: int, # noqa: A002 step: int | None = None, placeholder: str | None = None, ) -> None: - """Number + """Number. This element is used for number inputs @@ -30,7 +30,7 @@ def __init__( :param max: maximum value of the number input :param step: step value of the number input :param placeholder: placeholder text for the number input - """ + """ # noqa: D401 super().__init__("input") self.attributes["type"] = "number" self.attributes["name"] = name @@ -46,16 +46,14 @@ def __init__( def min(self) -> int: return self.attributes["min"] - @min.setter - def min(self, value: int): + @min.setter # noqa: A003 + def min(self, value: int) -> None: self.attributes["min"] = value - return self @property def max(self) -> int: return self.attributes["max"] - @max.setter - def max(self, value: int): + @max.setter # noqa: A003 + def max(self, value: int) -> None: self.attributes["max"] = value - return self diff --git a/src/uiwiz/elements/radio.py b/src/uiwiz/elements/radio.py index cfc9845..304cca0 100644 --- a/src/uiwiz/elements/radio.py +++ b/src/uiwiz/elements/radio.py @@ -5,8 +5,8 @@ class Radio(OnEvent): root_class: str = "radio" root_size: str = "radio-{size}" - def __init__(self, name: str, checked: bool = False) -> None: - """Radio + def __init__(self, name: str, *, checked: bool = False) -> None: + """Radio. This element is used for radio buttons diff --git a/src/uiwiz/elements/range.py b/src/uiwiz/elements/range.py index 98dc1c0..72e0c1c 100644 --- a/src/uiwiz/elements/range.py +++ b/src/uiwiz/elements/range.py @@ -7,8 +7,8 @@ class Range(OnEvent): root_class: str = "range " root_size: str = "range-{size}" - def __init__(self, name: str, value: int, min: int, max: int, step: int | None = None) -> None: - """Range + def __init__(self, name: str, value: int, min: int, max: int, step: int | None = None) -> None: # noqa: A002 + """Range. This element is used for range inputs @@ -36,16 +36,14 @@ def __init__(self, name: str, value: int, min: int, max: int, step: int | None = def min(self) -> int: return self.attributes["min"] - @min.setter - def min(self, value: int): + @min.setter # noqa: A003 + def min(self, value: int) -> None: self.attributes["min"] = value - return self @property def max(self) -> int: return self.attributes["max"] - @max.setter - def max(self, value: int): + @max.setter # noqa: A003 + def max(self, value: int) -> None: self.attributes["max"] = value - return self diff --git a/src/uiwiz/elements/row.py b/src/uiwiz/elements/row.py index 5f39ee6..789fa79 100644 --- a/src/uiwiz/elements/row.py +++ b/src/uiwiz/elements/row.py @@ -11,7 +11,7 @@ def __init__( gap: str = "gap-4", padding: str = "", ) -> None: - """Row + """Row. Align children elements vertically. diff --git a/src/uiwiz/elements/spinner.py b/src/uiwiz/elements/spinner.py index 172f2d5..aeebbf7 100644 --- a/src/uiwiz/elements/spinner.py +++ b/src/uiwiz/elements/spinner.py @@ -12,9 +12,9 @@ class Spinner(Element): _classes: str = "loading-{0} loading-{1}" def __init__(self, *args: Element) -> None: - """Spinner + """Spinner. - Show a spinner while making a request + Show a spinner while making a request. Example: .. code-block:: python @@ -39,11 +39,11 @@ def some_endpoint(): self.__self__format() @property - def spinner_for(self): + def spinner_for(self) -> Element: return self._spinner_for @spinner_for.setter - def spinner_for(self, elements: tuple[Element]) -> Spinner: + def spinner_for(self, elements: list[Element]) -> Spinner: self._spinner_for = elements if elements: for element in elements: diff --git a/src/uiwiz/elements/table.py b/src/uiwiz/elements/table.py index d8ebe2f..eb392eb 100644 --- a/src/uiwiz/elements/table.py +++ b/src/uiwiz/elements/table.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Optional, get_type_hints +from typing import TYPE_CHECKING, get_type_hints from pydantic import BaseModel @@ -9,6 +9,8 @@ from uiwiz.models.model_handler import ModelForm if TYPE_CHECKING: + from collections.abc import Callable + import polars as pl from uiwiz.element_types import ELEMENT_SIZE @@ -17,7 +19,7 @@ class ModelFormRender(ModelForm): - def render_model(self, *args, **kwargs) -> Form: + def render_model(self, *args: object, **kwargs: dict) -> Form: # noqa: ARG002 self.button = Button("Save") self.button.render_html = False @@ -30,7 +32,7 @@ class Table(Element): ) def __init__(self, data: list[BaseModel], id_column_name: str | None = None) -> None: - """Creates a table from a list of pydantic models + """Create a table from a list of pydantic models. Example: .. code-block:: python @@ -65,13 +67,13 @@ class User(BaseModel): self.container = container self.data = data self.did_render: bool = False - self.edit: Optional[FUNC_TYPE] = None - self.delete: Optional[FUNC_TYPE] = None - self.create: Optional[FUNC_TYPE] = None - self.id_column_name: Optional[str] = id_column_name + self.edit: FUNC_TYPE | None = None + self.delete: FUNC_TYPE | None = None + self.create: FUNC_TYPE | None = None + self.id_column_name: str | None = id_column_name def set_border(self, border_classes: str = "border border-base-content") -> Table: - """Set the border classes for the table + """Set the border classes for the table. :param border_classes: The border classes to set :return: The current instance of the element. @@ -151,9 +153,9 @@ def render_edit_row( save: FUNC_TYPE, cancel: FUNC_TYPE, size: ELEMENT_SIZE = "sm", - **kwargs, + **kwargs: dict, ) -> Element: - """Render a table row with inputs and cancel/save button + """Render a table row with inputs and cancel/save button. :param model: The Pydantic model to render :param id_column_name: The column that should be used with edit or delete as path param @@ -181,7 +183,7 @@ def __render_save_button__( id_column_name: str, model: BaseModel, size: ELEMENT_SIZE = "sm", - **kwargs, + **kwargs: dict, ) -> Element: with Element("td").classes("flex justify-end join"): Button("Cancel").size(size).on( @@ -245,7 +247,7 @@ def render_row( delete: FUNC_TYPE | None = None, size: ELEMENT_SIZE = "sm", ) -> Element: - """Render a table row + """Render a table row. :param row: The instance Pydantic model to render :param id_column_name: The optional column that should be used with edit or delete @@ -296,7 +298,7 @@ def render_row( @classmethod def from_dataframe(cls, df: pl.DataFrame) -> Element: - """Render a polars.DataFrame + """Render a polars.DataFrame. :param df: The DataFrame to render :return: The container element diff --git a/src/uiwiz/elements/tabs.py b/src/uiwiz/elements/tabs.py index ce4bb2c..cdb7b83 100644 --- a/src/uiwiz/elements/tabs.py +++ b/src/uiwiz/elements/tabs.py @@ -8,7 +8,8 @@ class Tabs(Element): _classes: str = "tabs-box" def __init__(self) -> None: - """Tabs + """Tabs. + This element is used for tab navigation. It should be used as a context manager to create tabs. @@ -28,10 +29,10 @@ def __init__(self) -> None: self.classes(Tabs.root_class + Tabs._classes) self.attributes["role"] = "tablist" - def __exit__(self, *_): - super().__exit__(*_) + def __exit__(self, *args: object, **kwargs: dict) -> None: + super().__exit__(*args, **kwargs) has_active = False - first_child_tab = None + first_child_tab: Tab | None = None for child in self.children: if not isinstance(child, Tab): continue @@ -41,15 +42,15 @@ def __exit__(self, *_): if "checked" in child.attributes: has_active = True - if not has_active: + if not has_active and first_child_tab is not None: first_child_tab.active() class Tab(Element): _classes: str = "tab" - def __init__(self, title: str, active: bool | None = None) -> None: - """Tab + def __init__(self, title: str, *, active: bool | None = None) -> None: + """Tab. This element is used for tab navigation and should be used inside a :class:`Tabs` element. @@ -70,7 +71,11 @@ def __init__(self, title: str, active: bool | None = None) -> None: :param active: If the tab should be active by default. Defaults to None, which will make the first tab active. """ self.selector = Element("input") - self.selector.attributes["name"] = self.selector.parent_element.id + parent = self.selector.parent_element + if parent is None: + self.selector.attributes["name"] = self.selector.id + else: + self.selector.attributes["name"] = parent.id self.selector.attributes["type"] = "radio" self.selector.attributes["aria-label"] = title self.selector.classes(self._classes) diff --git a/src/uiwiz/elements/textarea.py b/src/uiwiz/elements/textarea.py index eeb68dc..58e0ab1 100644 --- a/src/uiwiz/elements/textarea.py +++ b/src/uiwiz/elements/textarea.py @@ -14,7 +14,7 @@ def __init__( value: str | None = None, placeholder: str | None = None, ) -> None: - """TextArea + """TextArea. This element is used for text area inputs @@ -41,6 +41,5 @@ def placeholder(self) -> str | None: return self.attributes["placeholder"] @placeholder.setter - def placeholder(self, value: str): + def placeholder(self, value: str | None) -> None: self.attributes["placeholder"] = value - return self diff --git a/src/uiwiz/elements/theme_selector.py b/src/uiwiz/elements/theme_selector.py index 7b77b5c..b257d1e 100644 --- a/src/uiwiz/elements/theme_selector.py +++ b/src/uiwiz/elements/theme_selector.py @@ -1,9 +1,9 @@ import typing -from typing import Literal, Optional +from typing import Literal from uiwiz import ui -from uiwiz.middleware.asgi_request_middleware import get_request from uiwiz.element import Element +from uiwiz.middleware.asgi_request_middleware import get_request THEMES = Literal[ "light", @@ -42,8 +42,9 @@ class ThemeSelector(Element): - def __init__(self, themes: Optional[THEMES] = None) -> None: - """Theme Selector + def __init__(self, themes: THEMES | None = None) -> None: + """Theme Selector. + A dropdown to select a theme for the application. The selected theme will be stored in a cookie named "data-theme". @@ -53,7 +54,7 @@ def __init__(self, themes: Optional[THEMES] = None) -> None: from uiwiz import ui ui.themeSelector(["light", "dark", "nord", "cupcake", "pastel", "bumblebee"]) - + """ super().__init__() self.render_html = False @@ -69,16 +70,15 @@ def __init__(self, themes: Optional[THEMES] = None) -> None: self.theme_selector.classes("min-w-32") self.setup_listener() - def setup_listener(self): + def setup_listener(self) -> None: self.script = f""" function selectTheme(value) {{ - console.log(value); - element = document.getElementById("html"); - element.setAttribute("data-theme", value); - document.cookie = `data-theme=${{value}}; Path=/`; + element = document.getElementById(\"html\"); + element.setAttribute(\"data-theme\", value); + document.cookie = `data-theme=${{value}}; Path=/; SameSite=Lax`; }} -document.getElementById("{self.theme_selector.id}").addEventListener('change', function() {{ +document.getElementById(\"{self.theme_selector.id}\").addEventListener('change', function() {{ selectTheme(this.value); }}); """ diff --git a/src/uiwiz/elements/toast.py b/src/uiwiz/elements/toast.py index 5db8701..fd6e3e5 100644 --- a/src/uiwiz/elements/toast.py +++ b/src/uiwiz/elements/toast.py @@ -11,7 +11,7 @@ class Toast(Element): root_class: str = "alert w-full z-50 outline " def __init__(self, message: str = "", svg: _type = None) -> None: - """Toast + """Toast. Display a toast message on the client side. Can be used anywhere in a @page_router.ui("") or @app.ui("") @@ -50,15 +50,15 @@ def auto_close(self) -> bool: def auto_close(self, auto_close: bool) -> None: self._auto_close = auto_close - def set_auto_close(self, auto_close: bool) -> Toast: - """Set auto close + def set_auto_close(self, *, auto_close: bool) -> Toast: + """Set auto close. :param auto_close: True or False. Auto close False will keep the toast open until the user closes it """ self.auto_close = auto_close return self - def before_render(self): + def before_render(self) -> None: with self: with self.inner_element.classes(self.inner_class + " relative pr-16"): if self._svg: @@ -83,8 +83,8 @@ def svg(self, svg: _type) -> Toast: self._svg = svg return self - def classes(self, input: str = "") -> Toast: - self.inner_class = self.inner_class + input + def classes(self, _input: str = "") -> Toast: + self.inner_class = self.inner_class + _input return self def info(self) -> Toast: diff --git a/src/uiwiz/elements/toggle.py b/src/uiwiz/elements/toggle.py index 19448e7..2458496 100644 --- a/src/uiwiz/elements/toggle.py +++ b/src/uiwiz/elements/toggle.py @@ -5,8 +5,8 @@ class Toggle(OnEvent): root_class: str = "toggle" root_size: str = "toggle-{size}" - def __init__(self, name: str, checked: bool = False) -> None: - """Toggle + def __init__(self, name: str, *, checked: bool = False) -> None: + """Toggle. This element is used for toggle inputs diff --git a/src/uiwiz/elements/upload.py b/src/uiwiz/elements/upload.py index 1492a51..449e32a 100644 --- a/src/uiwiz/elements/upload.py +++ b/src/uiwiz/elements/upload.py @@ -11,7 +11,7 @@ def __init__( self, name: str, ) -> "Upload": - """Upload + """Upload. This element is used for file uploads @@ -40,7 +40,8 @@ def on_upload( trigger: ON_EVENTS = "change", swap: SWAP_EVENTS = None, ) -> "Upload": - """ + """Upload. + :param on_upload: The function to call when the upload event is triggered or the endpoint to call :param target: The target to swap the response to :param trigger: The event to trigger the function diff --git a/src/uiwiz/event.py b/src/uiwiz/event.py index 768537d..429e371 100644 --- a/src/uiwiz/event.py +++ b/src/uiwiz/event.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Callable -from typing import TYPE_CHECKING, Literal, Optional, TypedDict, Union +from typing import TYPE_CHECKING, Literal, TypedDict, TypeVar, Union if TYPE_CHECKING: from uiwiz.element import Element @@ -15,7 +15,7 @@ "keydown[altKey&&key=='Enter']", ] -ON_EVENTS = Union[ +ON_EVENTS = TypeVar( Literal[ "input", "change", @@ -31,13 +31,13 @@ "copy", "cut", "paste", - ], - TRIGGER_COMBINATIONS, -] + ] + | TRIGGER_COMBINATIONS, +) # HTMX Swap Events # https://htmx.org/attributes/hx-swap/ -SWAP_EVENTS = Optional[ +SWAP_EVENTS = TypeVar( Literal[ "innerHTML", "outerHTML", @@ -49,10 +49,11 @@ "delete", "none", ] -] + | None, +) -TARGET_TYPE = Optional[Union[Callable[[], str], str, "Element", None]] -FUNC_TYPE = Union[Callable, str] +TARGET_TYPE = Union[None, Callable[[], str], str, "Element"] +FUNC_TYPE = Callable | str class Event(TypedDict): diff --git a/src/uiwiz/frame.py b/src/uiwiz/frame.py index c97d44d..3559ef8 100644 --- a/src/uiwiz/frame.py +++ b/src/uiwiz/frame.py @@ -23,7 +23,7 @@ def get_task_id() -> int: class Frame: - stacks: dict[int, Frame] = {} + stacks: dict[int, Frame] = {} # noqa: RUF012 very intentional def __init__(self) -> None: self.root: list[Element] = [] @@ -42,7 +42,7 @@ def get_id(self) -> str: if swap is None: return f"a-{self.id_count}" - target_id = self.last_id if self.last_id else headers.get("hx-target") + target_id = self.last_id or headers.get("hx-target") if swap.lower() in ["outerhtml", "this"] and self.last_id != target_id: self.last_id = target_id else: @@ -77,7 +77,7 @@ def get_stack(cls) -> Frame: @classmethod def del_stack(cls) -> None: - del cls.stacks[get_task_id()] + cls.stacks.pop(get_task_id(), None) @classmethod def set_meta_description_content(cls, content: str) -> None: diff --git a/src/uiwiz/middleware/StripHiddenFormField.py b/src/uiwiz/middleware/StripHiddenFormField.py index f51a8aa..82f6ca8 100644 --- a/src/uiwiz/middleware/StripHiddenFormField.py +++ b/src/uiwiz/middleware/StripHiddenFormField.py @@ -1,7 +1,10 @@ +# ruff :noqa +import json + from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.types import ASGIApp -import json + class StripHiddenFormFieldMiddleware(BaseHTTPMiddleware): def __init__(self, app: ASGIApp, field_name: str = "csrf_token"): @@ -9,14 +12,17 @@ def __init__(self, app: ASGIApp, field_name: str = "csrf_token"): self.field_name = field_name async def dispatch(self, request: Request, call_next): - if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").startswith("application/json"): + if request.method in ("POST", "PUT", "PATCH") and request.headers.get("content-type", "").startswith( + "application/json", + ): body: dict = await request.json() - body.pop(self.field_name, None) + body.pop(self.field_name) new_body = json.dumps(body).encode() async def receive(): return {"type": "http.request", "body": new_body, "more_body": False} + request._receive = receive response = await call_next(request) - return response \ No newline at end of file + return response diff --git a/src/uiwiz/middleware/asgi_request_middleware.py b/src/uiwiz/middleware/asgi_request_middleware.py index aa14586..7750f23 100644 --- a/src/uiwiz/middleware/asgi_request_middleware.py +++ b/src/uiwiz/middleware/asgi_request_middleware.py @@ -1,23 +1,25 @@ from contextvars import ContextVar -from typing import Any from starlette.requests import Request from starlette.types import ASGIApp, Receive, Scope, Send REQUEST_CTX_KEY = "_request" -_request_ctx_var: ContextVar[dict[str, Request]] = ContextVar(REQUEST_CTX_KEY, default=None) +_request_ctx_var: ContextVar[Request | None] = ContextVar(REQUEST_CTX_KEY, default=None) def get_request() -> Request: - return _request_ctx_var.get() + request = _request_ctx_var.get() + if request is None: + raise RuntimeError("Request context is not available") + return request class AsgiRequestMiddleware: def __init__(self, app: ASGIApp) -> None: self.app = app - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> Any: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] == "http": request = Request(scope) _request_ctx_var.set(request) diff --git a/src/uiwiz/middleware/static_middleware.py b/src/uiwiz/middleware/static_middleware.py index dbd5571..d1bb23e 100644 --- a/src/uiwiz/middleware/static_middleware.py +++ b/src/uiwiz/middleware/static_middleware.py @@ -2,25 +2,25 @@ from starlette.datastructures import MutableHeaders from starlette.requests import Request -from starlette.types import Receive, Scope, Send +from starlette.types import AppType, Receive, Scope, Send class AsgiTtlMiddleware: - def __init__(self, app, cache_age: int) -> None: + def __init__(self, app: AppType, cache_age: int) -> None: self.app = app self.cache_age = cache_age - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> Any: + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> Any: # noqa: ANN401, RET503 if scope["type"] != "http": return await self.app(scope, receive, send) - async def send_with_extra_headers(message): + async def send_with_extra_headers(message) -> None: # noqa: ANN001 if message["type"] != "http.response.start": await send(message) return request = Request(scope) - if "static/" in str(request.url): + if request.url.path.startswith("/_static/"): headers = MutableHeaders(scope=message) headers["Cache-Control"] = f"max-age={self.cache_age}" diff --git a/src/uiwiz/models/display.py b/src/uiwiz/models/display.py index 54dfeb8..b81ece9 100644 --- a/src/uiwiz/models/display.py +++ b/src/uiwiz/models/display.py @@ -1,3 +1,3 @@ -def display_name(input: str) -> str: - value = str(input) +def display_name(_input: str) -> str: + value = str(_input) return value.replace("_", " ") diff --git a/src/uiwiz/models/model_handler.py b/src/uiwiz/models/model_handler.py index 672f329..5011500 100644 --- a/src/uiwiz/models/model_handler.py +++ b/src/uiwiz/models/model_handler.py @@ -3,15 +3,13 @@ import inspect from dataclasses import dataclass from datetime import date, datetime -from typing import Annotated, Literal, get_args, get_origin, get_type_hints +from typing import TYPE_CHECKING, Annotated, Literal, get_args, get_origin, get_type_hints from pydantic import BaseModel from pydantic_core import PydanticUndefinedType from uiwiz.element import Element -from uiwiz.element_types import ELEMENT_SIZE from uiwiz.elements.button import Button -from uiwiz.elements.checkbox import Checkbox from uiwiz.elements.datepicker import Datepicker from uiwiz.elements.divider import Divider from uiwiz.elements.dropdown import Dropdown @@ -20,14 +18,27 @@ from uiwiz.elements.input import Input from uiwiz.elements.label import Label from uiwiz.elements.radio import Radio -from uiwiz.elements.textarea import TextArea from uiwiz.elements.toggle import Toggle from uiwiz.models.display import display_name +if TYPE_CHECKING: + from uiwiz.element_types import ELEMENT_SIZE + from uiwiz.elements.checkbox import Checkbox + from uiwiz.elements.textarea import TextArea + @dataclass class UiAnno: - type: Input | HiddenInput | Toggle | Datepicker | Dropdown | TextArea | Checkbox = None + type: ( + type[Input] + | type[HiddenInput] + | type[Toggle] + | type[Datepicker] + | type[Dropdown] + | type[TextArea] + | type[Checkbox] + | None + ) = None placeholder: str | None = None classes: str | None = None @@ -53,13 +64,13 @@ class ModelForm(Form): def __init__( self, model: BaseModel, - compact: bool = True, + compact: bool = True, # noqa: FBT001, FBT002 card_classes: str = "border border-base-content rounded-lg shadow-lg w-full", label_classes: str = "flex-auto w-52", size: ELEMENT_SIZE = "md", - **kwargs, # override fields with custom ui + **kwargs, # override fields with custom ui # noqa: ANN003 ): - """ModelForm + """ModelForm. Create a form from a pydantic model. The form will be rendered with the fields from the model. The model can also be a pydantic model instance. The form will be prefilled with the instance data. @@ -102,12 +113,12 @@ async def handle_submit(data: DataInput): self.render_model(**kwargs) self.button.render_html = False - def on_submit(self, *args, **kwargs) -> ModelForm: + def on_submit(self, *args, **kwargs) -> ModelForm: # noqa: ANN002, ANN003 self.button.render_html = True super().on_submit(*args, **kwargs) return self - def render_model(self, **kwargs) -> Form: + def render_model(self, **kwargs) -> Form: # noqa: ANN003 if not issubclass(self.model, BaseModel): raise ValueError("type must be a pydantic model") @@ -117,7 +128,7 @@ def render_model(self, **kwargs) -> Form: self.render_model_attributes(key, field_type, **kwargs) self.button = Button("Save") - def render_model_attributes(self, key, field_type, **kwargs) -> ModelForm: + def render_model_attributes(self, key: str, field_type, **kwargs) -> ModelForm: # noqa: ANN001, ANN003 args = get_args(field_type) annotated = Annotated == get_origin(field_type) if key in kwargs: @@ -126,7 +137,7 @@ def render_model_attributes(self, key, field_type, **kwargs) -> ModelForm: self.render_type_hint_without_args(args, annotated, field_type, key) self.render_with_args_annotated(args, annotated, field_type, key) - def render_key_override(self, args: tuple, key: str, **kwargs) -> None: + def render_key_override(self, args: tuple, key: str, **kwargs) -> None: # noqa: ANN003 if key in kwargs: model_args: dict = kwargs[key] model = model_args.pop("ui") @@ -144,19 +155,19 @@ def render_key_override(self, args: tuple, key: str, **kwargs) -> None: else: raise ValueError("key not found in kwargs. Unable to render") - def render_type_hint_without_args(self, args: tuple, annotated: bool, field_type, key) -> Element: + def render_type_hint_without_args(self, args: tuple, annotated: bool, field_type, key) -> Element: # noqa: ANN001, FBT001 if len(args) == 0: if annotated: self.render_element(switch.get(field_type), key, placeholder=key) + else: + self.render_element(switch.get(field_type), key, placeholder=key) - self.render_element(switch.get(field_type), key, placeholder=key) - - def render_with_args_annotated(self, args: tuple, annotated: bool, field_type: tuple, key: str) -> Element: + def render_with_args_annotated(self, args: tuple, annotated: bool, field_type: tuple, key: str) -> Element: # noqa: FBT001 if len(args) > 0: if annotated: field_type, ele = self.get_type_and_uianno(args) if ele: - placeholder = ele.placeholder if ele.placeholder else key + placeholder = ele.placeholder or key if field_args := get_args(field_type): self.render_element_radio(ele, key, field_args) else: @@ -180,8 +191,7 @@ def render_element( ele: Element, key: str, classes: str | None = None, - field_arg: str | None = None, - **kwargs, + **kwargs, # noqa: ANN003 ) -> None: kwargs = {"name": key, **kwargs} placeholder = "placeholder" @@ -189,7 +199,7 @@ def render_element( compact = self.compact with Element().classes("flex flex-nowrap w-full"): ele_args = [item[0] for item in inspect.signature(ele.__init__).parameters.items()] - compact = self.extend_kwargs(kwargs, ele_args, key, field_arg, ele) + compact = self.extend_kwargs(kwargs, ele_args, key, ele) label: Label | None = None if ele is not HiddenInput: @@ -212,7 +222,7 @@ def render_element( if label: label.set_for(el) - def extend_kwargs(self, kwargs: dict, ele_args: list[str], key: str, field_arg: str, ele: Element) -> bool: + def extend_kwargs(self, kwargs: dict, ele_args: list[str], key: str, ele: Element) -> bool: compact = self.compact if self.instance and "value" in ele_args: kwargs["value"] = getattr(self.instance, key) diff --git a/src/uiwiz/page_definition.py b/src/uiwiz/page_definition.py index 555ac3d..4766d57 100644 --- a/src/uiwiz/page_definition.py +++ b/src/uiwiz/page_definition.py @@ -2,9 +2,8 @@ import inspect import json -from collections.abc import Callable from html import escape -from typing import Annotated +from typing import TYPE_CHECKING, Annotated from fastapi import Depends, Request, Response @@ -12,6 +11,9 @@ from uiwiz.frame import Frame from uiwiz.version import __version__ +if TYPE_CHECKING: + from collections.abc import Callable + class PageDefinition: html_ele: Element @@ -22,7 +24,7 @@ class PageDefinition: lang: str def __init__(self) -> None: - """The PageDefinition class is designed to be subclassed, allowing. + """PageDefinition class is designed to be subclassed, allowing. developers to override the `header`, `body`, and `content` methods to customize the HTML structure and content as needed. The `footer` @@ -45,7 +47,7 @@ def header(self, header: Element) -> None: def body(self, body: Element) -> None: Element("div", content="Custom Body").classes("custom-body") - def content(self, content: Element) -> Optional[Element]: + def content(self, content: Element) -> Element | None: return Element("h1", content="Custom Content").classes("custom-content") def footer(self, content: Element) -> None: @@ -78,6 +80,7 @@ async def render( user_method: Callable | None, request: Request, title: str | None = None, + favicon: str | None = None, ) -> Response | None: frame = Frame.get_stack() @@ -86,7 +89,7 @@ async def render( theme = escape(cookie_theme) class RenderDoctype: - def render(self): + def render(self) -> str: return "" frame.root.append(RenderDoctype()) # funky way to add doctype @@ -118,6 +121,8 @@ def render(self): ) Element("script", src=f"/_static/{__version__}/libs/tailwind.js") Element("link", href=f"/_static/{__version__}/app.css", rel="stylesheet", type="text/css") + if favicon: + Element("link", href=favicon, rel="icon") self.header(header) with Element("body") as body: self.body_ele = body diff --git a/src/uiwiz/page_route.py b/src/uiwiz/page_route.py index 0066caa..7946be0 100644 --- a/src/uiwiz/page_route.py +++ b/src/uiwiz/page_route.py @@ -122,12 +122,12 @@ def __init__( type[PageDefinition] | None, Doc(""" The page definition class to use for this router. - + This enables the use of custom page definitions for rendering the HTML pages. The default is `PageDefinition`, which provides a basic HTML structure. You can create your own class that inherits from `PageDefinition` and override the `header`, `body`, and `content` methods to customize the - HTML structure and content as needed. Setting the `page_definition_class` in the + HTML structure and content as needed. Setting the `page_definition_class` in the UiwizApp will set the default for all routers. Example: ```python @@ -135,17 +135,17 @@ class MyPageDefinition(PageDefinition): def header(self, header: Element) -> None: # Custom header content Element("link", href="/custom.css", rel="stylesheet") - + def body(self, body: Element) -> None: # Custom body content Element("div", content="Custom Body").classes("custom-body") - + def content(self, content: Element) -> None: # Custom content Element("h1", content="Custom Content").classes("custom-content") """), ] = None, - **kwargs, + **kwargs, # noqa: ANN003 ): super().__init__( prefix=prefix, @@ -164,65 +164,72 @@ def content(self, content: Element) -> None: def page( self, path: str, - *args, title: str | None = None, page_definition_class: type[PageDefinition] | None = None, favicon: str | None = None, router: APIRouter | None = None, - **kwargs, + **kwargs, # noqa: ANN003 ) -> Callable: - def decorator(func: Callable, *args, **kwargs) -> Callable: + def decorator(func: Callable) -> Callable: parameters_of_decorated_func = list(inspect.signature(func).parameters.keys()) cap_title = title cap_page_definition_class = page_definition_class + cap_favicon = favicon @functools.wraps(func) - async def decorated(*dec_args, **dec_kwargs: DecKwargs) -> Response: - Frame.get_stack().del_stack() - # Create frame before function is called - - request = None - response = None - for value in dec_kwargs.values(): - if isinstance(value, Request): - request = value - if isinstance(value, Response): - response = value - - if self.page_definition_class is None: - self.page_definition_class = request.app.page_definition_class - - page_class = cap_page_definition_class or self.page_definition_class - - page = page_class() - - dec_kwargs = { - k: v if not isinstance(v, PageDefinition) else page - for k, v in dec_kwargs.items() - if k in parameters_of_decorated_func - } - user_method = partial(func, *dec_args, **dec_kwargs) - result = await page.render(user_method=user_method, request=request, title=cap_title) - if isinstance(result, Response): - return self.return_function_response(result) - standard_headers = {"cache-control": "no-store", "x-uiwiz-content": "page"} - - if response: - standard_headers.update(response.headers) - - self.add_ext(page, include_js=True, include_css=True) - - return HTMLResponse( - content=Frame.get_stack().render(), - status_code=200, - media_type="text/html", - ) + async def decorated(*dec_args, **dec_kwargs: DecKwargs) -> Response: # noqa: ANN002 + Frame.del_stack() + Frame.get_stack() # Create frame before function is called + try: + request: Request | None = None + response: Response | None = None + for value in dec_kwargs.values(): + if isinstance(value, Request): + request = value + if isinstance(value, Response): + response = value + + if self.page_definition_class is None: + self.page_definition_class = request.app.page_definition_class + + page_class = cap_page_definition_class or self.page_definition_class + + page = page_class() + + dec_kwargs = { + k: v if not isinstance(v, PageDefinition) else page + for k, v in dec_kwargs.items() + if k in parameters_of_decorated_func + } + user_method = partial(func, *dec_args, **dec_kwargs) + result = await page.render( + user_method=user_method, + request=request, + title=cap_title, + favicon=cap_favicon, + ) + if isinstance(result, Response): + return result + standard_headers = {"cache-control": "no-store", "x-uiwiz-content": "page"} + + if response: + standard_headers.update(response.headers) + + self.add_ext(page, include_js=True, include_css=True) + + return HTMLResponse( + content=Frame.get_stack().render(), + status_code=200, + media_type="text/html", + ) + finally: + Frame.del_stack() self.__ensure_request_response_signature__(decorated) _router = router or self - return _router.get(path, *args, include_in_schema=False, **kwargs)(decorated) + return _router.get(path, include_in_schema=False, **kwargs)(decorated) return decorator @@ -230,10 +237,10 @@ def ui( self, path: str, router: APIRouter | None = None, - *args, + *, include_js: bool = False, include_css: bool = False, - **kwargs, + **kwargs, # noqa: ANN003 ) -> Callable: def decorator(func: Callable) -> Callable: # Capture values at decoration time @@ -242,28 +249,30 @@ def decorator(func: Callable) -> Callable: parameters_of_decorated_func = list(inspect.signature(func).parameters.keys()) @functools.wraps(func) - async def decorated(*dec_args, **dec_kwargs) -> Response: - Frame.get_stack().del_stack() + async def decorated(*dec_args, **dec_kwargs) -> Response: # noqa: ANN002, ANN003 + Frame.del_stack() Frame.get_stack() # Create frame before function is called - response = dec_kwargs["response"] + try: + response = dec_kwargs["response"] - # Ensure the signature matches the parameters of the function - dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} - result = func(*dec_args, **dec_kwargs) - if inspect.isawaitable(result): - result = await result + # Ensure the signature matches the parameters of the function + dec_kwargs = {k: v for k, v in dec_kwargs.items() if k in parameters_of_decorated_func} + result = func(*dec_args, **dec_kwargs) + if inspect.isawaitable(result): + result = await result - if isinstance(result, Response): - Frame.get_stack().del_stack() - return result + if isinstance(result, Response): + return result - standard_headers = {"cache-control": "no-store", "x-uiwiz-content": "partial-ui"} - standard_headers.update(response.headers) + standard_headers = {"cache-control": "no-store", "x-uiwiz-content": "partial-ui"} + standard_headers.update(response.headers) - self.add_ext(page=None, include_js=cap_include_js, include_css=cap_include_css) + self.add_ext(page=None, include_js=cap_include_js, include_css=cap_include_css) - content = Frame.get_stack().render() - return HTMLResponse(content=content, headers=standard_headers) + content = Frame.get_stack().render() + return HTMLResponse(content=content, headers=standard_headers) + finally: + Frame.del_stack() self.__ensure_request_response_signature__(decorated) _router = router or self @@ -285,7 +294,7 @@ def __ensure_request_response_signature__(self, func: Callable) -> None: def add_ext( self, page: PageDefinition | None = None, - *args, + *, include_js: bool = False, include_css: bool = False, ) -> None: @@ -299,7 +308,8 @@ def add_ext( with page.header_ele: Element("link", href=lib, rel="stylesheet", type="text/css") - # Hack to make aggrid work with daisyui + # Make aggrid work with daisyui. We have to add the css in the header and the body for some reason, + # otherwise the styles are not applied to the grid with page.html_ele: Element("link", href=lib, rel="stylesheet", type="text/css") elif lib.endswith("js") and include_js: diff --git a/src/uiwiz/server/__init__.py b/src/uiwiz/server/__init__.py index 684caa3..1ed5770 100644 --- a/src/uiwiz/server/__init__.py +++ b/src/uiwiz/server/__init__.py @@ -1,7 +1,7 @@ from uiwiz.server._server import Config, Server -def run(app: str, host: str = "localhost", port: int = 8080): +def run(app: str, host: str = "localhost", port: int = 8080) -> None: config = Config(host=host, port=port, app=app, root_path="") server = Server(config) diff --git a/src/uiwiz/server/_server.py b/src/uiwiz/server/_server.py index e32be99..d05de3c 100644 --- a/src/uiwiz/server/_server.py +++ b/src/uiwiz/server/_server.py @@ -10,6 +10,7 @@ from contextlib import suppress from dataclasses import dataclass from time import perf_counter +from types import CoroutineType from typing import Any import httptools @@ -26,12 +27,12 @@ fmt="%(asctime)s - %(levelname)s - %(name)s - %(lineno)d - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) -logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) -sc = logging.StreamHandler() -sc.setFormatter(formatter) -logger.addHandler(sc) +if not logger.handlers: + sc = logging.StreamHandler() + sc.setFormatter(formatter) + logger.addHandler(sc) HEADER_RE = re.compile(b'[\x00-\x1f\x7f()<>@,;:[]={} \t\\"]') HEADER_VALUE_RE = re.compile(b"[\x00-\x08\x0a-\x1f\x7f]") @@ -108,14 +109,14 @@ async def execute(self) -> None: async def send(self, message: dict) -> None: task = { - "lifespan.startup.complete": lambda: self.startup_done_event.set(), - "lifespan.startup.failed": lambda: self.startup_done_event.set(), - "lifespan.shutdown.complete": lambda: self.shutdown_done_event.set(), - "lifespan.shutdown.failed": lambda: self.shutdown_done_event.set(), + "lifespan.startup.complete": self.startup_done_event.set, + "lifespan.startup.failed": self.startup_done_event.set, + "lifespan.shutdown.complete": self.shutdown_done_event.set, + "lifespan.shutdown.failed": self.shutdown_done_event.set, } task.get(message["type"], lambda: 1)() - async def receive(self): + async def receive(self) -> CoroutineType[Any, Any, Any]: with suppress(asyncio.CancelledError): return await self.receive_queue.get() @@ -247,7 +248,7 @@ def on_headers_complete(self) -> None: self.flow.pause_reading() self.pipeline.appendleft((self.cycle, self.config.app_instance)) - def _shutdown(self, *args) -> None: + def _shutdown(self, *args) -> None: # noqa: ANN002, ARG002 task = self.loop.create_task(self.lifespan.shutdown()) task.add_done_callback(self.tasks.discard) self.tasks.add(task) @@ -272,7 +273,7 @@ def on_message_begin(self) -> None: } def shutdown(self) -> None: - """Called by the server to commence a graceful shutdown.""" + """Call by the server to commence a graceful shutdown.""" if self.cycle is None or self.cycle.response_complete: self.transport.close() else: @@ -332,7 +333,7 @@ def _should_upgrade_to_ws(self) -> bool: def _unsupported_upgrade_warning(self) -> None: logger.warning("Unsupported upgrade request.") if not self._should_upgrade_to_ws(): - msg = "No supported WebSocket library detected. Please use \"pip install 'uvicorn[standard]'\", or install 'websockets' or 'wsproto' manually." # noqa: E501 + msg = "No supported WebSocket library detected. Please use \"pip install 'uvicorn[standard]'\", or install 'websockets' or 'wsproto' manually." logger.warning(msg) def _should_upgrade(self) -> bool: @@ -346,7 +347,7 @@ def get_local_addr(self) -> tuple[str, int] | None: return (str(info[0]), int(info[1])) if isinstance(info, tuple) else None info = self.transport.get_extra_info("sockname") - if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: + if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: # noqa: PLR2004 return (str(info[0]), int(info[1])) return None @@ -360,7 +361,7 @@ def get_remote_addr(self) -> tuple[str, int] | None: return None info = self.transport.get_extra_info("peername") - if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: + if info is not None and isinstance(info, (list, tuple)) and len(info) == 2: # noqa: PLR2004 return (str(info[0]), int(info[1])) return None @@ -380,23 +381,21 @@ def send_400_response(self, msg: str) -> None: self.transport.close() def pause_writing(self) -> None: - """Called by the transport when the write buffer exceeds the high water mark.""" + """Call by the transport when the write buffer exceeds the high water mark.""" self.flow.pause_writing() # pragma: full coverage def resume_writing(self) -> None: - """Called by the transport when the write buffer drops below the low water mark.""" + """Call by the transport when the write buffer drops below the low water mark.""" self.flow.resume_writing() # pragma: full coverage def timeout_keep_alive_handler(self) -> None: - """Called on a keep-alive connection if no new data is received after a short - delay. - """ + """Call keep-alive connection if no new data is received after a shortdelay.""" if not self.transport.is_closing(): self.transport.close() class RRCycle(RequestResponseCycle): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs): # noqa: ANN002, ANN003 super().__init__(*args, **kwargs) # ASGI exception wrapper diff --git a/src/uiwiz/shared.py b/src/uiwiz/shared.py index 94c2e7a..0e11db2 100644 --- a/src/uiwiz/shared.py +++ b/src/uiwiz/shared.py @@ -15,7 +15,7 @@ @cache def _hash_function_extended(func: Callable) -> str: - """This was an interesting problem. I needed to hash the function. + """Interesting problem. I needed to hash the function. to be able to store the route in a dictionary. Nothing special but the reason for using the source code has to do with diff --git a/src/uiwiz/svg/svg_handler.py b/src/uiwiz/svg/svg_handler.py index 1ee7fd9..41f3256 100644 --- a/src/uiwiz/svg/svg_handler.py +++ b/src/uiwiz/svg/svg_handler.py @@ -1,7 +1,6 @@ +from pathlib import Path from typing import Literal -from anyio import Path - _type = Literal["info", "error", "success", "warning", "menu", "copy"] @@ -9,5 +8,5 @@ def get_svg(svg: _type) -> str: if not svg: raise ValueError("Value cannot be None or an empty string") path = Path(__file__).parent / (svg + ".svg") - with open(path) as f: + with path.open() as f: return f.read() diff --git a/tests/elements/test_button.py b/tests/elements/test_button.py index 1450c47..5b59391 100644 --- a/tests/elements/test_button.py +++ b/tests/elements/test_button.py @@ -15,7 +15,6 @@ def func(): btn.on_click(func) output = str(btn) - print(output) assert ( '' == output diff --git a/tests/elements/test_checkbox.py b/tests/elements/test_checkbox.py index 9908624..33fdf4b 100644 --- a/tests/elements/test_checkbox.py +++ b/tests/elements/test_checkbox.py @@ -2,10 +2,10 @@ def test_checkbox(): - output = str(ui.checkbox("name", False)) - assert '' == output + output = str(ui.checkbox("name", checked=False)) + assert output == '' def test_checkbox_checked(): - output = str(ui.checkbox("name", True)) - assert '' == output + output = str(ui.checkbox("name", checked=True)) + assert output == '' diff --git a/tests/elements/test_drawer.py b/tests/elements/test_drawer.py new file mode 100644 index 0000000..d9c9ada --- /dev/null +++ b/tests/elements/test_drawer.py @@ -0,0 +1,17 @@ +from uiwiz import ui +from uiwiz.frame import Frame + + +def test_drawer_side_context_preserves_parent_stack() -> None: + with ui.drawer() as drawer: + with drawer.drawer_content(): + ui.label("content") + with drawer.drawer_side(): + with ui.element("li"): + ui.link("item", "/") + ui.label("after") + + output = Frame.get_stack().render() + assert "after" in output + assert output.count("drawer-content") == 1 + assert output.count("drawer-side") == 1 diff --git a/tests/elements/test_extension_components_smoke.py b/tests/elements/test_extension_components_smoke.py new file mode 100644 index 0000000..9962363 --- /dev/null +++ b/tests/elements/test_extension_components_smoke.py @@ -0,0 +1,30 @@ +from uiwiz import ui +from uiwiz.frame import Frame + + +def test_markdown_registers_css_extensions() -> None: + ui.markdown("# hello") + extensions = Frame.get_stack().extensions + assert any("Markdown/markdown.css" in item for item in extensions) + assert any("Markdown/codehighlight.css" in item for item in extensions) + + +def test_ace_registers_js_extensions() -> None: + ui.ace(name="editor", content="print('ok')") + extensions = Frame.get_stack().extensions + assert any("Ace/ace.min.js" in item for item in extensions) + assert any("Ace/ace.js" in item for item in extensions) + + +def test_aggrid_registers_extensions() -> None: + ui.aggrid(None) + extensions = Frame.get_stack().extensions + assert any("Aggrid/aggrid-community.min.js" in item for item in extensions) + assert any("Aggrid/aggridtheme.css" in item for item in extensions) + + +def test_echart_registers_extensions() -> None: + ui.echart({"xAxis": {}, "yAxis": {}, "series": []}) + extensions = Frame.get_stack().extensions + assert any("EChart/echart.min.js" in item for item in extensions) + assert any("EChart/echart.js" in item for item in extensions) diff --git a/tests/elements/test_model_form.py b/tests/elements/test_model_form.py index af43e69..22319f9 100644 --- a/tests/elements/test_model_form.py +++ b/tests/elements/test_model_form.py @@ -1,8 +1,11 @@ +from typing import Annotated + from pydantic import BaseModel from uiwiz import ui from uiwiz.app import UiwizApp from uiwiz.frame import Frame +from uiwiz.models.model_handler import UiAnno class DataInput(BaseModel): @@ -35,3 +38,13 @@ async def submit(): output = Frame.get_stack().render() expected = """
""" assert expected == output + + +class AnnotatedDataInput(BaseModel): + first_name: Annotated[str, UiAnno(type=ui.input)] + + +def test_model_handler_annotated_renders_single_field() -> None: + ui.modelForm(AnnotatedDataInput) + output = Frame.get_stack().render() + assert output.count('name="first_name"') == 1 diff --git a/tests/elements/test_property_setters.py b/tests/elements/test_property_setters.py new file mode 100644 index 0000000..83e49d8 --- /dev/null +++ b/tests/elements/test_property_setters.py @@ -0,0 +1,28 @@ +from uiwiz import ui + + +def test_input_set_placeholder_returns_self() -> None: + element = ui.input("name") + assert element.set_placeholder("Name") is element + + +def test_textarea_placeholder_assignment_sets_attribute() -> None: + element = ui.textarea("notes") + element.placeholder = "Notes" + assert element.attributes.get("placeholder") == "Notes" + + +def test_number_min_max_assignment_sets_attributes() -> None: + element = ui.number("qty", 1, 0, 10) + element.min = 2 + element.max = 20 + assert element.attributes.get("min") == 2 + assert element.attributes.get("max") == 20 + + +def test_range_min_max_assignment_sets_attributes() -> None: + element = ui.range("progress", 10, 0, 100) + element.min = 5 + element.max = 95 + assert element.attributes.get("min") == 5 + assert element.attributes.get("max") == 95 diff --git a/tests/elements/test_tabs.py b/tests/elements/test_tabs.py new file mode 100644 index 0000000..973aff6 --- /dev/null +++ b/tests/elements/test_tabs.py @@ -0,0 +1,10 @@ +from uiwiz import ui +from uiwiz.frame import Frame + + +def test_tabs_with_no_children_does_not_crash() -> None: + with ui.tabs(): + pass + + output = Frame.get_stack().render() + assert 'role="tablist"' in output diff --git a/tests/elements/test_tabs_additional.py b/tests/elements/test_tabs_additional.py new file mode 100644 index 0000000..aa2bcac --- /dev/null +++ b/tests/elements/test_tabs_additional.py @@ -0,0 +1,13 @@ +from uiwiz import ui +from uiwiz.frame import Frame + + +def test_tabs_first_tab_is_activated_by_default() -> None: + with ui.tabs(): + with ui.tab("One"): + ui.label("one") + with ui.tab("Two"): + ui.label("two") + + output = Frame.get_stack().render() + assert output.count('checked=""') == 1 diff --git a/tests/elements/test_theme_selector.py b/tests/elements/test_theme_selector.py new file mode 100644 index 0000000..8ad682e --- /dev/null +++ b/tests/elements/test_theme_selector.py @@ -0,0 +1,14 @@ +from unittest import mock + +from uiwiz import ui + + +def test_theme_selector_script_uses_samesite_cookie_and_no_console_log() -> None: + mocked_request = mock.MagicMock() + mocked_request.cookies = {} + + with mock.patch("uiwiz.elements.theme_selector.get_request", return_value=mocked_request): + selector = ui.themeSelector() + + assert "SameSite=Lax" in selector.script + assert "console.log" not in selector.script diff --git a/tests/elements/test_theme_selector_additional.py b/tests/elements/test_theme_selector_additional.py new file mode 100644 index 0000000..0dfbb83 --- /dev/null +++ b/tests/elements/test_theme_selector_additional.py @@ -0,0 +1,14 @@ +from unittest import mock + +from uiwiz import ui + + +def test_theme_selector_uses_cookie_theme_as_placeholder() -> None: + mocked_request = mock.MagicMock() + mocked_request.cookies = {"data-theme": "forest"} + + with mock.patch("uiwiz.elements.theme_selector.get_request", return_value=mocked_request): + selector = ui.themeSelector() + + html = str(selector.theme_selector) + assert "forest" in html diff --git a/tests/elements/test_toggle.py b/tests/elements/test_toggle.py index 0f6df2c..29ac1f0 100644 --- a/tests/elements/test_toggle.py +++ b/tests/elements/test_toggle.py @@ -2,10 +2,10 @@ def test_toggle(): - output = str(ui.toggle("name", False)) - assert '' == output + output = str(ui.toggle("name", checked=False)) + assert output == '' def test_toggle_checked(): - output = str(ui.toggle("name", True)) - assert '' == output + output = str(ui.toggle("name", checked=True)) + assert output == '' diff --git a/tests/test_asgi_request_middleware.py b/tests/test_asgi_request_middleware.py new file mode 100644 index 0000000..cbfc0d9 --- /dev/null +++ b/tests/test_asgi_request_middleware.py @@ -0,0 +1,8 @@ +import pytest + +from uiwiz.middleware.asgi_request_middleware import get_request + + +def test_get_request_raises_outside_request_context() -> None: + with pytest.raises(RuntimeError, match="Request context is not available"): + get_request() diff --git a/tests/test_dict_element.py b/tests/test_dict_element.py index 5648b1f..e7e9539 100644 --- a/tests/test_dict_element.py +++ b/tests/test_dict_element.py @@ -21,3 +21,15 @@ class Test: def test_dict_exception_wrong_type(): with pytest.raises(ValueError): ui.dict(Test()) + + +def test_dict_duplicate_values_keep_separator_for_non_last_items() -> None: + output = str(ui.dict({"a": 1, "b": 1})) + assert ""a":" in output + assert ""b":" in output + assert output.count(",") >= 1 + + +def test_dict_list_duplicate_values_keep_separator_for_non_last_items() -> None: + output = str(ui.dict([1, 1])) + assert output.count(",") >= 1 diff --git a/tests/test_extract_text.py b/tests/test_extract_text.py index 0912614..33564aa 100644 --- a/tests/test_extract_text.py +++ b/tests/test_extract_text.py @@ -1,19 +1,25 @@ import pytest -from uiwiz.docs.pages.docs.extract_doc import extract_text from uiwiz import ui +from uiwiz.docs.pages.docs.extract_doc import extract_text -@pytest.mark.parametrize("docstring, expected_code_snippet", [ - (ui.ace.__init__.__doc__, 'ui.ace(name="editor")'), - (ui.upload.__init__.__doc__, 'ui.upload("file")'), - (ui.button.__init__.__doc__, 'button'), - (ui.textarea.__init__.__doc__, 'ui.textarea'), - (ui.input.__init__.__doc__, 'from uiwiz import ui\n\nui.input("username", "default_value", "Enter your username")'), - (ui.upload.on_upload.__doc__, 'ui.upload("file").on_upload(on_upload=handle_upload, swap="none")'), - #(ui.toast.__init__.__doc__, 'ui.toast("message")'), - #(ui.aggrid.__init__.__doc__, 'ui.aggrid("grid")'), -]) +@pytest.mark.parametrize( + "docstring, expected_code_snippet", + [ + (ui.ace.__init__.__doc__, 'ui.ace(name="editor")'), + (ui.upload.__init__.__doc__, 'ui.upload("file")'), + (ui.button.__init__.__doc__, "button"), + (ui.textarea.__init__.__doc__, "ui.textarea"), + ( + ui.input.__init__.__doc__, + 'from uiwiz import ui\n\nui.input("username", "default_value", "Enter your username")', + ), + (ui.upload.on_upload.__doc__, 'ui.upload("file").on_upload(on_upload=handle_upload, swap="none")'), + # (ui.toast.__init__.__doc__, 'ui.toast("message")'), + # (ui.aggrid.__init__.__doc__, 'ui.aggrid("grid")'), + ], +) def test_extract_text(docstring, expected_code_snippet): description, code_block, parameters = extract_text(docstring) @@ -23,10 +29,10 @@ def test_extract_text(docstring, expected_code_snippet): assert ":param" not in description # If the docstring has a code block, check for the expected snippet - if '.. code-block' in docstring: + if ".. code-block" in docstring: assert expected_code_snippet in code_block - if 'from uiwiz import ui' in docstring: - assert 'from uiwiz import ui' in code_block + if "from uiwiz import ui" in docstring: + assert "from uiwiz import ui" in code_block else: assert code_block == "" @@ -39,4 +45,4 @@ def test_extract_text(docstring, expected_code_snippet): "sql_options": "Options for the SQL language mode. Tables and columns to be used for autocompletion", "ace_options": "Options for the Ace Editor", } - assert parameters == expected_params \ No newline at end of file + assert parameters == expected_params diff --git a/tests/test_frame_cleanup.py b/tests/test_frame_cleanup.py new file mode 100644 index 0000000..4b1dc34 --- /dev/null +++ b/tests/test_frame_cleanup.py @@ -0,0 +1,37 @@ +from fastapi.testclient import TestClient + +from uiwiz import ui +from uiwiz.app import UiwizApp +from uiwiz.frame import Frame + + +def test_page_request_exception_cleans_frame_stack() -> None: + app = UiwizApp() + + @app.page("/boom") + def boom() -> None: + ui.label("before") + raise RuntimeError("boom") + + baseline_stack_ids = set(Frame.stacks.keys()) + client = TestClient(app, raise_server_exceptions=False) + response = client.get("/boom") + status_code = 500 + assert response.status_code == status_code + assert set(Frame.stacks.keys()) == baseline_stack_ids + + +def test_ui_request_exception_cleans_frame_stack() -> None: + app = UiwizApp() + + @app.ui("/boom-ui") + def boom_ui() -> None: + ui.label("before") + raise RuntimeError("boom-ui") + + baseline_stack_ids = set(Frame.stacks.keys()) + client = TestClient(app, raise_server_exceptions=False) + response = client.post("/boom-ui") + status_code = 500 + assert response.status_code == status_code + assert set(Frame.stacks.keys()) == baseline_stack_ids diff --git a/tests/test_login_response.py b/tests/test_login_response.py new file mode 100644 index 0000000..9a81846 --- /dev/null +++ b/tests/test_login_response.py @@ -0,0 +1,15 @@ +from uiwiz.login_response import LoginResponse + + +def test_login_response_sets_htmx_redirect_header() -> None: + response = LoginResponse(url="/dashboard") + assert response.headers["Hx-Redirect"] == "/dashboard" + + +def test_login_response_sets_secure_token_cookie() -> None: + response = LoginResponse() + response.set_token("sid", "token", 123) + cookie_header = response.headers.get("set-cookie", "") + assert "sid=token" in cookie_header + assert "HttpOnly" in cookie_header + assert "SameSite=strict" in cookie_header diff --git a/tests/test_nav.py b/tests/test_nav.py new file mode 100644 index 0000000..2bd12b9 --- /dev/null +++ b/tests/test_nav.py @@ -0,0 +1,11 @@ +from uiwiz import ui +from uiwiz.frame import Frame + + +def test_nav_renders_with_default_classes() -> None: + with ui.nav(): + ui.link("Home", "/") + + output = Frame.get_stack().render() + assert "navbar" in output + assert "w-full" in output diff --git a/tests/test_page_definition_render.py b/tests/test_page_definition_render.py new file mode 100644 index 0000000..82735e0 --- /dev/null +++ b/tests/test_page_definition_render.py @@ -0,0 +1,38 @@ +from fastapi.testclient import TestClient + +from uiwiz import ui +from uiwiz.app import UiwizApp + + +def test_page_definition_renders_expected_shell_bits() -> None: + app = UiwizApp(title="My App", theme="light") + + @app.page("/") + def home() -> None: + ui.label("hello") + + client = TestClient(app) + response = client.get("/") + body = response.text + status_code = 200 + assert response.status_code == status_code + assert "" in body + assert 'data-theme="light"' in body + assert " None: + app = UiwizApp(theme="light") + + @app.page("/") + def home() -> None: + ui.label("hello") + + client = TestClient(app) + client.cookies.set("data-theme", "forest") + response = client.get("/") + status_code = 200 + assert response.status_code == status_code + assert 'data-theme="forest"' in response.text diff --git a/tests/test_page_favicon.py b/tests/test_page_favicon.py new file mode 100644 index 0000000..c76677f --- /dev/null +++ b/tests/test_page_favicon.py @@ -0,0 +1,19 @@ +from fastapi.testclient import TestClient + +from uiwiz import ui +from uiwiz.app import UiwizApp + + +def test_page_renders_favicon_link_when_provided() -> None: + app = UiwizApp() + + @app.page("/", favicon="/favicon.ico") + def home() -> None: + ui.label("hello") + + client = TestClient(app) + response = client.get("/") + status_code = 200 + assert response.status_code == status_code + assert 'href="/favicon.ico"' in response.text + assert 'rel="icon"' in response.text diff --git a/tests/test_page_router.py b/tests/test_page_router.py index 015af5d..1ee7dde 100644 --- a/tests/test_page_router.py +++ b/tests/test_page_router.py @@ -146,7 +146,7 @@ def test_router_ui_extensions(): pr = PageRouter() route = "/path" - @pr.ui(route) + @pr.ui(route, include_js=True, include_css=True) def func(): ui.markdown(""" # Test @@ -160,5 +160,5 @@ def func(): response = client.post(route) body = response.read().decode("utf-8") assert response.status_code == 200 - assert "Markdown/markdown.css" not in body - assert "Markdown/codehighlight.css" not in body + assert "Markdown/markdown.css" in body + assert "Markdown/codehighlight.css" in body diff --git a/tests/test_static_middleware.py b/tests/test_static_middleware.py new file mode 100644 index 0000000..311eca3 --- /dev/null +++ b/tests/test_static_middleware.py @@ -0,0 +1,26 @@ +from fastapi.testclient import TestClient + +from uiwiz.app import UiwizApp +from uiwiz.version import __version__ + + +def test_static_middleware_sets_cache_control_for_static_paths() -> None: + app = UiwizApp(cache_age=120) + client = TestClient(app) + + response = client.get(f"/_static/{__version__}/app.css") + assert response.status_code == 200 + assert response.headers.get("Cache-Control") == "max-age=120" + + +def test_static_middleware_does_not_match_non_static_path_with_query() -> None: + app = UiwizApp(cache_age=120) + + @app.get("/probe") + def probe() -> dict[str, str]: + return {"ok": "true"} + + client = TestClient(app) + response = client.get("/probe?src=static/fake.css") + assert response.status_code == 200 + assert response.headers.get("Cache-Control") is None diff --git a/tests/test_toast.py b/tests/test_toast.py new file mode 100644 index 0000000..d604636 --- /dev/null +++ b/tests/test_toast.py @@ -0,0 +1,15 @@ +from uiwiz import ui + + +def test_toast_default_render_contains_toast_data() -> None: + output = str(ui.toast("Saved")) + assert 'id="toast"' in output + assert "hx-toast-data" in output + assert "Saved" in output + + +def test_toast_error_disables_auto_close_and_has_close_button() -> None: + output = str(ui.toast("Oops").error()) + assert "autoClose" in output + assert "false" in output + assert "btn-circle" in output diff --git a/tests/test_validation_error_handler.py b/tests/test_validation_error_handler.py new file mode 100644 index 0000000..a88ffcf --- /dev/null +++ b/tests/test_validation_error_handler.py @@ -0,0 +1,34 @@ +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from uiwiz.app import UiwizApp + + +class UserInput(BaseModel): + name: str + age: int + + +def _create_app() -> UiwizApp: + app = UiwizApp() + + @app.ui("/submit") + async def submit(data: UserInput): + return None + + return app + + +def test_validation_error_handles_missing_body() -> None: + client = TestClient(_create_app()) + response = client.post("/submit") + assert response.status_code == 400 + assert response.headers["x-uiwiz-validation-error"] == "true" + + +def test_validation_error_handles_field_specific_errors() -> None: + client = TestClient(_create_app()) + response = client.post("/submit", json={"name": "Jane", "age": "not-an-int"}) + assert response.status_code == 400 + assert response.headers["x-uiwiz-validation-error"] == "true" + assert "age" in response.text