Metadata-Version: 2.4
Name: inputrules
Version: 0.0.6
Summary: A robust Python library for validating and sanitizing input data with predefined rules and filters
Home-page: https://github.com/alvarodeleon/inputrules
Author: Alvaro De Leon
Author-email: info@alvarodeleon.com
License: GPL-3.0-or-later
Keywords: validation,sanitization,input,forms,json,security,filters
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security
Requires-Python: >=3.6
Description-Content-Type: text/markdown
License-File: LICENSE.md
Dynamic: author
Dynamic: author-email
Dynamic: classifier
Dynamic: description
Dynamic: description-content-type
Dynamic: home-page
Dynamic: keywords
Dynamic: license
Dynamic: license-file
Dynamic: requires-python
Dynamic: summary

# 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 &nbsp;[![Release notes](https://img.shields.io/badge/Full%20write--up-0.0.6-brightgreen?style=flat-square&logo=markdown)](./UPDATE-2026-Agust.md)
>
> **[📖 Read the extended release write-up →](./UPDATE-2026-Agust.md)**
>
> 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

```bash
pip install inputrules
```
| **Total** | **Last Month** | **Last Week** |
|-----------|----------------|---------------|
| [![Downloads](https://static.pepy.tech/badge/inputrules)](https://pepy.tech/project/inputrules) | [![Downloads](https://static.pepy.tech/badge/inputrules/month)](https://pepy.tech/project/inputrules) | [![Downloads](https://static.pepy.tech/badge/inputrules/week)](https://pepy.tech/project/inputrules) |

---

A robust Python library for validating and sanitizing input data from Forms, JSON, and HTTP Requests with predefined rules and filters.

## Example
```python
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](#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:

```python
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.

```python
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](#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

```python
from inputrules import InputRules, check
```

### The constructor

```python
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](#restricting-the-alphabet).

### Basic Usage

```python
# 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](#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:

```python
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:

```python
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.

```python
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:

```python
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](#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

```python
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:

```python
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

```python
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:

```python
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:

```python
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

```python
from inputrules import check
```

### Validation Methods

#### Type Validation
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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
```python
# 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](#sql-injection) for what to
> do instead.

### Practical Examples

#### Registration Form Validation
```python
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
```python
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

```python
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](#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:

```python
# 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:

```python
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:

```python
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

```python
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](#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.

```python
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`:

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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.

```python
o = InputRules(data, lang="unicode")
o.rules("bio", "required,string", "strip_control,nfc")
```

Western European text passes through untouched, punctuation included:

```python
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:

```python
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](#how-it-behaves). A field that opts out of the default and then filters
can come out of `data()` empty:

```python
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.

```python
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:

```python
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`](https://github.com/alvarodeleon/inputrules/blob/main/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:

```python
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*:

```python
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:

```python
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

```bash
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](./LICENSE) file for details.
