Size formatting and parsing, extension classification, path-traversal-safe filenames, collision avoidance, and directory listing — for handling uploads without trusting them.
Files (sillo.helpers.files)
Section titled “Files (sillo.helpers.files)”Accepting a file upload means accepting a name, a size, and a byte stream from someone you do not trust. Each of the three is an attack surface. The name can escape your upload directory. The declared size can lie. The extension can claim to be a JPEG while the bytes are a shell script.
This module covers the mechanical parts of handling that: formatting sizes for humans, parsing size limits from configuration, classifying extensions, and — most importantly — turning a hostile filename into one that is safe to write to disk.
from sillo.helpers import filesStdlib-only, built on pathlib and mimetypes.
Formatting sizes
Section titled “Formatting sizes”format_size(bytes_value) and format_size_binary(bytes_value)
Section titled “format_size(bytes_value) and format_size_binary(bytes_value)”Turn a byte count into something a person can read:
files.format_size(0) # '0 B'files.format_size(999) # '999 B'files.format_size(1024) # '1.0 KB'files.format_size(1536000) # '1.5 MB'
files.format_size_binary(1024) # '1.0 KiB'files.format_size_binary(1536000) # '1.5 MiB'Negative values format with a sign (format_size(-1024) gives '-1.0 KB'), which is occasionally useful for showing a delta.
parse_size(size_str) -> int
Section titled “parse_size(size_str) -> int”The inverse, for reading limits out of configuration files and environment variables:
files.parse_size("10 MB") # 10485760files.parse_size("10MB") # 10485760 — space optionalfiles.parse_size("1.5 GB") # 1610612736files.parse_size("512") # 512 — bare number means bytesMalformed input raises ValueError:
files.parse_size("banana") # ValueError: Invalid size string: bananaThis is what makes size limits readable in config rather than magic numbers:
import osfrom sillo.helpers.files import parse_size, format_size
MAX_UPLOAD = parse_size(os.environ.get("MAX_UPLOAD_SIZE", "25 MB"))
@app.post("/upload")async def upload(request, response): declared = int(request.headers.get("content-length", 0)) if declared > MAX_UPLOAD: return response.json( {"error": f"File too large. Maximum is {format_size(MAX_UPLOAD)}."}, status_code=413, ) ...Parse at import time, not per request, so a malformed value crashes at boot rather than on the first upload.
Extensions and types
Section titled “Extensions and types”files.get_extension("photo.jpg") # '.jpg'files.get_extension("photo.JPG") # '.jpg' — lowercasedfiles.get_extension("noext") # ''files.get_extension_clean("archive.tar.gz") # 'gz' — no dot, last part only
files.guess_mime_type("photo.png") # 'image/png'files.guess_mime_type("weird.xyzzy") # Noneget_extension keeps the dot and lowercases. get_extension_clean drops the dot. Note that for a double extension such as .tar.gz, both return only the final component, so a .tar.gz looks like a gzip file rather than a tar archive.
Three classifiers:
files.is_dangerous_extension("script.exe") # Truefiles.is_dangerous_extension("notes.txt") # Falsefiles.is_image_extension("photo.jpg") # Truefiles.is_media_extension("song.mp3") # Trueguess_mime_type maps an extension to a type using the stdlib mimetypes table, and returns None for anything unrecognised. It never inspects content. Treat its output as a hint for setting a response header on files you already trust, never as verification of an upload.
Safe filenames
Section titled “Safe filenames”safe_filename(filename, replacement="_") -> str
Section titled “safe_filename(filename, replacement="_") -> str”The most security-relevant function in the module. It strips path separators and characters that are illegal or dangerous in a filename:
files.safe_filename("user input: file?.txt") # 'user_input__file_.txt'files.safe_filename("../../etc/passwd") # '.._.._etc_passwd'files.safe_filename("") # 'file'The path-traversal case is the one that matters. Without it, a filename of ../../etc/passwd joined to your upload directory writes outside it:
# Catastrophicpath = os.path.join("/var/uploads", request_filename)# "/var/uploads/../../etc/passwd" -> "/etc/passwd"Separators become the replacement character, so the result stays inside the directory you join it to. An empty or fully stripped name falls back to 'file' rather than returning an empty string that would resolve to the directory itself.
unique_filename(directory, filename) -> str
Section titled “unique_filename(directory, filename) -> str”Returns a name that does not already exist in the directory, appending a counter if needed:
files.unique_filename("/tmp", "log.txt") # 'log (1).txt' when log.txt existsfiles.unique_filename("/tmp", "new.txt") # 'new.txt' when it does notThe suffix goes before the extension, so the file type is preserved.
Directories and ages
Section titled “Directories and ages”ensure_directory(path) -> Path
Section titled “ensure_directory(path) -> Path”Creates a directory and any missing parents, returning the Path. Idempotent:
uploads = files.ensure_directory("/data/uploads/2026/01")Useful at startup and for date-partitioned upload trees. It does not raise if the directory already exists.
list_files(directory, pattern="*", recursive=False) -> list[Path]
Section titled “list_files(directory, pattern="*", recursive=False) -> list[Path]”Globs a directory:
files.list_files("/data") # everything, top levelfiles.list_files("/data", "*.csv") # CSVs, top levelfiles.list_files("/data", "*.csv", recursive=True) # CSVs, all subdirectoriesReturns Path objects, not strings.
file_age(path) and file_age_human(path)
Section titled “file_age(path) and file_age_human(path)”Seconds since last modification, and a human-readable version:
files.file_age("/var/log/app.log") # 10842.3files.file_age_human("/var/log/app.log") # '3h ago'The obvious use is cleanup:
from sillo.helpers.files import list_files, file_age
TWENTY_FOUR_HOURS = 24 * 60 * 60
async def purge_stale_uploads(): for path in list_files("/tmp/uploads", "*"): if file_age(path) > TWENTY_FOUR_HOURS: path.unlink(missing_ok=True)Run it from the scheduler (Scheduled Jobs) rather than in a request. Both functions call stat() and raise OSError for a missing path, so guard against a file deleted between listing and checking.
What not to do
Section titled “What not to do”Do not join a user-supplied filename to a path. Sanitize it, or better, do not use it as the stored name at all.
Do not trust an extension. It is a string the uploader chose. Check magic bytes.
Do not use is_dangerous_extension as your upload filter. It is a blocklist. Allowlist instead.
Do not trust Content-Length. Enforce the limit while streaming.
Do not serve uploads from your application’s origin. A separate domain, or Content-Disposition: attachment plus nosniff.
Do not rely on unique_filename under concurrency. It is check-then-write.
Do not call list_files or hash_file in an async handler. Blocking I/O stalls the whole event loop.
Do not display format_size output next to a cloud provider’s number. The units disagree.
Do not assume get_extension understands double extensions. .tar.gz reports as gz.
Performance
Section titled “Performance”Formatting, parsing, and extension functions are pure string arithmetic — nanoseconds, no I/O.
The filesystem functions are the ones to watch. file_age, file_age_human, and unique_filename each perform a stat(). list_files walks the directory and, with recursive=True, the entire tree beneath it. ensure_directory is a mkdir syscall. All are synchronous and block the event loop.
The rule for an async handler is simple: string functions are free, anything that touches the disk is not. Push disk work to a thread with anyio.to_thread.run_sync, or out of the request entirely with Background Tasks.
Function reference
Section titled “Function reference”| Function | Signature | Returns |
|---|---|---|
format_size | (bytes_value: int | float) | Human size, 1024-based with decimal labels |
format_size_binary | (bytes_value: int | float) | Human size with KiB/MiB labels |
parse_size | (size_str: str) | Byte count; raises ValueError |
get_extension | (filename: str) | Lowercased extension with dot, or '' |
get_extension_clean | (filename: str) | Extension without the dot |
guess_mime_type | (filename: str) | MIME type or None. Extension-based only. |
is_dangerous_extension | (filename: str) | bool. Blocklist — do not rely on it. |
is_image_extension | (filename: str) | bool |
is_media_extension | (filename: str) | bool |
safe_filename | (filename: str, replacement: str = "_") | Single-component safe name |
unique_filename | (directory: str | Path, filename: str) | Non-colliding name. Racy. |
file_age | (path: str | Path) | Seconds since mtime; raises OSError |
file_age_human | (path: str | Path) | Human string such as '3h ago' |
ensure_directory | (path: str | Path) | Path, created with parents |
list_files | (directory, pattern: str = "*", recursive: bool = False) | list[Path], eager |
Related
Section titled “Related”- File Uploads — receiving and streaming uploads safely
- Static Files — serving files, and why not from the upload directory
- Strings — generating stored filenames
- Hashing — content-addressed storage and deduplication
- Background Tasks — moving disk work out of the request