Skip to main content

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  Release notes

📖 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, datetime in ISO 8601
📏 Lengths min_length:N / max_length:N for strings and lists
🧼 Text hygiene text, singleline, alpha, alnum, slug, nfc, strip_control
🌐 Script & emoji policy latin / noemoji by 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
Downloads Downloads Downloads

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:

  1. 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.
  2. 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.
  3. verify() comes before data(). Calling data() first raises RuntimeError instead of quietly handing back unvalidated, unfiltered data.
  4. data() is idempotent. Calling it twice returns the same value, and mutating the returned structure does not change the validator's state.
  5. 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.
  6. Text is Western by default. Every string value is checked against latin and noemoji whether or not you declare them. Open the whole validator up with InputRules(data, lang="unicode"), or one field at a time with unicode / 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:N and max_length:N bound one field each; nothing here bounds the request as a whole. A 200 MB JSON body is parsed into memory before InputRules ever sees it, so the limit has to be set where the request arrives - client_max_body_size in nginx, MAX_CONTENT_LENGTH in 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 sql filter is deprecated; see the note further down.
  • It does not clean text you did not ask it to clean. A control character, a \r\n or 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 / htmlentities escape 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 - a dict, 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 required
  • string: Must be a string
  • integer: Must be an integer
  • float: Must be a decimal number
  • numeric: Must be a number (integer or decimal)
  • empty: Must be empty
  • !empty: Must not be empty
  • none: Must be None
  • !none: Must not be None
  • min_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 address
  • domain: Must be a valid domain
  • ip: Must be a valid IP address
  • uuid: Must be a valid UUID
  • options: Must be in a list of valid options

Text Hygiene Rules

  • text: No control character other than tab, newline and carriage return
  • singleline: The same, and no line break either
  • alpha: Letters only
  • alnum: Letters and digits only
  • slug: A URL slug - lowercase ASCII words joined by single hyphens
  • latin: Every character is in the Western repertoire. Applied by default
  • noemoji: No emoji. Applied by default
  • unicode: Opts the field out of the default latin
  • emoji: Opts the field out of the default noemoji

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-DD
  • time: Must be an ISO 8601 time, HH:MM or HH:MM:SS, with an optional fraction of a second and an optional Z or +HH:MM offset
  • datetime: Must be a date and a time joined by T, 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: or before:, 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:30 on a spring forward night) is still a well-formed time. If your application needs a specific instant, require the offset and convert it yourself with datetime.

Available Filters

Filters are automatically applied to data after validation:

Text Filters

  • trim or strip: Removes whitespace from beginning and end
  • lower: Converts to lowercase
  • upper: Converts to uppercase
  • ucfirst: First letter uppercase
  • ucwords: First letter of each word uppercase

Conversion Filters

  • int or integer: Converts to integer
  • float: Converts to decimal
  • str or string: Converts to string

Encoding Filters

  • base64 or b64encode: Encodes in base64
  • b64decode: Decodes from base64
  • md5: MD5 checksum. Not a password hash - see the note below
  • urlencode: Encodes for URL
  • urldecode: Decodes from URL

Security Filters

  • xss or escape: Escapes HTML characters
  • sql: Deprecated, removed in 0.0.7. Emits a DeprecationWarning. It does not prevent SQL injection - use parameterised queries
  • htmlentities: Converts characters to HTML entities
  • htmlspecialchars: Converts special characters to HTML entities
  • striptags: 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 with xss / htmlentities instead of trying to strip it
  • addslashes: Escapes quotes and backslashes
  • stripslashes: Removes every backslash. It is not the inverse of addslashes: a round trip loses the backslashes that were in the original text

Text Hygiene Filters

  • strip_control: Removes exactly what the text rule 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 the singleline rule 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 answer
  • nfc: 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 values
  • latin: Deletes every character outside the Western repertoire. Destructive by design - see Restricting the alphabet before using it
  • noemoji: 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 breaks
  • json: 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 md5 filter 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, argon2 or scrypt - 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 the sql filter are deprecated, and emit a DeprecationWarning. 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. unicode is the accurate name for "every writing system", so that is the canonical spelling; the utf-8 aliases 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 None have no alphabet to restrict, so they are left alone.
  • The check.* predicates. check.string("Дмитрий") is still True, and so is check.rules("Дмитрий", "required,string"). Those are the raw checks; the policy lives in InputRules.

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 the unicode token, 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 required field 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() requires verify() and is idempotent
  • sql filter and check.sanitize_sql() deprecated: string sanitising is not a defence against SQL injection
  • The README no longer suggests md5 for passwords
  • Hostile input cannot crash the validator: a non-string value under mail, ip, domain or uuid is invalid data, not a TypeError
  • ip uses 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, time and datetime rules accept ISO 8601 only, so 2026-02-29 and 03/04/2026 are 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, alnum and slug rules and the strip_control and nfc filters. A NUL byte truncating a value, a \r\n forging an HTTP header or a line of a log, and a \u202e making factura.txt.exe render as factura.exe.txt are hazards in every destination, so they belong in the input layer

Version 0.0.5

  • Removed unsafe serialize/unserialize filters: These filters used Python's pickle module which could execute arbitrary code with untrusted input
  • Improved addslashes filter: Now properly escapes single quotes, double quotes, and backslashes
  • Fixed urlencode filter: 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: False is considered a valid value, not empty
  • Numbers: 0 and 0.0 are 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 InputRules instance now has independent variables
  • Improved error handling: Better handling of missing keys in nested structures
  • Enhanced getValue() function: Now returns None instead 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

inputrules-0.0.6.tar.gz (91.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

inputrules-0.0.6-py3-none-any.whl (56.1 kB view details)

Uploaded Python 3

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

Hashes for inputrules-0.0.6.tar.gz
Algorithm Hash digest
SHA256 79fc9cace75b0f3ef6f4f7b729e8e2c1de052bca5fabcfb7012027f1500f9978
MD5 d9d4c9e5998e6c2a62c853617b932a91
BLAKE2b-256 9995d8fd8a3b3fec51ddce58778d81d503ac38ae8b396548afcb4d9d68265b91

See more details on using hashes here.

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

Hashes for inputrules-0.0.6-py3-none-any.whl
Algorithm Hash digest
SHA256 2a6bcf2ee37eeb028c7743a35f04911b6db96a8d90946ff4901e3f48fb30a34b
MD5 c8888b390bd87b0ef60e77077749c678
BLAKE2b-256 90e20c2d5b7d9f57a8a79b6531ec8e7c1a687d7ade8d51b996317fc0769660e3

See more details on using hashes here.

Release history Release notifications | RSS feed

0.0.7

2 files

This release

0.0.6 This release

2 files

0.0.5

2 files

0.0.3

2 files

0.0.2

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page