InputRules - Input Rules Library
Version 0.0.6 - Correctness, safety and error reporting fixes
Is an Alpha version, develop in progress, do not use it in production
[!TIP]
🚀 New in 0.0.6
📖 Read the extended release write-up →
A wider, example-driven walkthrough of everything this version brings — more detail than the summary below:
🧮 Lists items[]syntax for collections and their fields📅 Dates & times date,time,datetimein ISO 8601📏 Lengths min_length:N/max_length:Nfor strings and lists🧼 Text hygiene text,singleline,alpha,alnum,slug,nfc,strip_control🌐 Script & emoji policy latin/noemojiby default,lang="unicode"to open up⚡ validated_data()validate, clean and return in one call ⚠️ Before you upgrade behaviour changes worth reading first
Installation
pip install inputrules
| Total | Last Month | Last Week |
|---|---|---|
A robust Python library for validating and sanitizing input data from Forms, JSON, and HTTP Requests with predefined rules and filters.
Example
from inputrules import InputRules
#JSON Example
data = {
"id":100,
"data": {
"name":" Alvaro ",
"lastname":" De Leon ",
"age":35,
"email": "asdasd",
"opt":"30",
"parms":[
10,20,30,40,50,60,70,80,90,100
],
"phones": {
"name":"Zaraza",
"home":"123456",
"cell":"123456"
}
}
}
#Example of list of options
options = ['10','20','30','40','50']
o = InputRules(data)
o.rules("id","required,integer")
o.rules("data.name","required,string","trim,upper")
o.rules("data.lastname","required,string","trim,lower")
o.rules("data.age","required,integer")
o.rules("data.phone","string")
o.rules("data.email","string","b64encode")
o.rules("data.opt","options",options=options)
o.rules("data.phones.name","required,string")
if o.verify():
print("Data is valid")
data = o.data()
print(data)
How it behaves
Four rules that hold for every use of InputRules:
- Your dictionary is never modified. The constructor takes a deep copy. Whatever you
pass in is exactly what you still have after
verify(),data()or a validation failure. - Fields without a rule are dropped from the result. A field nobody declared is data
that should not be there, so it never reaches
data(). It is removed from the returned copy, not from your object. verify()comes beforedata(). Callingdata()first raisesRuntimeErrorinstead of quietly handing back unvalidated, unfiltered data.data()is idempotent. Calling it twice returns the same value, and mutating the returned structure does not change the validator's state.- Rules run first, on the value as it arrived. Filters run afterwards. A rule describes what the client is allowed to send; a filter describes what you want to store.
- Text is Western by default. Every string value is checked against
latinandnoemojiwhether or not you declare them. Open the whole validator up withInputRules(data, lang="unicode"), or one field at a time withunicode/emoji. See Restricting the alphabet.
The fifth point is worth an example, because it is the one that surprises people. options
is compared against the raw value, so a filter can change a value after its rule has
accepted it:
validator.rules("status", "required,options", "lower", options=["ACTIVE"])
# 'ACTIVE' passes the rule, and data() returns 'active'
# 'active' does NOT pass the rule: it is not in the options list as sent
The same holds for min_length and max_length: they measure the value before trim runs.
If you want the filtered form to be the one that is validated, filter upstream and declare
the rule against what you actually accept.
original = {"a": 1, "csrf_token": "..."}
o = InputRules(original)
o.rules("a", "integer")
o.verify()
o.data() # {'a': 1} only the declared fields
original # {'a': 1, 'csrf_token': '...'} untouched
What this library does not do
Validation is one layer. These belong to other layers, and assuming they are covered here is how holes are left open:
- There is no limit on the total size of the payload.
min_length:Nandmax_length:Nbound one field each; nothing here bounds the request as a whole. A 200 MB JSON body is parsed into memory beforeInputRulesever sees it, so the limit has to be set where the request arrives -client_max_body_sizein nginx,MAX_CONTENT_LENGTHin Flask, the equivalent in your framework or reverse proxy. The same goes for the number of fields and the nesting depth of the incoming JSON. - It is not a defence against SQL injection. Use parameterised queries. The
sqlfilter is deprecated; see the note further down. - It does not clean text you did not ask it to clean. A control character, a
\r\nor an overridden text direction passes through untouched unless a rule or a filter says otherwise - see Text Hygiene. Nothing is applied by default, because silently altering a value is how data gets corrupted. - It does not make untrusted HTML safe to render.
xss/htmlentitiesescape text so that it is displayed rather than executed. If you need to allow some HTML, use a real sanitiser with an allowlist. - It does not check dates against a clock or resolve time zones. See the date rules above.
- It does not authenticate or authorise anything. A field that passes every rule is still a field the caller may have had no business sending.
One instance validates one payload. If you cache an InputRules instance at module level
and share it between requests, its errors() are shared too; build one per request.
Data Validation with InputRules
InputRules provides a powerful data validation system through the InputRules class. This class allows you to validate input data in a structured way and apply filters automatically.
Importing
from inputrules import InputRules, check
The constructor
InputRules(data, lang="latin")
data- adict, or a JSON string, which is parsed. Deep copied, so your object is never modified.lang- the repertoire this validator accepts:"latin"(the default) or"unicode". See Restricting the alphabet.
Basic Usage
# Example data
data = {
"id": 100,
"name": " John ",
"email": "john@example.com",
"age": 25,
"status": "active"
}
# Create InputRules instance
validator = InputRules(data)
# Define validation rules
validator.rules("id", "required,integer")
validator.rules("name", "required,string", "trim,upper")
validator.rules("email", "required,mail")
validator.rules("age", "required,integer")
validator.rules("status", "required,options", options=["active", "inactive"])
# Validate data
if validator.verify():
print("Data is valid")
validated_data = validator.data()
print(validated_data)
else:
print("Errors found:")
for error in validator.errors():
print(f"- {error}")
Available Validation Rules
Basic Rules
required: Field is requiredstring: Must be a stringinteger: Must be an integerfloat: Must be a decimal numbernumeric: Must be a number (integer or decimal)empty: Must be empty!empty: Must not be emptynone: Must be None!none: Must not be Nonemin_length:N/max_length:N: Length limits for strings and lists
integer and numeric reject booleans: in Python True is an int, but it is not a
number anyone typed. float accepts a whole number, because JSON writes 10 and 10.0
for the same quantity. 0 is a legitimate value, so it counts as present and not empty.
Format Rules
mail: Must be a valid email addressdomain: Must be a valid domainip: Must be a valid IP addressuuid: Must be a valid UUIDoptions: Must be in a list of valid options
Text Hygiene Rules
text: No control character other than tab, newline and carriage returnsingleline: The same, and no line break eitheralpha: Letters onlyalnum: Letters and digits onlyslug: A URL slug - lowercase ASCII words joined by single hyphenslatin: Every character is in the Western repertoire. Applied by defaultnoemoji: No emoji. Applied by defaultunicode: Opts the field out of the defaultlatinemoji: Opts the field out of the defaultnoemoji
The last four are covered in Restricting the alphabet.
These refuse what is a hazard wherever the value ends up, which is the test for what belongs in an input layer. A quote is dangerous only inside a concatenated SQL literal, so it is not judged here. These are dangerous everywhere:
check.text('ana\x00rm -rf') # False - a NUL truncates the value in any C library
check.text('factura\u202etxt.exe') # False - renders as 'facturaexe.txt' to every reader
check.text('ana\ufefflopez') # False - a byte order mark in the middle of the text
check.text('ana\ud800lopez') # False - cannot even be encoded to UTF-8
check.singleline('ana\r\nInjected: header') # False - this is how a header is forged
check.text('linea1\nlinea2') # True - a line break is fine in a comment
singleline is for a value that must stay on one line: a name, a code, a reference,
anything that ends up in an HTTP header or a line of a log. text is for a comment or a
description, where line breaks are the point.
Both keep the characters real languages need. ZWJ and ZWNJ are not refused, because an emoji family and correct Persian, Hindi or Arabic text are built with them:
check.text('👨👩👧') # True
check.text('café') # True
check.text('日本語') # True
alpha and alnum are Unicode aware, so José and año2026 pass - refusing them would
be a bug, not a defence. slug is the exception: a slug is a URL component, so it is ASCII
by definition.
check.alpha('José') # True
check.alnum('año2026') # True
check.alnum('abc-123') # False - a hyphen is not a letter or a digit
check.slug('mi-articulo') # True
check.slug('MiArticulo') # False - no uppercase
check.slug('mi--articulo') # False - no doubled hyphen
check.slug('-abc') # False - no hyphen at either end
Date and Time Rules
date: Must be an ISO 8601 calendar date,YYYY-MM-DDtime: Must be an ISO 8601 time,HH:MMorHH:MM:SS, with an optional fraction of a second and an optionalZor+HH:MMoffsetdatetime: Must be a date and a time joined byT, e.g.2026-08-25T14:30:00Z
These accept ISO 8601 and nothing else, on purpose:
check.date('2026-08-25') # True
check.date('2026-02-29') # False - 2026 is not a leap year
check.date('2026-04-31') # False - April has 30 days
check.date('03/04/2026') # False - March or April, depending on the reader
check.date('2026-8-25') # False - not padded
check.time('14:30:00+02:00') # True
check.time('24:00') # False
check.time('23:59:60') # False - a leap second is not a time Python can hold
check.datetime('2026-08-25T14:30:00Z') # True
check.datetime('2026-08-25 14:30:00') # False - a space is RFC 3339, not ISO 8601
A value that passes date is a date that exists in the calendar, not merely a string
shaped like one. Two things these rules do not do:
- They do not compare against a clock. There is no
after:orbefore:, and no minimum-age rule. A birth date in the year 3000 is a valid date. - They do not resolve time zones. An offset is checked for range and kept as written;
a local time that does not exist because of a daylight-saving change (
02:30on a spring forward night) is still a well-formed time. If your application needs a specific instant, require the offset and convert it yourself withdatetime.
Available Filters
Filters are automatically applied to data after validation:
Text Filters
trimorstrip: Removes whitespace from beginning and endlower: Converts to lowercaseupper: Converts to uppercaseucfirst: First letter uppercaseucwords: First letter of each word uppercase
Conversion Filters
intorinteger: Converts to integerfloat: Converts to decimalstrorstring: Converts to string
Encoding Filters
base64orb64encode: Encodes in base64b64decode: Decodes from base64md5: MD5 checksum. Not a password hash - see the note belowurlencode: Encodes for URLurldecode: Decodes from URL
Security Filters
xssorescape: Escapes HTML characterssql: Deprecated, removed in 0.0.7. Emits aDeprecationWarning. It does not prevent SQL injection - use parameterised querieshtmlentities: Converts characters to HTML entitieshtmlspecialchars: Converts special characters to HTML entitiesstriptags: Removes HTML tags. Destructive: it removes anything between a<and a>, so it damages legitimate text as well -apply_filter('if a<b and c>d', 'striptags')returns'if ad'. Every tag remover built out of substitution has this property. To show untrusted text safely, escape it withxss/htmlentitiesinstead of trying to strip itaddslashes: Escapes quotes and backslashesstripslashes: Removes every backslash. It is not the inverse ofaddslashes: a round trip loses the backslashes that were in the original text
Text Hygiene Filters
strip_control: Removes exactly what thetextrule refuses, keeping tab, newline and carriage return so a multi-line value survives. Use it when you would rather clean than reject. It does not remove line breaks, so it is not enough on its own for a value bound for an HTTP header or a log line - use thesinglelinerule there, which refuses the value instead. Deleting a line break out of a name silently is data corruption; for a field that must be one line, rejecting is the honest answernfc: Unicode NFC normalisation. Two byte sequences that look identical must compare equal; without this, uniqueness checks, lookups and blocklists read the same word as two different valueslatin: Deletes every character outside the Western repertoire. Destructive by design - see Restricting the alphabet before using itnoemoji: Deletes emoji, whole sequences at a time, so a family emoji does not leave a stray joiner behind
apply_filter('ana\x00rm\u202e -rf', 'strip_control') # 'anarm -rf'
descompuesto = unicodedata.normalize('NFD', 'café') # 5 code points
apply_filter(descompuesto, 'nfc') # 'café', 4 code points
Format Filters
nl2br: Converts line breaks to<br>br2nl: Converts<br>to line breaksjson: Converts to JSON
Note: The serialize and unserialize filters have been removed for security reasons.
Nested Structure Validation
InputRules supports validation of nested data structures using dot notation:
data = {
"user": {
"profile": {
"name": " Maria ",
"email": "maria@example.com",
"age": 30
},
"settings": {
"theme": "dark",
"notifications": True
}
}
}
validator = InputRules(data)
# Validate nested fields
validator.rules("user.profile.name", "required,string", "trim,ucfirst")
validator.rules("user.profile.email", "required,mail")
validator.rules("user.profile.age", "required,integer")
validator.rules("user.settings.theme", "required,options", options=["light", "dark"])
validator.rules("user.settings.notifications", "required")
if validator.verify():
validated_data = validator.data()
print(validated_data)
Complete Example with Options
from inputrules import InputRules
# Form data
form_data = {
"username": " admin ",
"password": "123456",
"email": "admin@example.com",
"role": "admin",
"profile": {
"first_name": " john ",
"last_name": " doe ",
"age": 35,
"country": "US"
}
}
# Valid options
role_options = ["admin", "user", "moderator"]
country_options = ["US", "CA", "UK", "DE", "FR"]
# Create validator
validator = InputRules(form_data)
# Define rules
validator.rules("username", "required,string", "trim,lower")
validator.rules("password", "required,string") # never hash a password with md5
validator.rules("email", "required,mail")
validator.rules("role", "required,options", options=role_options)
validator.rules("profile.first_name", "required,string", "trim,ucfirst")
validator.rules("profile.last_name", "required,string", "trim,ucfirst")
validator.rules("profile.age", "required,integer")
validator.rules("profile.country", "required,options", options=country_options)
# Validate
if validator.verify():
print("✓ Valid data")
clean_data = validator.data()
print("Processed data:", clean_data)
else:
print("✗ Validation errors:")
for error in validator.errors():
print(f" - {error}")
Passwords. Do not apply the
md5filter to a password. MD5 is an unsalted, general purpose checksum that a consumer GPU brute-forces at billions of guesses per second. Store passwords with a dedicated password hash -bcrypt,argon2orscrypt- outside this library.
Lists
Add [] to a path segment to describe the items of a list:
data = {"tags": ["a", "b"], "items": [{"id": 1, "is_admin": True}]}
validator = InputRules(data)
validator.rules("tags", "required,max_length:10") # rules for the list itself
validator.rules("tags[]", "required,string") # rules for each item
validator.rules("items[].id", "required,integer") # rules for a field of each item
validator.verify()
validator.data() # {'tags': ['a', 'b'], 'items': [{'id': 1}]}
Items are reported by position: items[1].id is required. A list without a []
declaration is not validated data, so it is dropped like any other undeclared field.
Errors
Error messages carry the full path of the field, so two fields with the same name in different branches can be told apart:
validator = InputRules({"user": "not an object"})
validator.rules("user.profile.name", "required,string")
validator.verify() # False
validator.errors() # ['user must be an object']
check Class - Individual Validations
The check class provides static methods for validating individual values. It's useful for specific validations without needing to create a complete schema.
Importing
from inputrules import check
Validation Methods
Type Validation
# Validate if it's a string
check.string("text") # True
check.string(123) # False
# Validate if it's an integer
check.integer(42) # True
check.integer(3.14) # False
# Validate if it's a decimal
check.float(3.14) # True
check.float(42) # False
# Validate if it's numeric (integer or decimal)
check.numeric(42) # True
check.numeric(3.14) # True
check.numeric("text") # False
State Validation
# Validate if it's empty
check.empty("") # True
check.empty(None) # True
check.empty(0) # True
check.empty("text") # False
# Validate if it's None
check.none(None) # True
check.none("") # False
# Validate if it's NOT None
check.notnone("text") # True
check.notnone(None) # False
Format Validation
# Validate email
check.mail("user@example.com") # True
check.mail("invalid-email") # False
# Validate domain
check.domain("example.com") # True
check.domain("invalid..domain") # False
# Validate IP
check.ip("192.168.1.1") # True
check.ip("999.999.999.999") # False
# Validate UUID
check.uuid("123e4567-e89b-12d3-a456-426614174000") # True
check.uuid("invalid-uuid") # False
Options Validation
# Validate if it's in a list of options
options = ["red", "green", "blue"]
check.options("red", options) # True
check.options("yellow", options) # False
Date and Time Validation
# ISO 8601 calendar date
check.date("2026-08-25") # True
check.date("2026-02-29") # False - the day does not exist
# ISO 8601 time, offset optional
check.time("09:00") # True
check.time("14:30:00.123+02:00") # True
check.time("24:00") # False
# Date and time joined by T
check.datetime("2026-08-25T14:30:00Z") # True
check.datetime("2026-08-25 14:30:00") # False - no T
Text Hygiene Validation
# Control characters, in any destination
check.text("ana\x00rm -rf") # False - a NUL truncates the value
check.text("linea1\nlinea2") # True - a line break is fine in a comment
check.singleline("ana\r\nInjected: x") # False - this is how a header is forged
# An overridden text direction: renders as 'facturaexe.txt'
check.text("factura\u202etxt.exe") # False
# Shapes
check.alpha("José") # True
check.alnum("año2026") # True
check.slug("mi-articulo") # True
check.slug("MiArticulo") # False - a slug is lowercase ASCII
Validation with Multiple Rules
# Use multiple rules separated by commas
check.rules("john@example.com", "required,mail") # True
check.rules("", "required,string") # False
check.rules(25, "required,integer") # True
check.rules("test", "required,string,!empty") # True
check.rules("1990-05-04", "required,date") # True
Data Sanitization
check.sanitize_sql()and thesqlfilter are deprecated, and emit aDeprecationWarning. They are a blacklist of string patterns: some payloads get through and legitimate text is silently mangled. See SQL Injection for what to do instead.
Practical Examples
Registration Form Validation
from inputrules import check
def validate_registration(form_data):
errors = []
# Validate username
if not check.rules(form_data.get('username'), 'required,string,!empty'):
errors.append("Username is required and must be valid")
# Validate email
if not check.rules(form_data.get('email'), 'required,mail'):
errors.append("Email must be a valid address")
# Validate age
if not check.rules(form_data.get('age'), 'required,integer'):
errors.append("Age must be an integer")
# Validate role
valid_roles = ['admin', 'user', 'moderator']
if not check.options(form_data.get('role'), valid_roles):
errors.append("Role must be admin, user or moderator")
return len(errors) == 0, errors
# Usage
form_data = {
'username': 'john_doe',
'email': 'john@example.com',
'age': 28,
'role': 'user'
}
is_valid, errors = validate_registration(form_data)
if is_valid:
print("Valid form")
else:
print("Errors:", errors)
Configuration Validation
from inputrules import check
def validate_config(config):
"""Validates system configuration"""
# Validate database host
if not check.rules(config.get('db_host'), 'required,string,!empty'):
return False, "Database host is required"
# Validate port
port = config.get('db_port')
if not check.integer(port) or port <= 0 or port > 65535:
return False, "Port must be an integer between 1 and 65535"
# Validate admin email
admin_email = config.get('admin_email')
if not check.mail(admin_email):
return False, "Admin email is not valid"
# Validate log level
log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR']
if not check.options(config.get('log_level'), log_levels):
return False, "Log level must be DEBUG, INFO, WARNING or ERROR"
return True, "Valid configuration"
# Usage
config = {
'db_host': 'localhost',
'db_port': 3306,
'admin_email': 'admin@company.com',
'log_level': 'INFO'
}
is_valid, message = validate_config(config)
print(message)
Integration Example
from inputrules import InputRules, check
# User data
user_data = {
'name': ' John Doe ',
'email': 'john@example.com',
'age': 30,
'status': 'active'
}
# Validate data
validator = InputRules(user_data)
validator.rules("name", "required,string", "trim,ucfirst")
validator.rules("email", "required,mail")
validator.rules("age", "required,integer")
validator.rules("status", "required,options", options=["active", "inactive"])
if validator.verify():
# Valid data, process it
clean_data = validator.data()
print(f"Validated data: {clean_data}")
else:
print("Validation errors:")
for error in validator.errors():
print(f"- {error}")
Text Hygiene
There is one test for what an input layer can honestly clean:
If the harm depends on where the data ends up, it is not this layer's business. If the harm exists wherever it goes, it is.
A quote is dangerous only inside a concatenated SQL literal. In JSON, in a log, in a template, it is a quote. That is why this library does not judge it - see SQL Injection.
These are dangerous in every destination, and they are this layer's business:
| Input | Why it is a hazard anywhere |
|---|---|
ana\x00rm -rf |
A NUL truncates the value in any C library that reads it. Stored whole, read as ana |
ana\r\nInjected: header |
Forges an HTTP header, and forges a line in your log |
factura\u202etxt.exe |
Renders as facturaexe.txt to every reader. The file is still an .exe |
ana\ufefflopez |
A byte order mark inside the text, invisible and never meaningful there |
ana\ud800lopez |
A lone surrogate. Cannot even be encoded to UTF-8: it raises wherever you write it |
None of that needed to know the destination.
Reject, or clean
Two ways to deal with it, and the choice is per field:
# Refuse the value: the rules
o.rules("user", "required,string,singleline") # a name, a code, a reference
o.rules("bio", "required,string,text") # a comment: line breaks are the point
# Or clean it: the filters
o.rules("bio", "required,string", "strip_control,nfc")
singleline is for anything that must stay on one line, which is anything that ends up in
a header or a log line. text allows tab, newline and carriage return, and refuses the
rest.
strip_control does not remove line breaks. It keeps tab, newline and carriage return
by design, so that a multi-line value survives:
apply_filter("ana\r\nInjected: header", "strip_control")
# 'ana\r\nInjected: header' - unchanged
So it is not enough on its own for a value bound for a header. Use the singleline rule
there, which refuses it. That is deliberate: silently deleting a line break out of a name is
data corruption, and for a field that must be one line, rejecting is the honest answer.
Normalisation
nfc collapses the byte sequences that look identical into one:
descompuesto = unicodedata.normalize("NFD", "café") # 5 code points
apply_filter(descompuesto, "nfc") # 'café', 4 code points
Without it, a uniqueness check, a lookup and a blocklist all read the same word as two different values.
What is deliberately kept
check.text("👨👩👧") # True
check.text("café") # True
check.text("日本語") # True
check.text("نستعلیق") # True
ZWJ (\u200d) and ZWNJ (\u200c) are not refused. An emoji family and correct
Persian, Hindi and Arabic text are built with them. Rejecting the whole Cf category would
have been simpler and would break those languages: that is an internationalisation bug, not
a defence.
These rules judge control characters, not writing systems - Persian passes for the same reason English does, because neither contains one.
These are the check.* predicates, which never apply the default policy. InputRules
does, so by default it refuses 日本語 while check.text("日本語") stays True. The two
answer different questions: check.text() asks "does this carry a control character?",
InputRules also asks "is this Western text?". See
Restricting the alphabet.
alpha and alnum are Unicode aware for the same reason - José and año2026 pass.
slug is the exception, being a URL component: lowercase ASCII, single hyphens, no hyphen
at either end.
Restricting the alphabet
latin and noemoji are applied to every string value, declared or not. This library
takes Western text as the default repertoire: a field that needs another writing system, or
emoji, has to ask for it.
o = InputRules({"name": "José Ñandú"})
o.rules("name", "required,string")
o.verify() # True
o = InputRules({"name": "Дмитрий"})
o.rules("name", "required,string")
o.verify() # False - ['name is not valid']
This is a different axis from the hygiene rules above. Those refuse what is a hazard in any destination; this refuses everything outside one writing system. It is a decision about what your fields are for, not a security measure.
Opting out
For the whole validator, with lang:
InputRules(data) # 'latin' - the default
InputRules(data, lang="unicode") # every writing system and emoji
unicode also answers to utf8, utf-8 and any, and latin to western.
On the name. UTF-8 is an encoding, not a repertoire: it encodes Latin as happily as it encodes anything else -
Joséis UTF-8 too.unicodeis the accurate name for "every writing system", so that is the canonical spelling; theutf-8aliases are there because that is what people reach for.
lang lifts the default policy. It does not override a rule the field declares, so
lang="unicode" with "required,string,latin" still refuses another script, and the
hygiene rules are a separate axis that lang never touches.
Per field, with unicode and emoji:
o.rules("name", "required,string,unicode") # any writing system
o.rules("note", "required,string,emoji") # Western text plus emoji
o.rules("free", "required,string,unicode,emoji") # both
emoji works on its own: it exempts the emoji from the latin pass as well, so the field
is Western text plus emoji. It does not open the door to another script.
For the whole application, at import time:
import inputrules
inputrules.DEFAULT_RULES = ()
It has to be reached through the module. from inputrules import * does not bring it,
on purpose: a star import binds a copy of the name, so reassigning that copy would look
like it worked and change nothing.
DEFAULT_RULES defines what lang="latin" means, so the two work together: the constructor
picks the policy per instance, this changes the policy itself.
Two things the defaults never touch:
- Values that are not text. A number, a boolean and
Nonehave no alphabet to restrict, so they are left alone. - The
check.*predicates.check.string("Дмитрий")is stillTrue, and so ischeck.rules("Дмитрий", "required,string"). Those are the raw checks; the policy lives inInputRules.
What this costs
The policy is applied before anything else has a say, so it overrides the format rules too:
o.rules("mail", "required,mail")
# '用户@例子.广告' False - a valid address, refused by the alphabet policy
# 'josé@correo.es' True
mail and domain accept internationalised addresses; the default policy refuses them
anyway. If a field must accept them, declare unicode on it.
As a filter
Both are also filters, which delete instead of refusing:
o.rules("note", "required,string,unicode", "latin") # delete, do not refuse
Note the unicode on that line: it is not optional. A control character is not in the
Latin repertoire either, so the default policy refuses the value before any filter runs:
check.latin("\x00") # False - a NUL is not Western text
check.latin("\u202e") # False - nor is an overridden text direction
check.latin("\r") # True - a carriage return is ordinary text
Which gives two paths that do not mix:
- Refuse - the default. Nothing to configure.
- Clean - needs the repertoire opened first, with
lang="unicode"or theunicodetoken, or the value is refused before the filter exists for it.
o = InputRules(data, lang="unicode")
o.rules("bio", "required,string", "strip_control,nfc")
Western European text passes through untouched, punctuation included:
apply_filter("José Ñandú", "latin") # 'José Ñandú'
apply_filter("Müller & Söhne", "latin") # 'Müller & Söhne'
apply_filter("Łódź", "latin") # 'Łódź'
apply_filter("Nguyễn Văn", "latin") # 'Nguyễn Văn'
apply_filter("¿Qué? ¡Vaya!", "latin") # '¿Qué? ¡Vaya!' (not ASCII, and Spanish needs it)
apply_filter("Precio: 100€ ±5%", "latin") # 'Precio: 100€ ±5%'
Everything else is removed:
apply_filter("Дмитрий", "latin") # ''
apply_filter("李明", "latin") # ''
apply_filter("أحمد", "latin") # ''
apply_filter("١٢٣", "latin") # '' (Arabic-Indic digits)
apply_filter("👍 ok", "latin") # ' ok'
apply_filter("Ana 李明 Lopez", "latin") # 'Ana Lopez'
A decomposed accent survives, because the value is composed to NFC first: José written as
e plus a combining acute comes out as José, not Jose.
Prefer the rule over the filter. Deleting characters from a name does not produce a shorter name, it produces a different person's name or an empty string. The rule tells the client what happened; the filter does it silently.
The trap: required will not catch an empty result
Rules run on the value as it arrived, and filters run afterwards - see
How it behaves. A field that opts out of the default and then filters
can come out of data() empty:
o = InputRules({"name": "李明"})
o.rules("name", "required,string,unicode", "latin")
o.verify() # True - 'required' saw a non-empty value, the filter had not run yet
o.data() # {'name': ''}
The default policy is what saves you from this in the ordinary case: without the unicode
opt-out, the same value is refused at validation time and never reaches the filter. Prefer
refusing to deleting - a deleted name is not a shorter name, it is an empty one.
The repertoire
A character is kept when it is a Latin letter - its Unicode name begins with LATIN, which
covers every Western European language - or when it is in an explicit list of non-letters:
tab, newline, carriage return, every printable ASCII character, and the Western punctuation
beyond ASCII (¡ ¿ « » ª º ° § ¶ · ' ' " " – — … † ‡ • ‰ € £ ¥ ¢ ¤ © ® ™ ± × ÷ µ).
It is written as a list rather than derived from a clever rule, so you can read it and
extend it: _LATIN_KEEP in inputrules/__init__.py.
Cyrillic, Greek, Arabic, Hebrew, Devanagari, the CJK scripts, Arabic-Indic and fullwidth digits, and emoji are all outside it.
noemoji uses its own list, _PICTOGRAPH, matching whole sequences so that a family emoji
does not leave a stray joiner behind. (c), (R) and (TM) are not emoji: they are
text, they live below U+2600, and they survive both.
apply_filter("a👨👩👧b", "noemoji") # 'ab'
apply_filter("© 2026 Ana ™", "noemoji") # '© 2026 Ana ™'
SQL Injection
This library does not build queries, and does not try to make a value "safe for SQL".
It validates and cleans; data() is where its job ends. What happens next - a driver, an
ORM, a queue, a template - belongs to the layer that receives the data.
That is not a gap. It is where the defence actually lives:
o = InputRules(request_body)
o.rules("name", "required,string,max_length:80", "trim")
o.rules("age", "required,integer")
if o.verify():
clean = o.data()
cursor.execute(
"INSERT INTO users (name, age) VALUES (?, ?)",
(clean["name"], clean["age"]),
)
The values travel in the driver's own parameters, apart from the statement. name can hold
a quote, a semicolon, a whole DROP TABLE: the database is told the structure of the query
and the data separately, and never reads the data looking for structure.
Note what the library did not do: it did not modify the value. O'Brien reaches the
database as O'Brien. A validated value comes back byte for byte, which is precisely what
lets the caller hand it to a parameter with confidence.
See ejemplos.md for a walkthrough with real output, including the attack
succeeding against a concatenated query and failing against a parameterised one.
Why the library does not sanitise the value
Because safety is not a property of the value. 1 OR 2=2 is harmless as a parameter and
catastrophic concatenated - the same text, both times. A function that only sees the value
cannot tell the two cases apart, because what distinguishes them is not in the value; it is
in how the caller combines it with the query. The library cannot see that, so it does not
guess.
That is why the old sql filter fails in both directions at once:
check.sanitize_sql("1 UNION ALL SELECT username, password FROM users")
# unchanged - the blacklist matches 'UNION SELECT', not 'UNION ALL SELECT'
check.sanitize_sql("Precio: 10 -- 20 dolares")
# 'Precio: 10' - a legitimate sentence, destroyed
It lets the attack through and damages the data, in the same pass. check.sanitize_sql()
and the sql filter are deprecated as of 0.0.6 and emit a DeprecationWarning. They
will be removed in 0.0.7.
What the rules do cover
For a field with a closed shape, validation removes the possibility entirely, before the value reaches any query. A value that can only be an integer cannot carry SQL - not "unlikely to", cannot:
o.rules("id", "required,integer") # a real int; str(5) is '5' and nothing else
o.rules("uid", "required,uuid")
o.rules("from", "required,date")
o.rules("status", "required,options", options=["active", "inactive"])
This is a whitelist over the shape of the value, which is sound where a blacklist of attack patterns is not.
It does not extend to open text - a name, an address, a comment. In a field where
O'Brien is legitimate data, no check on the value can separate the data from an attack,
because they are the same string. For those fields the driver's parameters are the whole
defence, and they are enough.
Table and column names
No driver can parameterise an identifier, so an identifier cannot come from the client as
free text. Choose it from a list you wrote, with the options rule, and interpolate the
validated value:
o.rules("sort", "required,options", options=["name", "created_at", "price"])
o.rules("dir", "required,options", options=["ASC", "DESC"])
clean = o.data()
cursor.execute(
"SELECT * FROM products WHERE price > ? ORDER BY %s %s" % (clean["sort"], clean["dir"]),
(clean["min"],),
)
That interpolation is safe because the value is one of the strings you listed, not one the client chose.
Security Improvements
Versions 0.0.1, 0.0.2 and 0.0.3 must not be installed
They shipped an unserialize filter that called pickle.loads() on its input.
Deserialising hostile input with pickle executes arbitrary code, so any application that
routed user input through that filter had a remote code execution primitive in its
dependency tree. The filter was removed in 0.0.5. If your lockfile pins 0.0.3 or earlier,
upgrade. See SECURITY.md.
Version 0.0.6
- Validation no longer passes silently: a
requiredfield nested three levels deep, and a scalar sent where the schema expects an object, are now reported instead of skipping the whole branch - Mass assignment closed: an object or a list sent where a single-value rule is declared is rejected and dropped, instead of travelling through untouched
- Lists are validated:
items[]declares rules for the items of a list - The input dictionary is never modified, on any path, including a failed validation
data()requiresverify()and is idempotentsqlfilter andcheck.sanitize_sql()deprecated: string sanitising is not a defence against SQL injection- The README no longer suggests
md5for passwords - Hostile input cannot crash the validator: a non-string value under
mail,ip,domainoruuidis invalid data, not aTypeError ipuses the standard library: leading zeros (read as octal by many resolvers, an SSRF blacklist bypass) are rejected, and IPv6 is supported- Format rules reject a trailing newline, which allowed header injection downstream
jsontools.save()no longer destroys the destination file when given a dictionary: the content is serialised before the file is opened, and replaced atomically- Dates are validated against the calendar: the new
date,timeanddatetimerules accept ISO 8601 only, so2026-02-29and03/04/2026are rejected instead of passing as ordinary strings - Control characters, overridden text directions and lone surrogates can be rejected or
removed: the new
text,singleline,alpha,alnumandslugrules and thestrip_controlandnfcfilters. A NUL byte truncating a value, a\r\nforging an HTTP header or a line of a log, and a\u202emakingfactura.txt.exerender asfactura.exe.txtare hazards in every destination, so they belong in the input layer
Version 0.0.5
- Removed unsafe
serialize/unserializefilters: These filters used Python'spicklemodule which could execute arbitrary code with untrusted input - Improved
addslashesfilter: Now properly escapes single quotes, double quotes, and backslashes - Fixed
urlencodefilter: Removed double encoding issue
Empty Function Improvements
The empty() function now correctly handles all data types:
- Collections (lists, dicts, tuples, sets): empty if length is 0
- Booleans:
Falseis considered a valid value, not empty - Numbers:
0and0.0are not empty - zero is a legitimate quantity, price or id - Strings: empty or whitespace-only strings are considered empty
Bug Fixes in Version 0.0.5
(These were previously listed here as 0.0.4. There was no 0.0.4 release: PyPI has only ever held 0.0.1, 0.0.2, 0.0.3 and 0.0.5.)
- Fixed class variable sharing: Each
InputRulesinstance now has independent variables - Improved error handling: Better handling of missing keys in nested structures
- Enhanced
getValue()function: Now returnsNoneinstead of raising exceptions - Fixed validation schema: Better handling of nested structures and missing data
Tests
python -m unittest discover -s tests
This documentation provides a complete guide for using both InputRules and the check class, allowing you to validate and sanitize data robustly and securely.
License
This project is licensed under the GNU General Public License v3.0 - see the LICENSE file for details.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file inputrules-0.0.6.tar.gz.
File metadata
- Download URL: inputrules-0.0.6.tar.gz
- Upload date:
- Size: 91.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
79fc9cace75b0f3ef6f4f7b729e8e2c1de052bca5fabcfb7012027f1500f9978
|
|
| MD5 |
d9d4c9e5998e6c2a62c853617b932a91
|
|
| BLAKE2b-256 |
9995d8fd8a3b3fec51ddce58778d81d503ac38ae8b396548afcb4d9d68265b91
|
File details
Details for the file inputrules-0.0.6-py3-none-any.whl.
File metadata
- Download URL: inputrules-0.0.6-py3-none-any.whl
- Upload date:
- Size: 56.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.13.5
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a6bcf2ee37eeb028c7743a35f04911b6db96a8d90946ff4901e3f48fb30a34b
|
|
| MD5 |
c8888b390bd87b0ef60e77077749c678
|
|
| BLAKE2b-256 |
90e20c2d5b7d9f57a8a79b6531ec8e7c1a687d7ade8d51b996317fc0769660e3
|