Serve a Plone site with ploneasgi#

In this tutorial, you will create a Plone site, serve it under uvicorn with ploneasgi, and add a native route that tells you who is asking. At the end, the same process will answer both a Plone page and a JSON endpoint that knows the logged-in user.

You need Python 3.14, uv, and curl. The steps take about ten minutes, most of it installing Plone.

Create the project#

Create a new Python package and move into it:

uv init --package --python 3.14 my-site
cd my-site

Add ploneasgi and Plone's default content types:

uv add "ploneasgi[plone] @ git+https://github.com/simplesconsultoria/ploneasgi" plone.app.contenttypes

This downloads Plone, so it takes a few minutes.

Write the route#

Create src/my_site/endpoints.py:

from starlette.responses import JSONResponse


def hello(ctx, request):
    return JSONResponse({
        "user": ctx.user.getUserName(),
        "site": ctx.portal.Title(),
    })

ctx is the site as the request's user sees it: ploneasgi has already opened the database, entered the site, and authenticated the request before it calls hello.

Create src/my_site/routes.zcml to register it:

<configure xmlns:ploneasgi="http://namespaces.plone.org/ploneasgi">

  <ploneasgi:route
      path="/hello"
      handler="my_site.endpoints.hello"
      permission="zope2.View"
      />

</configure>

Generate the instance#

Generate a Zope instance that loads ploneasgi's directive and the routes:

uvx cookiecutter --no-input gh:plone/cookiecutter-zope-instance \
  target=instance \
  initial_user_name=admin \
  initial_user_password=letmein-plone \
  zcml_package_metas=ploneasgi \
  zcml_include_file_location=src/my_site/routes.zcml

You now have an instance folder, with the configuration in instance/etc/zope.conf.

Create the site#

Create create_site.py:

from Products.CMFPlone.factory import addPloneSite
from Testing.makerequest import makerequest

import transaction


app = makerequest(app)  # noqa: F821 - zconsole binds `app`

if "Plone" not in app.objectIds():
    addPloneSite(
        app,
        "Plone",
        title="My site",
        extension_ids=("plone.app.contenttypes:default",),
        setup_content=True,
    )
    transaction.commit()
    print("Created the site at /Plone")

Run it:

uv run zconsole run instance/etc/zope.conf create_site.py

The terminal should print Created the site at /Plone.

Write the server#

Create server.py:

from ploneasgi import create_app
from ploneasgi import load_env
from ploneasgi import serve
from ploneasgi import Settings
from ploneasgi.zcml import routes_from_registry
from ploneasgi.zopeapp import build_wsgi_app
from ploneasgi.zopeapp import check_concurrency_contract


load_env()
settings = Settings.from_env()

# Starting Zope executes the ZCML, which registers the routes collected below.
wsgi_app = build_wsgi_app(settings)
check_concurrency_contract(settings)

app = create_app(
    settings,
    routes=routes_from_registry(prefix=settings.route_prefix),
    wsgi_app=wsgi_app,
)

if __name__ == "__main__":
    serve(app, settings)

Start it, with the access log on:

PLONE_ACCESS_LOG=1 uv run python server.py

Notice the line Uvicorn running on http://0.0.0.0:8080. Leave the server running, and open a second terminal in the same folder.

Visit the site#

Open http://127.0.0.1:8080/Plone in your browser. You should see the front page of the site, titled My site, served by Plone under uvicorn.

Look at the first terminal. Each request has written one access log line, with two timings after the status and size: the total duration, then the time to the first byte.

Call the route#

In the second terminal, call the route:

curl http://127.0.0.1:8080/++asgi++/hello

The answer is:

{"user":"Anonymous User","site":"My site"}

The route entered the site and read its title, as an anonymous visitor.

Now call it as the administrator:

curl -u admin:letmein-plone http://127.0.0.1:8080/++asgi++/hello

This time the answer names admin. The route authenticated the request through Plone, exactly as a Plone page would.

Stop the server#

Press Ctrl+C in the first terminal. The last lines of the log say Closing ZODB: the database closed cleanly.

You have served a Plone site under an ASGI server, and added an endpoint beside it that knows who is asking.

Next steps#