Skip to content

The utility modules that ship with sillo — what each one is for, which need extra dependencies, and which have sharp edges worth reading about first.

Every backend rewrites the same utilities. Slugify a title. Hash a password. Work out the real client IP behind a load balancer. Retry a flaky API call. Format a file size. None of it is hard, all of it is fiddly, and each one has an edge case that bites in production six months later.

sillo ships eleven modules covering that ground, so you are not maintaining a utils.py that slowly accretes subtle bugs.

ModuleUse it forDependency
StringsSlugs, case conversion, secret masking, secure random tokensstdlib
TextTruncation, search excerpts, pluralization, URL/email extractionstdlib
HTMLEscaping, tag stripping, safe attributes, linkificationstdlib
HashingPassword hashing, digests, HMAC, constant-time comparisonbcrypt
CryptoSymmetric encryption, key derivation, signed valuescryptography
JWTEncoding, decoding, and verifying JSON Web Tokenspyjwt
NetworkClient IP behind proxies, address classification, subnetsstdlib
FilesSize formatting, extension checks, safe filenamesstdlib
RetryExponential backoff with jitter for transient failuresstdlib
AsyncDetecting async callables, awaitable context managersstdlib
DeprecationDeprecation warnings for functions and parametersstdlib

Eight of the eleven need nothing installed, so most of this is available the moment you install sillo. Three optional packages cover the rest, and each is only needed if you use that specific module:

Terminal window
uv add bcrypt # password hashing
uv add cryptography # encryption and key derivation
uv add pyjwt # JSON Web Tokens

Most modules live under sillo.helpers:

from sillo.helpers import strings, text, html, files, network, retry
from sillo.helpers import hashing, crypto, jwt

Or import the functions directly, which is what most codebases settle on:

from sillo.helpers.strings import slugify, random_token
from sillo.helpers.hashing import hash_password, verify_password

Four of these modules touch security directly. Every page has its own warnings section; these are the ones that most often cause real incidents.

HTMLsanitize_html is not safe for untrusted input. It is a regex scanner, not a parser, and has verified bypasses including entity-encoded and whitespace-split javascript: URLs. Use nh3 or bleach for anything a stranger typed. That page shows the exact payloads that get through.

Networkget_client_ip trusts every header by default. trusted_proxies defaults to None, which means “believe anyone”. Any client can then claim any IP, defeating rate limiting, geoblocking, and audit logs. Always pass the parameter.

Hashing — never store a password with a digest. sha256 is built to be fast, which is exactly wrong for password storage. Use hash_password. Also note bcrypt’s hard 72-byte input limit, which raises rather than truncating.

Cryptounsign_value(max_age=...) does nothing. The parameter is accepted and never read; there is no timestamp in the token format. Tokens you believe expire do not. Put the expiry inside the signed payload, or use JWT.

Some of these overlap, and choosing wrong is the usual source of bugs.

Hiding a value. Do you need it back?

Removing markup. Both html.strip_tags and text.strip_html remove tags. They are equivalent for ordinary content; use whichever module you already imported.

Shortening text. text.truncate cuts by character, text.ellipsis cuts by line, text.wrap_text reflows without cutting.

Making an identifier. strings.slugify for URLs, html.generate_safe_id for HTML anchors, files.safe_filename for disk.

Random values. All of random_string, random_digits, random_token draw from secrets, so all are safe against guessing. Pick by output shape: alphanumeric, digits-only, or URL-safe base64.

A registration endpoint touching five of these modules:

Registration, end to end
from sillo import silloApp
from sillo.helpers.hashing import hash_password
from sillo.helpers.strings import random_token, slugify, mask_email
from sillo.helpers.network import get_client_ip
app = silloApp()
TRUSTED_PROXIES = ["10.0.0.0/8"]
@app.post("/register")
async def register(request, response):
body = await request.json
# bcrypt rejects anything over 72 bytes, so cap it before hashing.
if len(body["password"].encode()) > 72:
return response.json({"error": "Password too long"}, status_code=422)
user = await User.create(
email=body["email"],
username_slug=slugify(body["display_name"]) or "user",
password=hash_password(body["password"]),
signup_ip=get_client_ip(
request.headers, request.client.host, trusted_proxies=TRUSTED_PROXIES
),
verify_token=random_token(32),
)
await send_verification_email(user)
return response.json(
{"message": f"Check {mask_email(user.email)} to confirm your account"},
status_code=201,
)

Five modules, five decisions: hash rather than encrypt the password because it never needs reading back, cap the length because bcrypt raises above 72 bytes, generate the token from secrets because a guessable verification link is an account takeover, resolve the IP through the trusted-proxy list because the raw header is forgeable, and mask the address in the response because the endpoint is unauthenticated.

Across all eleven modules, the same three misunderstandings cause most of the incidents.

Confusing “encoded” with “encrypted”. Base64 is not encryption. A JWT payload, a sign_value token, and a mask_string output are all readable by whoever holds them. Signing proves nobody changed a value; it does nothing to stop them reading it. If the holder must not know the contents, encrypt.

Trusting something the client sent. An X-Forwarded-For header, a Content-Length, a filename, a file extension, and a JWT’s unverified claims are all attacker-controlled. Each has a helper here that handles it safely, and each has a naive path that does not. The safe path always involves either a signature you verify or a list you control.

Using a fast function where a slow one is required. sha256 for password storage and == for secret comparison are the two classic cases. Both are fast, both are wrong, and both fail silently: your tests pass, your logins work, and the weakness only surfaces when someone attacks it. Use hash_password and constant_time_compare.

If you internalize only those three, most of the warnings on the individual pages follow from them.

Knowing what a utility package does not cover saves you looking for it.

Input validation. Nothing here validates a request body, coerces a query parameter, or produces a 422. That is Validation, which is Pydantic-backed and integrated with routing and OpenAPI. extract_emails finding something that looks like an address is not validation, and is_valid_ip is a format check rather than a policy decision.

Date and time handling. No parsing, formatting, or timezone conversion. Python’s datetime plus zoneinfo covers it, and for anything harder the ecosystem already has better answers than a framework should ship.

Serialization. Converting models to JSON is Serialization, which handles dataclasses, Pydantic models, Decimal, datetime, and UUIDs. Do not hand-roll it with the string helpers.

HTTP requests. Making outbound calls is the HTTP Client, which has its own retry, timeout, and connection-pooling configuration. The Retry helpers are for arbitrary callables, not specifically for HTTP.

Caching. Cache has backends, TTLs, and tag invalidation. sha256 makes a good cache key, but the cache itself lives elsewhere.

A real HTML sanitizer. As noted above, sanitize_html is not one. This is a deliberate gap rather than an oversight: correct sanitization requires a full HTML parser, and that belongs in a dedicated, widely audited library rather than a framework’s utility module.

Two patterns come up constantly.

Freeze the randomness. Functions built on secrets return something different every call, which makes assertions awkward. Assert on shape rather than value, or patch the generator:

def test_reset_token_is_long_enough():
token = random_token(32)
assert len(token) >= 40 # shape, not value
def test_reset_link(monkeypatch):
monkeypatch.setattr("myapp.auth.random_token", lambda n=64: "fixed-token")
assert build_reset_link(user) == "https://example.com/reset?t=fixed-token"

Do not hash passwords in tests you run thousands of times. hash_password costs 250 ms. A fixture that creates fifty users spends over ten seconds inside bcrypt. Hash once at module scope and reuse the string:

import pytest
from sillo.helpers.hashing import hash_password
KNOWN_HASH = hash_password("test-password") # computed once per session
@pytest.fixture
def user():
return User(email="a@example.com", password=KNOWN_HASH)

Never lower the bcrypt cost factor to speed up tests by changing production configuration — you will eventually ship the reduced value.

Pin the clock, not the helper, for time-dependent behaviour. file_age and the JWT expiry checks read the current time. Rather than patching them, use a library like freezegun or inject the timestamp, so you are testing your logic rather than your mocking.

Almost everything here is a pure in-memory string operation costing microseconds, and you can ignore the cost. Three exceptions matter. hash_password and verify_password take roughly 250 ms each and occupy a full CPU core — that is deliberate, and it caps how many logins you serve per core. derive_key costs the same for the same reason. Anything that touches the disk (hash_file, list_files, file_age) is blocking I/O and stalls the entire event loop if called directly from an async handler; push it to a thread or a background task.

A quick index by problem rather than by module, for when you know what you need and not what it is called.

I need to…FunctionPage
Turn a title into a URL segmentslugifyStrings
Store a passwordhash_passwordHashing
Check a password at loginverify_passwordHashing
Generate a session or reset tokenrandom_tokenStrings
Generate a six-digit OTPrandom_digitsStrings
Show part of an API key in a UImask_stringStrings
Confirm an email without printing itmask_emailStrings
Verify an inbound webhook signaturehmac_digest + constant_time_compareHashing
Compare two secrets safelyconstant_time_compareHashing
Deduplicate uploaded filessha256 or hash_fileHashing
Store a third-party API keyencrypt / decryptCrypto
Make a tamper-proof unsubscribe linksign_value / unsign_valueCrypto
Issue a stateless auth tokencreate_access_tokenJWT
Find the real client IPget_client_ipNetwork
Block SSRF on a user-supplied URLis_public_ipNetwork
Check an IP against an allowlistsubnet_containsNetwork
Make an upload filename safe to writesafe_filenameFiles
Read a size limit from configparse_sizeFiles
Show a file size to a userformat_size_binaryFiles
Escape user text for a pageescape_htmlHTML
Build a meta description from rich textstrip_html + truncateText
Show search results with contextexcerptText
Say “1 item” or “3 items”pluralizeText
Estimate reading timeword_countText
Survive a flaky upstream API@retryRetry
Accept a sync or async callbackis_async_callableAsync
Remove a public function safely@deprecatedDeprecation
  • Authentication — the full auth stack built on hashing and JWT
  • Rate Limiting — the main consumer of get_client_ip
  • File Uploads — where the file helpers get used
  • Validation — input validation, which these helpers do not do
  • Security — headers and hardening beyond these utilities