Forwards-only migrations, new urls, and more
As we get closer to the 1.0 release, I've decided to bite off a few more of the larger, backwards-incompatible changes that I've been considering. Some of these may even be controversial 😱
There's no time like the present!
- Removed backwards migrations
- Development db backups
- New url
Router
, updated url functions @register
everything- Removed swappable models
- Tailwind v4
- Other notable changes
- Next steps
Removed backwards migrations
In my experience, backwards migrations can be helpful in development, but in production you would never use them.
This deserves an entire blog post (and I'd love to hear the counter arguments), but the migrations are code (committed in your repo!) and coordinating the rollback of migrations + code can be ridiculously complex in production. You can't simply git revert
or heroku rollback
because of the way everything is intertwined. Instead, you're probably going to deploy more code (and update/add migrations), and possibly restore database backups or manually fix data.
Development db backups
Ok, so backwards migrations are gone. What do we do in development if we need to undo something? What if we used backups instead?!
The plain migrate
command will now prompt to make a local backup.
You can also manually create, list, and restore backups using the plain backups
command.
Turns out, this is incredibly useful if you work on git branches that have new migrations. When you switch branches, just restore the backup for that branch!
We'll continue to polish the experience here, but I like the direction it's headed.
New url Router
, updated url functions
There are a number of things that I consistenly find confusing with urls, url namespaces, the path()
and include()
functions, and even the params of the reverse
function. Lots of small details. This is what it looks like now!
from plain.assets.urls import AssetsRouter
from plain.urls import Router, include, path
from . import views
# The primary entrypoint for your app
class AppRouter(Router):
namespace = "" # A required attribute!
urls = [
# Use include() by itself, not path(include())
include("assets/", AssetsRouter),
path("login/", views.LoginView, name="login"),
path("", views.HomeView),
]
# The primary router is pointed to in settings.py
URLS_ROUTER = "app.urls.AppRouter"
# And reversing urls is now like this (simpler args/kwargs)
reverse("users:detail", pk=1)
I will admit that the Router looks more complex than simply defining a list named urlpatterns
. But being more explicit is one of Plain's design goals. Doing it this way means:
urls.py
is no longer a special moduleurlpatterns
is no longer a special module-level variableapp_name
is no longer a special module-level variable (which is not a good name anyway IMO)- namespaces are front and center, and strongly encouraged (basically everything should have a namespace except the root router)
@register
everything
In the name of explicitness and clarity being a good thing, I've decided to go all-in on the @register
decorator pattern.
When you create a model, you'll now need to @register_model
it.
from plain import models
@models.register_model
class User(models.Model):
email = models.EmailField()
You'll see a similar pattern in worker jobs, package configs, Jinja extensions and filters, custom CLIs, admin views, and preflight checks.
Why do this? I learned Python by learning Django, over 12 years ago. There was so much "magic" involved that I didn't really understand what was Django and what was Python. My Model
classes were gigantic, because I honestly didn't know that you could write classes and objects that weren't models! There's a lot to blame for this, including me and my ways of learning, but I think there is also a lot of value to saying "look, here's the line that makes that happen!" which is something you get by explicitly writing things like @register_model
. This is a pattern that already existed for system checks and a couple other things, and it's one that I think works well.
Sidenote: this takes us one step closer to delivering a "single file application" experience, at least in principle. The models.py
(and most filename conventions) don't matter much anymore, so you could start to envision the example app.py
that defines a model, router, and a couple views which make up a small but useful app.
Removed swappable models
One of the first things I did with Plain was change how the User
model works. In Django, the django.contrib.auth
package comes with a User
model, but how you go forward from there is the source of lots of debate.
My solution is simply to put the User
model in the user's code! We still have an AUTH_USER_MODEL
setting so we can refer to it across the framework (the concept of a user is a crucial thing), but otherwise it's a regular model like anything else.
# app/users/models.py
from plain import models
from plain.models.functions import Lower
from plain.passwords.models import PasswordField
@models.register_model
class User(models.Model):
email = models.EmailField()
password = PasswordField()
is_admin = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
Lower("email"),
name="unique_lower_email",
),
]
Anyway, Django has a concept of "swappable" models, and as far as I know it's really only used by for this user-modeling situation. "Swapping" a built-in User
for your own is not how Plain does it, so I removed the swappable models feature entirely.
This kind of change is mostly internal, but I mention it because it is one of those key design decisions that has a surprising number of trickle-down effects. You'd be surprised how many places AUTH_USER_MODEL
was special-cased in the migrations code.
Tailwind v4
Since Tailwind is a core element of Plain (any Plain package with templates can use Tailwind), it's important to note that we're now using Tailwind v4! I do have one workaround in place related to the .venv
gitignore behavior, but I'm hoping that will get resolved soon.
The way this works under the hood is that your app has a tailwind.css
, and we @source
the Plain packages themselves.
/* tailwind.css */
@import 'tailwindcss';
/* Add Plain packages to the sources */
@import "./.plain/tailwind.css";
/* Form styling plugin (optional) */
@plugin "@tailwindcss/forms";
/* .plain/tailwind.css */
@source "../.venv/lib/python3.12/site-packages/plain/admin";
@source "../.venv/lib/python3.12/site-packages/plain/auth";
@source "../.venv/lib/python3.12/site-packages/plain/htmx";
@source "../.venv/lib/python3.12/site-packages/plain/sessions";
@source "../.venv/lib/python3.12/site-packages/plain/models";
@source "../.venv/lib/python3.12/site-packages/plain/elements";
@source "../.venv/lib/python3.12/site-packages/plain/tailwind";
@source "../.venv/lib/python3.12/site-packages/plain/passwords";
The end result is a single stylesheet across the application that covers both your code and the installed packages you're using.
Other notable changes
- Removed a bunch of signals (not my favorite pattern...)
- Fully separated the concept of models from the core
plain.packages
registry - Renamed the
plain compile
command toplain build
- Vultured tons of dead code (mostly from
plain.utils
) - Removed support for "unmanaged" models (for now anyway)
Next steps
There are only a few more things on my list that have to change before 1.0, but the nature of working through them tends to reveal others. I really enjoy that process but I also know it could go on forever! I do think we're getting close though. Honestly the documentation is the thing I'm dreading the most — I think there's a lot of other cool stuff buried in Plain that you have no way to know about.