Skip to content
Merged
11 changes: 11 additions & 0 deletions collagraph/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
from .collagraph import Collagraph # noqa: F401
from .component import Component # noqa: F401
from .constants import EventLoopType # noqa: F401
from .dsl import ( # noqa: F401
dynamic,
each,
fill,
h,
or_when,
otherwise,
slot,
static,
when,
)
from .renderers import * # noqa: F403
from .sfc import importer # noqa: F401

Expand Down
99 changes: 91 additions & 8 deletions collagraph/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import argparse
import importlib
import json
import sys
from pathlib import Path

from observ import reactive
Expand All @@ -25,15 +26,74 @@ def available_renderers():
return result


def load_component_class(component_path: Path, class_name: str | None = None):
"""Import a component file (.cgx or .py) and return its root component class."""
if component_path.suffix == ".cgx":
file_as_module = ".".join([*component_path.parts[:-1], component_path.stem])
component_module = importlib.import_module(file_as_module)
return component_module.__component_class

# Plain Python module with view component(s): import it by its stem,
# with its directory on sys.path so sibling imports work
directory = str(component_path.parent.resolve())
if directory not in sys.path:
sys.path.insert(0, directory)
module = importlib.import_module(component_path.stem)
module_file = getattr(module, "__file__", None)
if module_file is None or Path(module_file).resolve() != component_path.resolve():
raise SystemExit(
f"Cannot import {component_path}: the module name "
f"{component_path.stem!r} is already taken by {module_file!r}. "
"Rename the file to something unique."
)
return find_view_component_class(module, class_name, component_path)


def find_view_component_class(module, class_name: str | None, component_path: Path):
"""Find the root view component class in a plain Python module."""
if class_name is not None:
cls = getattr(module, class_name, None)
if not (isinstance(cls, type) and issubclass(cls, cg.Component)):
raise SystemExit(
f"{component_path}: {class_name!r} is not a Component subclass"
)
return cls

# Allow modules to mark their root component explicitly,
# like compiled .cgx modules do
explicit = getattr(module, "__component_class", None)
if explicit is not None:
return explicit

candidates = [
attr
for attr in vars(module).values()
if isinstance(attr, type)
and issubclass(attr, cg.Component)
and attr is not cg.Component
and attr.__module__ == module.__name__
and attr.view is not cg.Component.view
]
if len(candidates) == 1:
return candidates[0]
if not candidates:
raise SystemExit(f"No view component found in {component_path}")
names = ", ".join(cls.__name__ for cls in candidates)
hint = f"{component_path}:{candidates[-1].__name__}"
raise SystemExit(
f"Multiple view components found in {component_path}: {names}. "
f"Select one by appending the class name: {hint}"
)


def init_collagraph(
renderer_type: str,
component_path: Path,
state: dict | None = None,
hot_reload: bool = False,
class_name: str | None = None,
):
file_as_module = ".".join([*component_path.parts[:-1], component_path.stem])
component_module = importlib.import_module(file_as_module)
component_class = component_module.__component_class
component_class = load_component_class(component_path, class_name)
props = reactive(state or {})

if renderer_type == "pygfx":
Expand Down Expand Up @@ -91,13 +151,24 @@ def show_code(component_path: Path):

def existing_component_file(value):
path = Path(value)
class_name = None
if not path.exists() and ":" in value:
# Allow selecting a component class: path/to/module.py:ClassName
path_str, _, name = value.rpartition(":")
if name.isidentifier():
path = Path(path_str)
class_name = name
if not path.exists():
raise argparse.ArgumentTypeError(f"{value} does not exist")
if not path.is_file():
raise argparse.ArgumentTypeError(f"{value} is not a file")
if path.suffix != ".cgx":
if path.suffix not in (".cgx", ".py"):
raise argparse.ArgumentTypeError(f"{value} is not a collagraph component")
return path
if class_name and path.suffix != ".py":
raise argparse.ArgumentTypeError(
f"{value}: selecting a component class is only supported for .py files"
)
return path, class_name


def json_contents(value):
Expand Down Expand Up @@ -134,7 +205,10 @@ def run():
parser.add_argument(
"component",
type=existing_component_file,
help="Path to component to render",
help=(
"Path to component to render: a .cgx file or a .py module "
"with a view component (append :ClassName to select a class)"
),
)
parser.add_argument(
"--hot-reload",
Expand All @@ -148,12 +222,21 @@ def run():
help="Pretty print the compiled Python code for the component and exit",
)
args = parser.parse_args()
component_path, class_name = args.component

if args.show_code:
show_code(args.component)
if component_path.suffix != ".cgx":
parser.error("--show-code is only supported for .cgx files")
show_code(component_path)
return

init_collagraph(args.renderer, args.component, args.state, args.hot_reload)
init_collagraph(
args.renderer,
component_path,
args.state,
args.hot_reload,
class_name=class_name,
)


if __name__ == "__main__":
Expand Down
27 changes: 24 additions & 3 deletions collagraph/component.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from __future__ import annotations

from abc import abstractmethod
from collections import defaultdict
from typing import TYPE_CHECKING, ClassVar
from weakref import ref
Expand Down Expand Up @@ -117,8 +116,30 @@ def before_unmount(self):
"""
pass

@abstractmethod
def render(self, renderer: Renderer) -> ComponentFragment: # pragma: no cover
def render(self, renderer: Renderer) -> ComponentFragment:
"""Build the fragment tree for this component.

Components defined in a .cgx file get a compiled implementation of
this method injected. Pure-Python components implement `view`
instead, which is picked up here.
"""
if type(self).view is not Component.view:
from .dsl import build_view

return build_view(self, renderer)
raise NotImplementedError(
f"{type(self).__name__} defines neither a template (.cgx) "
"nor a `view` method"
)

def view(self) -> None:
"""Describe the UI of this component with the pure-Python view API.

Runs once, when the component is created. Build the element tree
with `collagraph.h` and the control flow helpers (`when`, `each`,
...); pass callables for any value that should update reactively.
See `collagraph.dsl` for the full API.
"""
raise NotImplementedError

def provide(self, key: str, value):
Expand Down
Loading
Loading