From 53c783e36793757a5c042ee31a230fa6f402ba8e Mon Sep 17 00:00:00 2001 From: Sean N Date: Fri, 17 Apr 2026 16:32:36 +0200 Subject: [PATCH] Addded correct docstrings to all surface api's --- pyproject.toml | 2 +- src/nitro_ui/core/component.py | 13 +- src/nitro_ui/core/element.py | 364 ++++++++++++++++++++++-------- src/nitro_ui/core/parser.py | 36 +-- src/nitro_ui/core/partial.py | 21 +- src/nitro_ui/core/slot.py | 7 +- src/nitro_ui/forms/field.py | 257 ++++++++++++++------- src/nitro_ui/styles/style.py | 128 +++++------ src/nitro_ui/styles/stylesheet.py | 180 +++++++-------- src/nitro_ui/styles/theme.py | 114 +++++----- src/nitro_ui/tags/form.py | 46 +++- src/nitro_ui/tags/html.py | 11 + src/nitro_ui/tags/lists.py | 36 ++- src/nitro_ui/tags/table.py | 38 ++++ src/nitro_ui/tags/tag_factory.py | 56 ++++- 15 files changed, 888 insertions(+), 421 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb07716..6580da8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nitro-ui" -version = "1.0.9" +version = "1.0.10" description = "Build HTML with Python, not strings. Zero-dependency library with type-safe elements, method chaining, and JSON serialization." readme = "README.md" requires-python = ">=3.8" diff --git a/src/nitro_ui/core/component.py b/src/nitro_ui/core/component.py index 76b26e9..688a88b 100644 --- a/src/nitro_ui/core/component.py +++ b/src/nitro_ui/core/component.py @@ -233,10 +233,17 @@ def _process_template( return result def template(self) -> List[Any]: - """Override to define the component structure. + """Override to define this component's structure. + + The method's parameters become declared props: when a caller + instantiates the component, non-element positional arguments and + matching keyword arguments are routed here. ``Slot()`` markers in + the returned tree are replaced with child content; named slots + are filled from kwargs of the same name. Returns: - List of HTMLElement instances and/or Slot markers. - Use Slot() for default slot, Slot("name") for named slots. + A list of ``HTMLElement`` instances and/or ``Slot`` markers + that describe the rendered output. Use ``Slot()`` for the + default slot and ``Slot("name")`` for named slots. """ return [Slot()] diff --git a/src/nitro_ui/core/element.py b/src/nitro_ui/core/element.py index 7949ff1..3571ae0 100644 --- a/src/nitro_ui/core/element.py +++ b/src/nitro_ui/core/element.py @@ -130,6 +130,28 @@ def _validate_css_value(value: str) -> bool: class HTMLElement: + """Foundation class for every HTML element in NitroUI. + + Owns the tag name, attributes, inline styles, and a list of children + (other ``HTMLElement`` instances or plain strings). All mutating methods + return ``self`` so calls chain. Instances are also context managers and + are serializable to JSON or HTML. + + Children may be passed positionally; keyword arguments become HTML + attributes. Python keywords are handled via ``class_name`` / ``for_element`` + (``class_`` and ``for_`` are also accepted). Underscores in other kwargs + are converted to hyphens (``data_value`` -> ``data-value``); SVG + camelCase attributes (``view_box`` -> ``viewBox``) are preserved. + + Text content and attribute values are HTML-escaped automatically; only + ``render()`` output is unescaped. + + Example: + >>> el = HTMLElement("Hello", tag="div", class_name="greeting") + >>> el.append(HTMLElement("world", tag="span")).render() + '
Helloworld
' + """ + __slots__ = [ "_tag", "_children", @@ -147,6 +169,23 @@ def __init__( self_closing: bool = False, **attributes: str, ): + """Create an HTML element with the given tag, children, and attributes. + + Args: + *children: Positional children. Each may be an ``HTMLElement``, + a string (appended to text content), a list/tuple of either + (flattened recursively), or ``None`` (skipped). + tag: HTML tag name (e.g. ``"div"``). Must match + ``^[a-zA-Z][a-zA-Z0-9-]*$``. + self_closing: If ``True``, renders as ```` with no + closing tag. Adding children emits a warning. + **attributes: HTML attributes. See class docstring for the + naming conventions applied to keys. + + Raises: + ValueError: If ``tag`` is empty, malformed, or a child has an + unsupported type. + """ # Attributes that should keep underscores (not convert to hyphens) PRESERVE_UNDERSCORE = {"class_name", "for_element"} @@ -240,11 +279,11 @@ def __del__(self) -> None: pass def __enter__(self) -> "HTMLElement": - """Context manager entry - returns self for use in with statements.""" + """Enter a ``with`` block, returning this element for further mutation.""" return self def __exit__(self, exc_type, exc_val, exc_tb) -> None: - """Context manager exit - no cleanup needed.""" + """Exit a ``with`` block. No cleanup is required.""" pass @staticmethod @@ -257,13 +296,25 @@ def _flatten(items: Union[List[Any], tuple]) -> Iterator[Any]: yield item def prepend(self, *children: Union["HTMLElement", str, List[Any]]) -> "HTMLElement": - """Prepends children to the current tag. + """Insert children at the front of this element. + + Mirrors ``append()`` but adds content at the start rather than the + end. Strings are prefixed to the existing text content; elements + are placed before existing children. + + Args: + *children: Elements, strings, or nested lists/tuples. ``None`` + values are skipped. Returns: - self for method chaining + This element, for chaining. Raises: - ValueError: If any child is not an HTMLElement or string + ValueError: If any child is not an ``HTMLElement`` or string. + + Example: + >>> Div(H2("Body")).prepend(H1("Title")).render() + '

Title

Body

' """ new_children: List[HTMLElement] = [] text_parts: List[str] = [] @@ -283,13 +334,25 @@ def prepend(self, *children: Union["HTMLElement", str, List[Any]]) -> "HTMLEleme return self def append(self, *children: Union["HTMLElement", str, List[Any]]) -> "HTMLElement": - """Appends children to the current tag. + """Add children to the end of this element. + + Strings are concatenated to the existing text content; elements + are placed after existing children. Nested lists/tuples are + flattened so you can splice collections in directly. + + Args: + *children: Elements, strings, or nested lists/tuples. ``None`` + values are skipped. Returns: - self for method chaining + This element, for chaining. Raises: - ValueError: If any child is not an HTMLElement or string + ValueError: If any child is not an ``HTMLElement`` or string. + + Example: + >>> Div().append(H1("Title"), Paragraph("Body")).render() + '

Title

Body

' """ text_parts: List[str] = [] for child in self._flatten(children): @@ -313,16 +376,24 @@ def filter( max_depth: int = DEFAULT_MAX_DEPTH, _current_depth: int = 0, ) -> Iterator["HTMLElement"]: - """Yields children (and optionally descendants) that meet the condition. + """Yield children (and optionally descendants) matching a predicate. Args: - condition: A callable that takes a child and returns True if it matches - recursive: If True, search descendants recursively - max_depth: Maximum recursion depth (default 1000) to prevent stack overflow - _current_depth: Internal parameter for tracking current depth + condition: Called with each child; return ``True`` to yield it. + recursive: If ``True``, walk the full subtree; otherwise only + direct children are considered. + max_depth: Traversal cutoff (default 1000) guarding against + circular references. + + Yields: + Matching ``HTMLElement`` instances. Raises: - RecursionError: If max_depth is exceeded + RecursionError: If ``max_depth`` is exceeded. + + Example: + >>> list(page.filter(lambda c: c.tag == "img", recursive=True)) + [, ] """ if _current_depth > max_depth: raise RecursionError( @@ -341,10 +412,15 @@ def filter( ) def remove_all(self, condition: Callable[[Any], bool]) -> "HTMLElement": - """Removes all children that meet the condition. + """Remove every direct child matching a predicate. + + Only the immediate children are considered (not descendants). + + Args: + condition: Called with each child; return ``True`` to remove it. Returns: - self for method chaining + This element, for chaining. """ to_remove = list(self.filter(condition)) for child in to_remove: @@ -353,31 +429,52 @@ def remove_all(self, condition: Callable[[Any], bool]) -> "HTMLElement": return self def clear(self) -> "HTMLElement": - """Clears all children from the tag. + """Remove all children from this element. + + Text content is left untouched; use ``text = ""`` to clear it. Returns: - self for method chaining + This element, for chaining. """ self._children.clear() return self def pop(self, index: int = 0) -> "HTMLElement": - """Pops a child from the tag.""" + """Remove and return a child by position. + + Args: + index: Position to remove from; defaults to the first child. + + Returns: + The removed ``HTMLElement``. + + Raises: + IndexError: If the element has no children or the index is + out of range. + """ return self._children.pop(index) def first(self) -> Union["HTMLElement", None]: - """Returns the first child of the tag.""" + """Return the first child, or ``None`` if this element has none.""" return self._children[0] if self._children else None def last(self) -> Union["HTMLElement", None]: - """Returns the last child of the tag.""" + """Return the last child, or ``None`` if this element has none.""" return self._children[-1] if self._children else None def add_attribute(self, key: str, value: str) -> "HTMLElement": - """Adds an attribute to the current tag. + """Set a single HTML attribute on this element. + + Existing attributes with the same key are overwritten. Setting the + ``style`` attribute invalidates the parsed-styles cache used by + ``add_style`` / ``get_style``. + + Args: + key: Attribute name (use ``class_name`` instead of ``class``). + value: Attribute value. Will be HTML-escaped at render time. Returns: - self for method chaining + This element, for chaining. """ # Prevent duplicate class attributes from mixed API usage if key == "class" and "class_name" in self._attributes: @@ -392,10 +489,16 @@ def add_attribute(self, key: str, value: str) -> "HTMLElement": return self def add_attributes(self, attributes: List[Tuple[str, str]]) -> "HTMLElement": - """Adds multiple attributes to the current tag. + """Set multiple HTML attributes in one call. + + Args: + attributes: Iterable of ``(key, value)`` pairs. Returns: - self for method chaining + This element, for chaining. + + Example: + >>> el.add_attributes([("id", "main"), ("data-role", "card")]) """ has_style = False for key, value in attributes: @@ -408,10 +511,13 @@ def add_attributes(self, attributes: List[Tuple[str, str]]) -> "HTMLElement": return self def remove_attribute(self, key: str) -> "HTMLElement": - """Removes an attribute from the current tag. + """Remove an attribute by key. Missing keys are silently ignored. + + Args: + key: Attribute name to remove. Returns: - self for method chaining + This element, for chaining. """ self._attributes.pop(key, None) if key == "style": @@ -419,11 +525,11 @@ def remove_attribute(self, key: str) -> "HTMLElement": return self def get_attribute(self, key: str) -> Union[str, None]: - """Gets an attribute from the current tag.""" + """Return the value of a named attribute, or ``None`` if absent.""" return self._attributes.get(key) def has_attribute(self, key: str) -> bool: - """Checks if an attribute exists in the current tag.""" + """Return ``True`` if the given attribute is set on this element.""" return key in self._attributes def _get_styles_dict(self) -> dict: @@ -446,18 +552,24 @@ def _flush_styles_cache(self) -> None: self._attributes.pop("style", None) def add_style(self, key: str, value: str) -> "HTMLElement": - """ - Adds a CSS style to the element's inline styles. + """Add a single inline CSS declaration to this element. + + Values are checked for common injection patterns (``javascript:``, + ``expression(...)``, etc.) before being stored. Args: - key: CSS property name (e.g., 'color', 'font-size') - value: CSS property value (e.g., 'red', '14px') + key: CSS property name (e.g. ``"color"``, ``"font-size"``). + value: CSS property value (e.g. ``"red"``, ``"14px"``). Returns: - self for method chaining + This element, for chaining. Raises: - ValueError: If the CSS value contains potentially dangerous content + ValueError: If ``value`` contains potentially dangerous content. + + Example: + >>> Div().add_style("color", "red").render() + '
' """ if not _validate_css_value(str(value)): raise ValueError( @@ -472,18 +584,20 @@ def add_style(self, key: str, value: str) -> "HTMLElement": return self def add_styles(self, styles: dict) -> "HTMLElement": - """ - Adds multiple CSS styles to the element's inline styles. + """Merge a dict of inline CSS declarations into this element. + + Later keys overwrite earlier ones on the element. All values are + validated before any are stored. Args: - styles: Dictionary of CSS properties and values - e.g., {"color": "red", "font-size": "14px"} + styles: Mapping of CSS property names to values, e.g. + ``{"color": "red", "font-size": "14px"}``. Returns: - self for method chaining + This element, for chaining. Raises: - ValueError: If any CSS value contains potentially dangerous content + ValueError: If any value contains potentially dangerous content. """ for key, value in styles.items(): if not _validate_css_value(str(value)): @@ -499,27 +613,29 @@ def add_styles(self, styles: dict) -> "HTMLElement": return self def get_style(self, key: str) -> Union[str, None]: - """ - Gets a specific CSS style value from the element's inline styles. + """Return the inline CSS value for a property, or ``None`` if unset. Args: - key: CSS property name + key: CSS property name (e.g. ``"color"``). Returns: - The CSS property value or None if not found + The stored value, or ``None`` if this element has no such + inline declaration. """ styles_dict = self._get_styles_dict() return styles_dict.get(key) def remove_style(self, key: str) -> "HTMLElement": - """ - Removes a CSS style from the element's inline styles. + """Remove an inline CSS declaration by property name. + + Missing keys are silently ignored. If this removes the last + declaration, the ``style`` attribute is dropped entirely. Args: - key: CSS property name to remove + key: CSS property name to remove. Returns: - self for method chaining + This element, for chaining. """ styles_dict = self._get_styles_dict() styles_dict.pop(key, None) @@ -582,16 +698,30 @@ def _format_styles(styles_dict: dict) -> str: return "; ".join(f"{k}: {v}" for k, v in styles_dict.items()) def generate_id(self) -> None: - """Generates an id for the current tag if not already present.""" + """Assign an auto-generated ``id`` attribute if one is not already set. + + The id is ``"el-"`` followed by the first 6 hex characters of a + UUID4. Called automatically on construction when the + ``NITRO_UI_GENERATE_IDS`` environment variable is set. + """ if "id" not in self._attributes: self._attributes["id"] = f"el-{str(uuid.uuid4())[:6]}" def clone(self) -> "HTMLElement": - """Clones the current tag.""" + """Return a deep copy of this element and its entire subtree.""" return copy.deepcopy(self) def replace_child(self, old_index: int, new_child: "HTMLElement") -> None: - """Replaces a existing child element with a new child element.""" + """Swap a child at the given index with a new element. + + Args: + old_index: Position of the child to replace. + new_child: Replacement element. + + Raises: + ValueError: If ``new_child`` is not an ``HTMLElement``. + IndexError: If ``old_index`` is out of range. + """ if not isinstance(new_child, HTMLElement): raise ValueError( f"new_child must be an HTMLElement, got {type(new_child).__name__}" @@ -604,18 +734,22 @@ def find_by_attribute( attr_value: Any, max_depth: int = DEFAULT_MAX_DEPTH, ) -> Union["HTMLElement", None]: - """Finds a child by an attribute. + """Return the first descendant (or self) whose attribute matches. + + Performs a depth-first search through the subtree; the current + element itself is checked before its children. Args: - attr_name: The attribute name to search for - attr_value: The attribute value to match - max_depth: Maximum recursion depth (default 1000) to prevent stack overflow + attr_name: Attribute name to look up on each visited element. + attr_value: Value to compare against; must match exactly. + max_depth: Traversal cutoff (default 1000) guarding against + circular references. Returns: - The matching element or None if not found + The first matching element, or ``None`` if no match is found. Raises: - RecursionError: If max_depth is exceeded + RecursionError: If ``max_depth`` is exceeded. """ def _find( @@ -639,33 +773,52 @@ def _find( return _find(self) def get_attributes(self, *keys: str) -> dict: - """Returns the attributes of the current tag.""" + """Return a copy of attributes, optionally restricted to given keys. + + Args: + *keys: If provided, the returned dict is limited to these keys; + missing keys map to ``None``. If omitted, all attributes + are returned. + + Returns: + A new dict; mutating it does not affect the element. + """ if keys: return {key: self._attributes.get(key) for key in keys} return self._attributes.copy() def count_children(self) -> int: - """Returns the number of children in the current tag.""" + """Return the number of direct children (not including text content).""" return len(self._children) def on_load(self) -> None: - """Callback called when the tag is loaded.""" + """Hook called at the end of ``__init__``. Override for custom setup.""" pass def on_before_render(self) -> None: - """Callback called before the tag is rendered.""" + """Hook called immediately before this element renders itself. + + Override to mutate the tree right before serialization. Called by + ``render()`` on each invocation, including nested child renders. + """ pass def on_after_render(self) -> None: - """Callback called after the tag is rendered.""" + """Hook called immediately after this element finishes rendering.""" pass def on_unload(self) -> None: - """Callback called when the tag is unloaded.""" + """Hook called from ``__del__``. Do not rely on this for cleanup. + + Python's garbage collector may skip ``__del__`` for objects + participating in reference cycles. Use explicit cleanup or context + managers for anything load-bearing. + """ pass @property def tag(self) -> str: + """The HTML tag name (e.g. ``"div"``, ``"span"``).""" return self._tag @tag.setter @@ -674,6 +827,11 @@ def tag(self, value: str) -> None: @property def children(self) -> List["HTMLElement"]: + """A shallow copy of the list of child elements. + + The returned list can be mutated without affecting this element. + Assigning to this property replaces the internal child list. + """ return list(self._children) @children.setter @@ -682,6 +840,7 @@ def children(self, value: List["HTMLElement"]) -> None: @property def text(self) -> str: + """Text content of this element (already-joined, unescaped source).""" return self._text @text.setter @@ -690,6 +849,7 @@ def text(self, value: str) -> None: @property def attributes(self) -> dict: + """Live dict of attributes. Mutation invalidates the styles cache on assign.""" return self._attributes @attributes.setter @@ -699,6 +859,7 @@ def attributes(self, value: dict) -> None: @property def self_closing(self) -> bool: + """Whether this element renders as a void/self-closing tag.""" return self._self_closing @self_closing.setter @@ -754,19 +915,28 @@ def render( _indent: int = 0, max_depth: int = DEFAULT_MAX_DEPTH, ) -> str: - """ - Renders the HTML element and its children to a string. + """Serialize this element and its subtree to an HTML string. + + Attribute values and text content are HTML-escaped. The opening + tag, attributes, children, and closing tag are emitted in order; + self-closing elements emit ```` with no children. Args: - pretty: If True, renders with indentation and newlines for readability - _indent: Internal parameter for tracking indentation level - max_depth: Maximum recursion depth (default 1000) to prevent stack overflow + pretty: If ``True``, insert indentation and newlines for + human-readable output. Defaults to compact output. + max_depth: Traversal cutoff (default 1000) guarding against + circular references in the tree. Returns: - String representation of the HTML element + HTML representation of this element. Raises: - RecursionError: If max_depth is exceeded (likely circular reference) + RecursionError: If ``max_depth`` is exceeded - usually a sign + of a circular reference rather than legitimate depth. + + Example: + >>> Div(Paragraph("Hi"), class_name="card").render() + '

Hi

' """ if _indent > max_depth: raise RecursionError( @@ -829,17 +999,21 @@ def to_dict( _depth: int = 0, max_depth: int = DEFAULT_MAX_DEPTH, ) -> dict: - """Serialize the element to a dictionary. + """Serialize this element and its subtree to a plain dict. + + The returned dict has keys ``tag``, ``self_closing``, + ``attributes``, ``text``, and ``children`` (recursively). It is + JSON-safe and round-trips via ``from_dict()``. Args: - _depth: Internal parameter for tracking recursion depth - max_depth: Maximum recursion depth (default 1000) + max_depth: Traversal cutoff (default 1000) guarding against + circular references. Returns: - Dictionary representation of the element + Dict representation of the element. Raises: - RecursionError: If max_depth is exceeded + RecursionError: If ``max_depth`` is exceeded. """ if _depth > max_depth: raise RecursionError( @@ -858,14 +1032,14 @@ def to_dict( } def to_json(self, indent: Union[int, None] = None) -> str: - """ - Serializes the element and its children to a JSON string. + """Serialize this element and its subtree to a JSON string. Args: - indent: Number of spaces for JSON indentation (None for compact output) + indent: Spaces for pretty-printed JSON, or ``None`` for compact + single-line output. Returns: - JSON string representation of the element + JSON representation; pair with ``from_json()`` to round-trip. """ return json.dumps(self.to_dict(), indent=indent) @@ -876,20 +1050,23 @@ def from_dict( _depth: int = 0, max_depth: int = DEFAULT_MAX_DEPTH, ) -> "HTMLElement": - """ - Reconstructs an HTMLElement from a dictionary. + """Reconstruct an element tree from a ``to_dict()`` result. + + Uses the tag registry populated by the tag factory to instantiate + the most specific subclass for each tag, so ``Fragment``, + ``Table``, ``Form``, etc. round-trip as their real types. Args: - data: Dictionary containing element data (from to_dict()) - _depth: Internal parameter for tracking recursion depth - max_depth: Maximum recursion depth (default 1000) + data: Dict produced by ``to_dict()``. + max_depth: Traversal cutoff (default 1000). Returns: - Reconstructed HTMLElement instance + Reconstructed ``HTMLElement`` (or subclass matching ``tag``). Raises: - ValueError: If input data is invalid - RecursionError: If max_depth is exceeded + ValueError: If ``data`` is not a dict, is missing ``tag``, or + has a field with the wrong type. + RecursionError: If ``max_depth`` is exceeded. """ if not isinstance(data, dict): raise ValueError("Input must be a dictionary") @@ -966,14 +1143,17 @@ def from_dict( @classmethod def from_json(cls, json_str: str) -> "HTMLElement": - """ - Reconstructs an HTMLElement from a JSON string. + """Reconstruct an element tree from a ``to_json()`` result. Args: - json_str: JSON string representation (from to_json()) + json_str: JSON previously produced by ``to_json()``. Returns: - Reconstructed HTMLElement instance + Reconstructed ``HTMLElement`` (or registered subclass). + + Raises: + ValueError: If the string is not valid JSON or the decoded + payload is not a valid element dict. """ try: data = json.loads(json_str) diff --git a/src/nitro_ui/core/parser.py b/src/nitro_ui/core/parser.py index f274ccd..51af9d3 100644 --- a/src/nitro_ui/core/parser.py +++ b/src/nitro_ui/core/parser.py @@ -195,28 +195,32 @@ def parse_fragment(self, html_string: str) -> List[HTMLElement]: def from_html( html_string: str, fragment: bool = False ) -> Union[HTMLElement, List[HTMLElement], None]: - """Parse HTML string and convert to NitroUI element(s). + """Parse an HTML string into a NitroUI element tree. + + Uses the standard library's ``html.parser`` under the hood. Class + attributes are mapped to ``class_name``; SVG camelCase attributes + (``viewBox`` etc.) are preserved. Whitespace inside ``
``,
+    ````, ``