Skip to content

The field types sillo.record adds on top of Tortoise (PasswordField, the timestamp fields, SlugField and ULIDField) and exactly what each does and does not do.

Every Tortoise field type works: IntField, CharField, TextField, JSONField, ForeignKeyField and the rest. Record adds six.

from sillo.record.fields import (
PasswordField, CreatedAtField, UpdatedAtField,
SoftDeleteField, SlugField, ULIDField,
)
from sillo.record import Model
from sillo.record.fields import PasswordField
class Account(Model):
email = fields.CharField(max_length=255, unique=True)
password = PasswordField()

A CharField that hashes on the way into the database:

account.password = "correct horse battery staple"
await account.save()
# stored: $2b$12$…

Assigning plaintext through the ORM stores a hash. There is no separate step to forget, which is the point.

Verify with the helper, never by comparison:

from sillo.helpers.hashing import verify_password
if verify_password(submitted, account.password):
...

An admin built on the model layer can detect a PasswordField and render a password widget — reveal toggle, strength meter, confirmation — rather than a text input. Warder does.

It hashes with bcrypt. hash_password defaults to bcrypt, and this field uses the default. Install the extra:

Terminal window
uv add "sillo-framework[hashing-bcrypt]"

It recognises a hash from any configured scheme. Assigning a value that is already hashed stores it as-is; anything else is treated as a plaintext password and hashed. The check asks passlib which scheme produced the value, so argon2, scrypt and pbkdf2 hashes are all passed through, not just bcrypt.

from sillo.hashing import hash_password
account.password = hash_password(plaintext, scheme="argon2") # stored as given
account.password = plaintext # hashed for you

Declared on the base model, so you rarely write them yourself.

FieldWrapsBehaviour
CreatedAtFieldDatetimeField(auto_now_add=True)Set on insert, never updated
UpdatedAtFieldDatetimeField(auto_now=True)Set on every save
SoftDeleteFieldDatetimeField(null=True, default=None)None means active

They are thin: each sets one Tortoise default and adds nothing else. The value is that the intent is in the name. deleted_at = SoftDeleteField() says what a nullable datetime is for.

Use them directly when a model needs a second one:

class Invoice(Model):
approved_at = SoftDeleteField() # nullable datetime, defaults to None
from sillo.record.fields import ULIDField
from sillo.record.mixins import HasUlidMixin
class Event(Model, HasUlidMixin):
id = ULIDField()

A 26-character CharField, primary key by default. A ULID sorts by creation time as a string, which gives you a sortable identifier that does not leak a row count the way an auto-increment integer does.

class Post(Model):
slug = SlugField(max_length=200)

A CharField sized for a slug, with the intent in the name.

Pass source_field and a row saved without a slug gets one from that attribute:

class Post(Model):
title = fields.CharField(max_length=200)
slug = SlugField(source_field="title")
post = await Post.create(title="Hello World")
post.slug # "hello-world"

An explicitly assigned slug is kept. Generation only fills a blank, so editing the title later does not move a published URL.

Add unique=True and decide what happens on a collision, usually a numeric suffix.

Subclass a Tortoise field and override the two conversion hooks:

from tortoise import fields
class UpperCharField(fields.CharField):
def to_db_value(self, value, instance, *args, **kwargs):
return value.upper() if isinstance(value, str) else value
def to_python_value(self, value, *args, **kwargs):
return value

For converting values without a custom column type, casting is usually the lighter answer. It is configured per model rather than declared as a type.