Metadata-Version: 2.4
Name: brickly-core
Version: 1.0.0
Summary: Base classes and services for a backend application
Author-email: Sargsian Serj <serj.sargsian@gmail.com>, Taraskin Anton <antonnidhoggr@me.com>
Maintainer-email: Sargsian Serj <serj.sargsian@gmail.com>, Taraskin Anton <antonnidhoggr@me.com>
Project-URL: Homepage, https://gitlab.com/brickly/brickly-core
Project-URL: Documentation, https://gitlab.com/brickly/brickly-core
Keywords: brickly
Classifier: Development Status :: 4 - Beta
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: Implementation :: CPython
Requires-Python: >=3.13
Description-Content-Type: text/x-rst
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: uv; extra == "dev"
Requires-Dist: pre-commit; extra == "dev"
Requires-Dist: bump-my-version; extra == "dev"
Requires-Dist: black; extra == "dev"
Requires-Dist: isort; extra == "dev"
Requires-Dist: bandit; extra == "dev"
Requires-Dist: pyupgrade; extra == "dev"
Requires-Dist: pyroma; extra == "dev"
Requires-Dist: pylint; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Requires-Dist: pytest-aiohttp; extra == "dev"
Requires-Dist: docker; extra == "dev"
Requires-Dist: coverage; extra == "dev"
Provides-Extra: docs
Requires-Dist: sphinx; extra == "docs"
Requires-Dist: python-docs-theme; extra == "docs"
Dynamic: license-file

brickly-core
============

brickly-core lets you build the pages of a web app on the backend, in Python. You don't write
frontend code for each page: you describe **what** is shown at each URL, and a ready-made frontend
renders it.

How it works:

* **Widgets** describe the UI: a text, a form, a list, a layout. They are plain Python classes.
* **Screens** say which widgets are shown at a URL, which layout wraps them, what to show if
  something goes wrong.
* **Routers** bind screens to URLs, and **middleware** decides who may open them (log in, check
  access, redirect).

From this, brickly-core builds the routing of the whole app, which the frontend loads to know which
pages exist; when a user opens a page, the frontend asks your backend for its widgets and renders
them.

brickly-core has no dependencies and isn't tied to a web framework: it holds the description of
your app, and connecting it to FastAPI, Django or another framework is done by a separate adapter.
Requires Python 3.13+.

Installation
------------

pip install brickly-core

Quick start
-----------

You describe which widget is shown at which path, and brickly-core builds the routing for the
frontend out of it. Everything you need is imported from the package:

.. code-block:: python

    from dataclasses import dataclass
    from typing import Literal

    from brickly_core import *


    # 1. Widgets: what the frontend renders.
    @dataclass(kw_only=True)
    class TextWidget(Widget):
        type: Literal["text"] = "text"
        text: str


    # 2. Events: functions that build the content of a screen.
    @event("layout")
    async def layout(**_):
        return FragmentWidget(TextWidget(text="My shop"), OutletWidget())

    @event("home")
    async def home(**_):
        return TextWidget(text="Welcome!")

    @event("item")
    async def item(item_id: int, **_):
        return TextWidget(text=f"Item #{item_id}")

    @event("item.meta")
    async def item_meta(item_id: int, **_):
        return {"title": f"Item #{item_id}"}


    # 3. Screens bound to paths.
    router = Router(paths=[
        RouterPath(template="/", screen=Screen(shell_widget=layout, widget=home)),
        RouterPath(template="item/{item_id:int}", screen=Screen(
            widget=item,
            meta=item_meta,
            fallback_widget=TextWidget(text="Item is unavailable"),
        )),
    ])

    # 4. The routing of the app, ready to be served to the frontend.
    settings = RouterSettings(routers=[router])

Opening ``/item/42`` shows ``My shop`` (the layout of ``/``) with ``Item #42`` inside it.

Events
------

``@event`` marks a function that the routing may call to build a screen: its widget, its layout,
its meta data, or a middleware step. Only decorated functions can be used in ``Screen`` and
``Middleware``.

.. code-block:: python

    @event("item")
    async def item(item_id: int, **_):
        return TextWidget(text=f"Item #{item_id}")

* Give every event a **unique** name (``"item"``, ``"shop.cart"``, ...). Using the same name twice
  is an error at import time.
* Path parameters arrive as keyword arguments with their names from the template:
  ``item/{item_id:int}`` gives ``item_id``. End the signature with ``**_`` to accept everything else.
* Both ``async def`` and plain ``def`` work.
* Events can be static or class methods. Put ``@event`` **above** ``@staticmethod`` /
  ``@classmethod``:

  .. code-block:: python

      class Pages:
          @event("pages.about")
          @staticmethod
          async def about(**_):
              return TextWidget(text="About")

          @event("pages.contacts")
          @classmethod
          async def contacts(cls, **_):
              return TextWidget(text="Contacts")

      RouterPath(template="about", screen=Screen(widget=Pages.about))

  Regular methods taking ``self`` can't be events.

Screens
-------

A ``Screen`` describes what is shown at a path. All settings are optional:

=====================  ========================================================================
Setting                Meaning
=====================  ========================================================================
``widget``             Event returning the content of the page.
``shell_widget``       Event returning a layout around this page **and all pages below it**.
                       It must contain one ``OutletWidget()``: the place where the nested page
                       is rendered.
``meta``               Event returning data for the page, e.g. its title.
``fallback_widget``    A widget shown instead of the page if anything fails while preparing it
                       (middleware, layout, content or meta).
``cache``              Cache the screen (``False`` by default).
``cache_ttl``          How long to cache it, in seconds: ``100`` by default, ``-1`` forever,
                       ``0`` no cache.
=====================  ========================================================================

Layouts nest along the path. With a layout on ``/`` and another on ``/dashboard``, opening
``/dashboard/stats`` renders the ``/`` layout, inside it the ``/dashboard`` layout, and inside that
the ``stats`` page:

.. code-block:: python

    router = Router(paths=[
        RouterPath(template="/", screen=Screen(shell_widget=main_layout)),
        RouterPath(template="dashboard", screen=Screen(shell_widget=dashboard_layout)),
        RouterPath(template="dashboard/stats", screen=Screen(widget=stats)),
    ])

Paths
-----

``RouterPath(template=..., screen=...)`` binds a screen to a path.

* Segments are separated by ``/``; leading and trailing slashes don't matter, and ``"/"`` is the
  root of the router.
* A parameter is written ``{name:type}``: ``item/{item_id:int}``, ``shop/{slug:str}``. Its value is
  passed to the events of that screen and of the screens below it.

Routers
-------

A ``Router`` groups paths under a common ``prefix``. Split a big app into several routers, e.g. one
per module, and pass them all to ``RouterSettings``:

.. code-block:: python

    shop = Router(prefix="/shop", paths=[
        RouterPath(template="/", screen=Screen(widget=shop_page)),
        RouterPath(template="item/{item_id:int}", screen=Screen(widget=item)),
    ])
    blog = Router(prefix="/blog", paths=[
        RouterPath(template="/", screen=Screen(widget=blog_page)),
    ])

    settings = RouterSettings(routers=[shop, blog])     # /shop, /shop/item/42, /blog

Paths can also be added later with ``router.add(RouterPath(...))``. Build ``RouterSettings`` once
every router is complete.

Routers with the same prefix are merged
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Several routers may use the **same prefix**: their paths end up in one section, and their
middleware is shared by **all** the paths under that prefix. This lets different modules add pages
to one section without knowing about each other:

.. code-block:: python

    # users module
    admin_users = Router(prefix="/admin", middleware=Middleware(events=[require_admin]), paths=[
        RouterPath(template="users", screen=Screen(widget=users)),
    ])

    # billing module: no middleware of its own
    admin_billing = Router(prefix="/admin", paths=[
        RouterPath(template="billing", screen=Screen(widget=billing)),
    ])

    settings = RouterSettings(routers=[admin_users, admin_billing])

Both ``/admin/users`` and ``/admin/billing`` are protected by ``require_admin``. If both routers had
middleware, the lists are joined in the order of ``routers``, and a middleware listed in both runs
only once.

Overriding a route
~~~~~~~~~~~~~~~~~~

Registering the same path twice is an error. To replace a route on purpose, for example a page
that comes from a shared module, register it again with ``override=True``:

.. code-block:: python

    custom = Router(paths=[
        RouterPath(template="/", screen=Screen(widget=my_home), override=True),
    ])

    settings = RouterSettings(routers=[router, custom])   # "/" now shows my_home

The overriding router must come **after** the original one in ``routers``.

Middleware
----------

Middleware are events that run **before** a screen is shown: load the current user, check access,
redirect. Each one returns what should happen next:

=============================  ==================================================================
Return                         Effect
=============================  ==================================================================
``None``                       Continue.
``Allow(context={...})``       Continue, and pass data on to the next middleware.
``Redirect(to="/login")``      Stop and go to another page.
``Deny(status=403)``           Stop and show a refusal on the same page (its own ``widget=...``
                               or the screen's ``fallback_widget``).
=============================  ==================================================================

.. code-block:: python

    @event("auth.load_user")
    async def load_user(ctx, **_):
        user = await get_current_user()
        if user is None:
            return Redirect(to="/login")
        return Allow(context={"user": user})

    @event("auth.require_admin")
    async def require_admin(ctx, **_):
        if not ctx.data["user"].is_admin:          # data passed on by load_user
            return Deny(status=403)

Attach middleware to the whole app or to a router:

.. code-block:: python

    admin = Router(prefix="/admin", middleware=Middleware(events=[require_admin]), paths=[...])

    settings = RouterSettings(
        routers=[router, admin],
        middleware=Middleware(events=[load_user]),      # runs before every screen
    )

* ``RouterSettings(middleware=...)`` runs before every screen of the app, first.
* ``Router(middleware=...)`` runs before every screen under the router's prefix, including pages of
  other routers with the same prefix (see above), and nested prefixes such as ``/admin/reports``.
* Middleware runs from the outside in: app first, then ``/admin``, then ``/admin/reports``. A
  middleware listed more than once runs only once.
* ``Redirect`` and ``Deny`` stop the chain: the middleware after them don't run.
