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,)PasswordField
Section titled “PasswordField”from sillo.record import Modelfrom 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): ...The admin panel detects PasswordField and renders a password
widget (reveal toggle, strength meter, confirmation) rather than a text input.
Two things to know
Section titled “Two things to know”It hashes with bcrypt. hash_password defaults to bcrypt, and this field
uses the default. Install the extra:
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 givenaccount.password = plaintext # hashed for youThe timestamp fields
Section titled “The timestamp fields”Declared on the base model, so you rarely write them yourself.
| Field | Wraps | Behaviour |
|---|---|---|
CreatedAtField | DatetimeField(auto_now_add=True) | Set on insert, never updated |
UpdatedAtField | DatetimeField(auto_now=True) | Set on every save |
SoftDeleteField | DatetimeField(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 NoneULIDField
Section titled “ULIDField”from sillo.record.fields import ULIDFieldfrom 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.
SlugField
Section titled “SlugField”class Post(Model): slug = SlugField(max_length=200)A CharField sized for a slug, with the intent in the name.
Generating the slug
Section titled “Generating the slug”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.
Writing your own
Section titled “Writing your own”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 valueFor converting values without a custom column type, casting is usually the lighter answer. It is configured per model rather than declared as a type.