diff --git a/README.md b/README.md index 765cea5..a26ad6c 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,8 @@
- -- Fast settings management using msgspec - a high-performance validation and serialization library + High-performance settings management and validation library powered by msgspec
@@ -21,14 +19,14 @@ ## Features -- ✅ **7x faster than pydantic-settings** - High performance built on msgspec -- ✅ **Drop-in API compatibility** - Familiar interface, easy migration from pydantic-settings -- ✅ **Type-safe** - Full type hints and validation -- ✅ **17+ built-in validators** - Email, URLs, numeric constraints, payment cards, paths, and more -- ✅ **.env support** - Fast built-in .env parser (no dependencies) -- ✅ **Nested settings** - Support for complex configuration structures -- ✅ **Zero dependencies** - Only msgspec required -- ✅ **169x faster cached loads** - Smart caching for repeated access +- ⚡ **7x faster than pydantic-settings** - Built on [msgspec](https://github.com/jcrist/msgspec)'s high-performance validation +- 🎯 **26 built-in validators** - Email, URLs, IP addresses, MAC addresses, dates, storage sizes, and more +- 🔧 **Drop-in API compatibility** - Familiar interface, easy migration from pydantic-settings +- 📦 **All msgspec types supported** - Full compatibility with [msgspec's rich type system](https://jcristharif.com/msgspec/supported-types.html) +- 🔐 **Type-safe** - Complete type hints and validation +- 📁 **.env support** - Fast built-in .env parser (169x faster cached loads) +- 🎨 **Nested settings** - Support for complex configuration structures +- 🪶 **Zero dependencies** - Only msgspec required ## Installation @@ -44,224 +42,494 @@ uv add msgspec-ext ## Quick Start +### With BaseSettings (Environment Variables) + ```python -from msgspec_ext import BaseSettings, SettingsConfigDict +from msgspec_ext import BaseSettings, EmailStr, HttpUrl, PositiveInt class AppSettings(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", # Load from .env file - env_prefix="APP_" # Prefix for env vars - ) - - # Settings with type validation + # Basic types (msgspec native support) name: str debug: bool = False - port: int = 8000 - timeout: float = 30.0 + + # Numeric validators + port: PositiveInt = 8000 # Must be > 0 + workers: PositiveInt = 4 + + # String validators + admin_email: EmailStr # RFC 5321 validation + api_url: HttpUrl # HTTP/HTTPS only # Load from environment variables and .env file settings = AppSettings() -print(settings.name) # from APP_NAME env var -print(settings.port) # from APP_PORT env var or default 8000 +print(settings.name) # my-app +print(settings.port) # 8000 +print(settings.admin_email) # admin@example.com + +# Serialize to dict +print(settings.model_dump()) +# Output: { +# 'name': 'my-app', +# 'debug': False, +# 'port': 8000, +# 'workers': 4, +# 'admin_email': 'admin@example.com', +# 'api_url': 'https://api.example.com' +# } + +# Serialize to JSON +print(settings.model_dump_json()) +# Output: '{"name":"my-app","debug":false,"port":8000,"workers":4,"admin_email":"admin@example.com","api_url":"https://api.example.com"}' ``` -## Environment Variables - -By default, msgspec-ext looks for environment variables matching field names (case-insensitive). - -**.env file**: +Set environment variables: ```bash -APP_NAME=my-app -DEBUG=true -PORT=3000 -DATABASE__HOST=localhost -DATABASE__PORT=5432 +export NAME="my-app" +export ADMIN_EMAIL="admin@example.com" +export API_URL="https://api.example.com" ``` -**Python code**: +### With msgspec Structs (Direct Usage) + +All validators work directly with msgspec structs for JSON/MessagePack serialization: + ```python -from msgspec_ext import BaseSettings, SettingsConfigDict +import msgspec +from msgspec_ext import EmailStr, IPv4Address, ByteSize, PositiveInt, dec_hook, enc_hook + +class ServerConfig(msgspec.Struct): + host: IPv4Address + port: PositiveInt + admin_email: EmailStr + max_upload: ByteSize + +# From JSON (use dec_hook for custom type conversion) +config = msgspec.json.decode( + b'{"host":"192.168.1.100","port":8080,"admin_email":"admin@example.com","max_upload":"50MB"}', + type=ServerConfig, + dec_hook=dec_hook +) + +print(config.host) # 192.168.1.100 +print(int(config.max_upload)) # 50000000 (50MB in bytes) + +# To JSON (use enc_hook to serialize custom types) +json_bytes = msgspec.json.encode(config, enc_hook=enc_hook) +``` -class AppSettings(BaseSettings): - model_config = SettingsConfigDict( - env_file=".env", - env_nested_delimiter="__" - ) +## Type Support - app_name: str - debug: bool = False - database_host: str = "localhost" - database_port: int = 5432 +msgspec-ext supports **all msgspec native types** plus **26 additional validators** for common use cases. -settings = AppSettings() -# Automatically loads from .env file and environment variables -``` +### Built-in msgspec Types -## Advanced Usage +msgspec-ext has full compatibility with [msgspec's extensive type system](https://jcristharif.com/msgspec/supported-types.html): + +- **Basic**: `bool`, `int`, `float`, `str`, `bytes`, `bytearray` +- **Collections**: `list`, `tuple`, `set`, `frozenset`, `dict` +- **Typing**: `Optional`, `Union`, `Literal`, `Final`, `Annotated` +- **Advanced**: `datetime`, `date`, `time`, `timedelta`, `UUID`, `Decimal` +- **msgspec**: `msgspec.Raw`, `msgspec.UNSET` (re-exported for convenience) -### Field Validators +Plus many more - see the [full list in msgspec documentation](https://jcristharif.com/msgspec/supported-types.html). -msgspec-ext provides 17+ built-in validator types for common use cases: +### Custom Validators (26 types) -#### Numeric Constraints +msgspec-ext adds **26 specialized validators** for common validation scenarios: + +#### 🔢 Numeric Constraints (8 types) ```python -from msgspec_ext import BaseSettings, PositiveInt, NonNegativeInt +from msgspec_ext import ( + PositiveInt, NegativeInt, NonNegativeInt, NonPositiveInt, + PositiveFloat, NegativeFloat, NonNegativeFloat, NonPositiveFloat +) class ServerSettings(BaseSettings): port: PositiveInt # Must be > 0 - max_connections: PositiveInt - retry_count: NonNegativeInt # Can be 0 + offset: NegativeInt # Must be < 0 + retry_count: NonNegativeInt # Can be 0 or positive + balance: NonPositiveFloat # Can be 0 or negative ``` -**Available numeric types:** -- `PositiveInt`, `NegativeInt`, `NonNegativeInt`, `NonPositiveInt` -- `PositiveFloat`, `NegativeFloat`, `NonNegativeFloat`, `NonPositiveFloat` +#### 🌐 Network & Hardware (4 types) -#### String Validators +```python +import msgspec +from msgspec_ext import IPv4Address, IPv6Address, IPvAnyAddress, MacAddress + +# With BaseSettings +class NetworkSettings(BaseSettings): + server_ipv4: IPv4Address # 192.168.1.1 + server_ipv6: IPv6Address # 2001:db8::1 + proxy_ip: IPvAnyAddress # Accepts IPv4 or IPv6 + device_mac: MacAddress # AA:BB:CC:DD:EE:FF + +# Or with msgspec.Struct for API responses +class Device(msgspec.Struct): + name: str + ip: IPv4Address + mac: MacAddress + +device = msgspec.json.decode( + b'{"name":"router-01","ip":"192.168.1.1","mac":"AA:BB:CC:DD:EE:FF"}', + type=Device, + dec_hook=dec_hook +) +``` + +#### ✉️ String Validators (4 types) ```python -from msgspec_ext import BaseSettings, EmailStr, HttpUrl, SecretStr +from msgspec_ext import EmailStr, HttpUrl, AnyUrl, SecretStr class AppSettings(BaseSettings): admin_email: EmailStr # RFC 5321 validation api_url: HttpUrl # HTTP/HTTPS only - api_key: SecretStr # Masked in logs/output + webhook_url: AnyUrl # Any valid URL scheme + api_key: SecretStr # Masked in logs: ********** ``` -**Available string types:** -- `EmailStr` - Email validation (RFC 5321) -- `HttpUrl` - HTTP/HTTPS URLs only -- `AnyUrl` - Any valid URL scheme -- `SecretStr` - Masks sensitive data in output - -#### Database & Cache Validators +#### 🗄️ Database & Connections (3 types) ```python -from msgspec_ext import BaseSettings, PostgresDsn, RedisDsn +from msgspec_ext import PostgresDsn, RedisDsn, PaymentCardNumber class ConnectionSettings(BaseSettings): database_url: PostgresDsn # postgresql://user:pass@host/db cache_url: RedisDsn # redis://localhost:6379 + card_number: PaymentCardNumber # Luhn validation + masking ``` -#### Payment Card Validation +#### 📁 Path Validators (2 types) ```python -from msgspec_ext import BaseSettings, PaymentCardNumber +from msgspec_ext import FilePath, DirectoryPath -class PaymentSettings(BaseSettings): - card: PaymentCardNumber # Luhn algorithm + masking +class PathSettings(BaseSettings): + config_file: FilePath # Must exist and be a file + data_dir: DirectoryPath # Must exist and be a directory ``` -**Features:** -- Validates using Luhn algorithm -- Automatically strips spaces/dashes -- Masks card number in repr (shows last 4 digits only) +#### 💾 Storage & Dates (3 types) + +```python +import msgspec +from msgspec_ext import ByteSize, PastDate, FutureDate +from datetime import date + +# With BaseSettings +class AppSettings(BaseSettings): + max_upload: ByteSize # Parse "10MB", "1GB", etc. + cache_size: ByteSize # Supports KB, MB, GB, KiB, MiB, GiB + founding_date: PastDate # Must be before today + launch_date: FutureDate # Must be after today + +# Or with msgspec.Struct for configuration files +class StorageConfig(msgspec.Struct): + max_file_size: ByteSize + cache_limit: ByteSize + cleanup_after: int # days + +config = msgspec.json.decode( + b'{"max_file_size":"100MB","cache_limit":"5GB","cleanup_after":30}', + type=StorageConfig, + dec_hook=dec_hook +) +print(int(config.max_file_size)) # 100000000 +``` -#### Path Validators +#### 🎯 Constrained Strings (2 types) ```python -from msgspec_ext import BaseSettings, FilePath, DirectoryPath +from msgspec_ext import ConStr -class PathSettings(BaseSettings): - config_file: FilePath # Must exist and be a file - data_dir: DirectoryPath # Must exist and be a directory +class UserSettings(BaseSettings): + # With constraints + username: ConStr # Can use min_length, max_length, pattern + +# Usage: +username = ConStr("alice", min_length=3, max_length=20, pattern=r"^[a-z0-9]+$") ``` -**Complete validator list:** - -| Validator | Description | -|-----------|-------------| -| `PositiveInt` | Integer > 0 | -| `NegativeInt` | Integer < 0 | -| `NonNegativeInt` | Integer ≥ 0 | -| `NonPositiveInt` | Integer ≤ 0 | -| `PositiveFloat` | Float > 0.0 | -| `NegativeFloat` | Float < 0.0 | -| `NonNegativeFloat` | Float ≥ 0.0 | -| `NonPositiveFloat` | Float ≤ 0.0 | -| `EmailStr` | Email address (RFC 5321) | -| `HttpUrl` | HTTP/HTTPS URL | -| `AnyUrl` | Any valid URL | -| `SecretStr` | Masked sensitive data | -| `PostgresDsn` | PostgreSQL connection string | -| `RedisDsn` | Redis connection string | -| `PaymentCardNumber` | Credit card with Luhn validation | -| `FilePath` | Existing file path | -| `DirectoryPath` | Existing directory path | - -See `examples/06_validators.py` for complete examples. +### Complete Validator List -### Nested Configuration +| Category | Validators | +|----------|-----------| +| **Numeric** | `PositiveInt`, `NegativeInt`, `NonNegativeInt`, `NonPositiveInt`, `PositiveFloat`, `NegativeFloat`, `NonNegativeFloat`, `NonPositiveFloat` | +| **Network** | `IPv4Address`, `IPv6Address`, `IPvAnyAddress`, `MacAddress` | +| **String** | `EmailStr`, `HttpUrl`, `AnyUrl`, `SecretStr` | +| **Database** | `PostgresDsn`, `RedisDsn`, `PaymentCardNumber` | +| **Paths** | `FilePath`, `DirectoryPath` | +| **Storage & Dates** | `ByteSize`, `PastDate`, `FutureDate` | +| **Constrained** | `ConStr` | + +See `examples/06_validators.py` and `examples/07_advanced_validators.py` for complete usage examples. + +## Use Cases + +### API Request/Response Validation + +```python +import msgspec +from msgspec_ext import EmailStr, HttpUrl, PositiveInt, ByteSize, dec_hook, enc_hook + +class CreateUserRequest(msgspec.Struct): + email: EmailStr + age: PositiveInt + website: HttpUrl + max_storage: ByteSize + +class UserResponse(msgspec.Struct): + id: int + email: EmailStr + website: HttpUrl + +# Validate incoming JSON +request = msgspec.json.decode( + b'{"email":"user@example.com","age":25,"website":"https://example.com","max_storage":"1GB"}', + type=CreateUserRequest, + dec_hook=dec_hook +) + +print(request.email) # user@example.com +print(request.age) # 25 +print(int(request.max_storage)) # 1000000000 + +# Serialize response +response = UserResponse(id=1, email=request.email, website=request.website) +json_bytes = msgspec.json.encode(response, enc_hook=enc_hook) +print(json_bytes) +# b'{"id":1,"email":"user@example.com","website":"https://example.com"}' +``` + +### Configuration Files with Validation + +```python +import msgspec +from msgspec_ext import IPv4Address, PositiveInt, PostgresDsn, ByteSize, dec_hook + +class ServerConfig(msgspec.Struct): + host: IPv4Address + port: PositiveInt + database_url: PostgresDsn + max_upload: ByteSize + workers: PositiveInt = 4 + +# Load from JSON config file +with open("config.json", "rb") as f: + config = msgspec.json.decode(f.read(), type=ServerConfig, dec_hook=dec_hook) + +print(f"Server: {config.host}:{config.port}") +# Server: 192.168.1.50:8080 + +print(f"Max upload: {int(config.max_upload)} bytes") +# Max upload: 100000000 bytes + +print(f"Workers: {config.workers}") +# Workers: 4 +``` + +### Message Queue Data Validation + +```python +import msgspec +from msgspec_ext import EmailStr, IPvAnyAddress, FutureDate, dec_hook, enc_hook + +class ScheduledTask(msgspec.Struct): + task_id: str + notify_email: EmailStr + target_server: IPvAnyAddress + execute_at: FutureDate + +# Serialize for queue (MessagePack is faster than JSON) +task = ScheduledTask( + task_id="task-123", + notify_email=EmailStr("admin@example.com"), + target_server=IPvAnyAddress("192.168.1.100"), + execute_at=FutureDate("2025-12-31") +) +msg_bytes = msgspec.msgpack.encode(task, enc_hook=enc_hook) + +# Deserialize from queue +received_task = msgspec.msgpack.decode(msg_bytes, type=ScheduledTask, dec_hook=dec_hook) +``` + +## Advanced Usage + +### Environment Variables & .env Files ```python from msgspec_ext import BaseSettings, SettingsConfigDict +class AppSettings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", # Load from .env file + env_prefix="APP_", # Prefix for env vars + env_nested_delimiter="__" # Nested config separator + ) + + name: str + debug: bool = False + port: int = 8000 + +# Loads from APP_NAME, APP_DEBUG, APP_PORT +settings = AppSettings() +``` + +**.env file**: +```bash +APP_NAME=my-app +APP_DEBUG=true +APP_PORT=3000 +APP_DATABASE__HOST=localhost +APP_DATABASE__PORT=5432 +``` + +### Nested Configuration + +```python +from msgspec_ext import BaseSettings, SettingsConfigDict, PostgresDsn + class DatabaseSettings(BaseSettings): host: str = "localhost" port: int = 5432 name: str = "myapp" - user: str = "postgres" - password: str = "" + url: PostgresDsn class AppSettings(BaseSettings): model_config = SettingsConfigDict( env_file=".env", env_nested_delimiter="__" ) - + name: str = "My App" debug: bool = False database: DatabaseSettings -# Loads nested config from DATABASE__HOST, DATABASE__PORT, etc. +# Loads from DATABASE__HOST, DATABASE__PORT, DATABASE__URL, etc. settings = AppSettings() -print(settings.database.host) # from DATABASE__HOST env var + +print(settings.name) # My App +print(settings.database.host) # localhost +print(settings.database.port) # 5432 + +# Full nested dump +print(settings.model_dump()) +# Output: { +# 'name': 'My App', +# 'debug': False, +# 'database': { +# 'host': 'localhost', +# 'port': 5432, +# 'name': 'myapp', +# 'url': 'postgresql://user:pass@localhost:5432/myapp' +# } +# } ``` -### Custom Validation +### Secret Masking ```python -from msgspec_ext import BaseSettings, SettingsConfigDict -from typing import Literal +from msgspec_ext import BaseSettings, SecretStr class AppSettings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env") - - # Custom validation with enums - environment: Literal["development", "staging", "production"] = "development" - - # JSON parsing from environment variables + api_key: SecretStr + db_password: SecretStr + +settings = AppSettings() + +print(settings.api_key) # ********** +print(settings.api_key.get_secret_value()) # actual-secret-key + +print(settings.model_dump()) +# Output: {'api_key': '**********', 'db_password': '**********'} + +print(settings.model_dump_json()) +# Output: '{"api_key":"**********","db_password":"**********"}' +``` + +### Storage Size Parsing + +```python +from msgspec_ext import BaseSettings, ByteSize + +class StorageSettings(BaseSettings): + max_upload: ByteSize + cache_limit: ByteSize + +# Environment variables: +# MAX_UPLOAD=10MB +# CACHE_LIMIT=1GB + +settings = StorageSettings() +print(int(settings.max_upload)) # 10000000 (10 MB in bytes) +print(int(settings.cache_limit)) # 1000000000 (1 GB in bytes) + +print(settings.model_dump()) +# Output: {'max_upload': 10000000, 'cache_limit': 1000000000} +``` + +Supported units: `B`, `KB`, `MB`, `GB`, `TB`, `KiB`, `MiB`, `GiB`, `TiB` + +### Date Validation + +```python +from msgspec_ext import BaseSettings, PastDate, FutureDate +from datetime import date, timedelta + +class EventSettings(BaseSettings): + founding_date: PastDate # Must be before today + launch_date: FutureDate # Must be after today + +# Environment variables: +# FOUNDING_DATE=2020-01-01 +# LAUNCH_DATE=2025-12-31 + +settings = EventSettings() +``` + +### JSON Parsing from Environment + +```python +from msgspec_ext import BaseSettings + +class AppSettings(BaseSettings): + # Automatically parse JSON from environment variables features: list[str] = ["auth", "api"] - limits: dict[str, int] = {"requests": 100, "timeout": 30} + limits: dict[str, int] = {"requests": 100} + config: dict[str, any] = {} + +# Environment variable: +# FEATURES=["auth","api","payments"] +# LIMITS={"requests":1000,"timeout":30} settings = AppSettings() -print(settings.features) # Automatically parsed from JSON string +print(settings.features) # ['auth', 'api', 'payments'] ``` ## Why Choose msgspec-ext? -msgspec-ext provides a **faster, lighter alternative** to pydantic-settings while maintaining a familiar API and full type safety. +msgspec-ext provides a **faster, lighter alternative** to pydantic-settings while offering **more validators** and maintaining a familiar API. -### Performance Comparison (Google Colab Results) +### Performance Comparison -**Cold start** (first load, includes .env parsing) - *Benchmarked on Google Colab*: +**Cold start** (first load, includes .env parsing): | Library | Time per load | Speed | |---------|---------------|-------| | **msgspec-ext** | **0.353ms** | **7.0x faster** ⚡ | | pydantic-settings | 2.47ms | Baseline | -**Warm (cached)** (repeated loads in long-running applications) - *Benchmarked on Google Colab*: +**Warm (cached)** (repeated loads in long-running applications): | Library | Time per load | Speed | |---------|---------------|-------| | **msgspec-ext** | **0.011ms** | **169x faster** ⚡ | | pydantic-settings | 1.86ms | Baseline | -> *Benchmark executed on Google Colab includes .env file parsing, environment variable loading, type validation, and nested configuration. Run `benchmark/benchmark_cold_warm.py` on Google Colab to reproduce these results.* +> *Benchmarks run on Google Colab. Includes .env parsing, environment variable loading, type validation, and nested configuration. Run `benchmark/benchmark_cold_warm.py` to reproduce.* ### Key Advantages @@ -269,28 +537,40 @@ msgspec-ext provides a **faster, lighter alternative** to pydantic-settings whil |---------|------------|-------------------| | **Cold start** | **7.0x faster** ⚡ | Baseline | | **Warm (cached)** | **169x faster** ⚡ | Baseline | +| **Validators** | **26 built-in** | ~15 | | **Package size** | **0.49 MB** | 1.95 MB | | **Dependencies** | **1 (msgspec only)** | 5+ | -| .env support | ✅ Built-in | ✅ Via python-dotenv | -| Type validation | ✅ | ✅ | -| Advanced caching | ✅ | ❌ | +| .env support | ✅ Built-in fast parser | ✅ Via python-dotenv | +| Type validation | ✅ msgspec C backend | ✅ Pydantic | +| Advanced caching | ✅ 169x faster | ❌ | | Nested config | ✅ | ✅ | | JSON Schema | ✅ | ✅ | -| Secret masking | ⚠️ Planned | ✅ | ### How is it so fast? -msgspec-ext achieves its performance through: -- **Bulk validation**: Validates all fields at once in C (via msgspec), not one-by-one in Python -- **Custom .env parser**: Built-in fast parser with zero external dependencies (no python-dotenv overhead) -- **Smart caching**: Caches .env files, field mappings, and type information - loads after the first are 169x faster -- **Optimized file operations**: Uses fast os.path operations instead of slower pathlib alternatives -- **Zero overhead**: Fast paths for common types (str, bool, int, float) with minimal Python code +msgspec-ext achieves exceptional performance through: + +1. **Bulk validation**: Validates all fields at once in C (via msgspec), not one-by-one in Python +2. **Custom .env parser**: Built-in fast parser with zero external dependencies (117.5x faster than pydantic) +3. **Smart caching**: Caches .env files, field mappings, and type information - subsequent loads are 169x faster +4. **Zero overhead**: Fast paths for common types with minimal Python code + +This means: +- 🚀 **CLI tools** - 7.0x faster startup every invocation +- ⚡ **Serverless functions** - Lower cold start latency +- 🔄 **Long-running apps** - Reloading settings takes only 11 microseconds after first load! -This means your application **starts faster** and uses **less memory**, especially important for: -- 🚀 **CLI tools** - 7.0x faster startup every time you run the command -- ⚡ **Serverless functions** - Lower cold start latency means better response times -- 🔄 **Long-running apps** - After the first load, reloading settings is 169x faster (11 microseconds!) +## Examples + +Check out the `examples/` directory for comprehensive examples: + +- `01_basic_usage.py` - Getting started with BaseSettings +- `02_env_prefix.py` - Using environment variable prefixes +- `03_dotenv_file.py` - Loading from .env files +- `04_advanced_types.py` - Optional, lists, dicts, JSON parsing +- `05_serialization.py` - model_dump(), model_dump_json(), schema() +- `06_validators.py` - String, numeric, path, and database validators (17 types) +- `07_advanced_validators.py` - Network, storage, and date validators (8 types) ## Contributing @@ -298,4 +578,8 @@ We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guid ## License -MIT License - see [LICENSE](LICENSE) file for details. \ No newline at end of file +MIT License - see [LICENSE](LICENSE) file for details. + +## Acknowledgments + +Built on top of the amazing [msgspec](https://github.com/jcrist/msgspec) library by [@jcrist](https://github.com/jcrist). diff --git a/examples/07_advanced_validators.py b/examples/07_advanced_validators.py new file mode 100644 index 0000000..8a58ffd --- /dev/null +++ b/examples/07_advanced_validators.py @@ -0,0 +1,270 @@ +"""Example demonstrating advanced validators. + +This example shows advanced validators: +- IPv4Address, IPv6Address, IPvAnyAddress for IP validation +- MacAddress for MAC address validation +- ConStr for constrained strings +- ByteSize for storage size parsing +- PastDate, FutureDate for date validation +""" + +import os +from datetime import date, timedelta + +from msgspec_ext import ( + BaseSettings, + ByteSize, + ConStr, + FutureDate, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, + PastDate, + SettingsConfigDict, +) + + +# Example 1: IP Address validation +class NetworkSettings(BaseSettings): + """Settings with IP address validation.""" + + model_config = SettingsConfigDict(env_prefix="NET_") + + server_ipv4: IPv4Address + server_ipv6: IPv6Address + proxy_ip: IPvAnyAddress # Accepts both IPv4 and IPv6 + + +# Example 2: MAC Address validation +class DeviceSettings(BaseSettings): + """Settings with MAC address validation.""" + + model_config = SettingsConfigDict(env_prefix="DEVICE_") + + primary_mac: MacAddress + backup_mac: MacAddress + + +# Example 3: Constrained String validation +class UsernameSettings(BaseSettings): + """Settings with constrained strings.""" + + model_config = SettingsConfigDict(env_prefix="USER_") + + username: ConStr # Can add min_length, max_length, pattern constraints + + +# Example 4: ByteSize validation +class StorageSettings(BaseSettings): + """Settings with storage size validation.""" + + model_config = SettingsConfigDict(env_prefix="STORAGE_") + + max_file_size: ByteSize + cache_size: ByteSize + upload_limit: ByteSize + + +# Example 5: Date validation +class EventSettings(BaseSettings): + """Settings with date validation.""" + + model_config = SettingsConfigDict(env_prefix="EVENT_") + + launch_date: FutureDate # Must be in the future + founding_date: PastDate # Must be in the past + + +# Example 6: Combined advanced validators +class AppSettings(BaseSettings): + """Real-world app settings with advanced validators.""" + + # Network + api_server: IPv4Address + dns_server: IPvAnyAddress + + # MAC Address + server_mac: MacAddress + + # Storage + max_upload: ByteSize + cache_limit: ByteSize + + # Dates + release_date: FutureDate + + +def main(): # noqa: PLR0915 + print("=" * 60) + print("msgspec-ext Advanced Validators Demo") + print("=" * 60) + + # Example 1: IP Address validation + print("\n1. IP Address Validation") + print("-" * 60) + + os.environ.update( + { + "NET_SERVER_IPV4": "192.168.1.100", + "NET_SERVER_IPV6": "2001:db8::1", + "NET_PROXY_IP": "10.0.0.1", # Can be IPv4 or IPv6 + } + ) + + net_settings = NetworkSettings() + print(f"Server IPv4: {net_settings.server_ipv4}") + print(f"Server IPv6: {net_settings.server_ipv6}") + print(f"Proxy IP: {net_settings.proxy_ip}") + + # Try invalid IPv4 + try: + IPv4Address("256.1.1.1") + except ValueError as e: + print(f"✓ IPv4 validation works: {e}") + + # Try invalid IPv6 + try: + IPv6Address("gggg::1") + except ValueError as e: + print(f"✓ IPv6 validation works: {e}") + + # Example 2: MAC Address validation + print("\n2. MAC Address Validation") + print("-" * 60) + + os.environ.update( + { + "DEVICE_PRIMARY_MAC": "00:1B:44:11:3A:B7", + "DEVICE_BACKUP_MAC": "001B.4411.3AB7", # Different format + } + ) + + device_settings = DeviceSettings() + print(f"Primary MAC: {device_settings.primary_mac}") + print(f"Backup MAC: {device_settings.backup_mac}") + + # Try invalid MAC + try: + MacAddress("GG:1B:44:11:3A:B7") + except ValueError as e: + print(f"✓ MAC validation works: {e}") + + # Example 3: Constrained String + print("\n3. Constrained String Validation") + print("-" * 60) + + # ConStr with no constraints + username1 = ConStr("john_doe") + print(f"Username (no constraints): {username1}") + + # ConStr with min/max length + username2 = ConStr("alice", min_length=3, max_length=20) + print(f"Username (with length): {username2}") + + # ConStr with pattern + username3 = ConStr("bob123", pattern=r"^[a-z0-9]+$") + print(f"Username (with pattern): {username3}") + + # Try too short + try: + ConStr("ab", min_length=5) + except ValueError as e: + print(f"✓ Min length validation works: {e}") + + # Try pattern mismatch + try: + ConStr("ABC", pattern=r"^[a-z]+$") + except ValueError as e: + print(f"✓ Pattern validation works: {e}") + + # Example 4: ByteSize validation + print("\n4. Byte Size Validation") + print("-" * 60) + + os.environ.update( + { + "STORAGE_MAX_FILE_SIZE": "10MB", + "STORAGE_CACHE_SIZE": "500MB", + "STORAGE_UPLOAD_LIMIT": "1GB", + } + ) + + storage_settings = StorageSettings() + print(f"Max File Size: {storage_settings.max_file_size} bytes = 10MB") + print(f"Cache Size: {storage_settings.cache_size} bytes = 500MB") + print(f"Upload Limit: {storage_settings.upload_limit} bytes = 1GB") + + # Different units + print(f"\n1KB = {ByteSize('1KB')} bytes") + print(f"1MB = {ByteSize('1MB')} bytes") + print(f"1GB = {ByteSize('1GB')} bytes") + print(f"1KiB = {ByteSize('1KiB')} bytes (binary)") + print(f"1MiB = {ByteSize('1MiB')} bytes (binary)") + + # Try invalid size + try: + ByteSize("100XB") + except ValueError as e: + print(f"✓ ByteSize validation works: {e}") + + # Example 5: Date validation + print("\n5. Past/Future Date Validation") + print("-" * 60) + + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + tomorrow = date.today() + timedelta(days=1) # noqa: DTZ011 + + os.environ.update( + { + "EVENT_FOUNDING_DATE": yesterday.isoformat(), + "EVENT_LAUNCH_DATE": tomorrow.isoformat(), + } + ) + + event_settings = EventSettings() + print(f"Founding Date (past): {event_settings.founding_date}") + print(f"Launch Date (future): {event_settings.launch_date}") + + # Try future date as past (invalid) + try: + PastDate(tomorrow) + except ValueError as e: + print(f"✓ PastDate validation works: {e}") + + # Try past date as future (invalid) + try: + FutureDate(yesterday) + except ValueError as e: + print(f"✓ FutureDate validation works: {e}") + + # Example 6: Real-World Combined validators + print("\n6. Real-World App Settings") + print("-" * 60) + + os.environ.update( + { + "API_SERVER": "192.168.1.50", + "DNS_SERVER": "8.8.8.8", + "SERVER_MAC": "AA:BB:CC:DD:EE:FF", + "MAX_UPLOAD": "50MB", + "CACHE_LIMIT": "1GB", + "RELEASE_DATE": tomorrow.isoformat(), + } + ) + + app_settings = AppSettings() + print(f"API Server: {app_settings.api_server}") + print(f"DNS Server: {app_settings.dns_server}") + print(f"Server MAC: {app_settings.server_mac}") + print(f"Max Upload: {app_settings.max_upload} bytes") + print(f"Cache Limit: {app_settings.cache_limit} bytes") + print(f"Release Date: {app_settings.release_date}") + + print("\n" + "=" * 60) + print("All advanced validator examples completed successfully!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 1c013bc..7a5b38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "msgspec-ext" dynamic = ["version"] -description = "Fast settings management using msgspec" +description = "High-performance settings management and validation library extending msgspec" readme = "README.md" license = "MIT" authors = [ @@ -15,10 +15,24 @@ requires-python = ">=3.10" dependencies = [ "msgspec>=0.19.0", ] +keywords = [ + "msgspec", + "settings", + "configuration", + "validation", + "validators", + "pydantic", + "environment-variables", + "dotenv", + "type-validation", + "fast", + "performance" +] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Intended Audience :: Science/Research", + "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX :: Linux", @@ -29,7 +43,9 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules" + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Systems Administration", + "Typing :: Typed" ] [tool.hatch.version] @@ -101,6 +117,7 @@ ignore = [ "TID252", # Allow relative imports "UP007", # Allow `from typing import Optional` instead of `X | None` "UP035", # Allow `from typing import Sequence` instead of `Sequence[X]` + "UP038", # Allow isinstance with tuple instead of `X | Y` "PLR0911", # Allow more than 6 return statements "PLR0913", "B904", # Allow raise in except without from @@ -132,3 +149,4 @@ ban-relative-imports = "all" "examples/**/*" = ["D", "S101", "S104", "S105", "T201", "F401"] "benchmark.py" = ["D", "S101", "S105", "T201", "PLC0415", "F841", "C901", "PLR0915"] "benchmark/**/*" = ["D", "S101", "S104", "S105", "T201", "F401", "S603", "S607", "PLC0415", "ARG001"] +"test_readme_examples.py" = ["D", "S101", "T201", "PLR2004"] diff --git a/src/msgspec_ext/__init__.py b/src/msgspec_ext/__init__.py index 37a3dc0..ce25982 100644 --- a/src/msgspec_ext/__init__.py +++ b/src/msgspec_ext/__init__.py @@ -1,16 +1,26 @@ -from .settings import BaseSettings, SettingsConfigDict +import msgspec + +from .settings import BaseSettings, SettingsConfigDict, _dec_hook, _enc_hook from .types import ( AnyUrl, + ByteSize, + ConStr, DirectoryPath, EmailStr, FilePath, + FutureDate, HttpUrl, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, NegativeFloat, NegativeInt, NonNegativeFloat, NonNegativeInt, NonPositiveFloat, NonPositiveInt, + PastDate, PaymentCardNumber, PositiveFloat, PositiveInt, @@ -19,24 +29,44 @@ SecretStr, ) +# Re-export useful msgspec native types for convenience +Raw = msgspec.Raw +UNSET = msgspec.UNSET + +# Re-export hooks with public names +dec_hook = _dec_hook +enc_hook = _enc_hook + __all__ = [ + "UNSET", "AnyUrl", "BaseSettings", + "ByteSize", + "ConStr", "DirectoryPath", "EmailStr", "FilePath", + "FutureDate", "HttpUrl", + "IPv4Address", + "IPv6Address", + "IPvAnyAddress", + "MacAddress", "NegativeFloat", "NegativeInt", "NonNegativeFloat", "NonNegativeInt", "NonPositiveFloat", "NonPositiveInt", + "PastDate", "PaymentCardNumber", "PositiveFloat", "PositiveInt", "PostgresDsn", + "Raw", "RedisDsn", "SecretStr", "SettingsConfigDict", + "dec_hook", + "enc_hook", ] diff --git a/src/msgspec_ext/settings.py b/src/msgspec_ext/settings.py index 639a894..0b2435a 100644 --- a/src/msgspec_ext/settings.py +++ b/src/msgspec_ext/settings.py @@ -8,10 +8,17 @@ from msgspec_ext.fast_dotenv import load_dotenv from msgspec_ext.types import ( AnyUrl, + ByteSize, DirectoryPath, EmailStr, FilePath, + FutureDate, HttpUrl, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, + PastDate, PaymentCardNumber, PostgresDsn, RedisDsn, @@ -47,15 +54,78 @@ def _dec_hook(typ: type, obj: Any) -> Any: PaymentCardNumber, FilePath, DirectoryPath, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, ) if typ in custom_types: if isinstance(obj, str): return typ(obj) + # Handle ByteSize (accepts str or int) + if typ is ByteSize: + return ByteSize(obj) + + # Handle date types (PastDate, FutureDate) + if typ in (PastDate, FutureDate): + return typ(obj) + + # Handle ConStr (string with constraints) - but needs special handling + # ConStr requires additional parameters, so it can't be used directly in dec_hook + # Users should use it manually or with custom validators + # If we don't handle it, let msgspec raise an error raise NotImplementedError(f"Type {typ} unsupported in dec_hook") +def _enc_hook(obj: Any) -> Any: + """Encoding hook for custom types. + + Handles conversion from custom types to JSON-serializable values. + + Args: + obj: The object to encode + + Returns: + JSON-serializable representation of obj + + Raises: + NotImplementedError: If type is not supported + """ + # Convert all our custom string-based types to str + custom_types = ( + EmailStr, + HttpUrl, + AnyUrl, + SecretStr, + PostgresDsn, + RedisDsn, + PaymentCardNumber, + FilePath, + DirectoryPath, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, + ) + if isinstance(obj, custom_types): + return str(obj) + + # Convert ByteSize to int + if isinstance(obj, ByteSize): + return int(obj) + + # Convert date types to ISO format string + if isinstance(obj, (PastDate, FutureDate)): + return obj.isoformat() + + # If we don't handle it, let msgspec raise an error + raise NotImplementedError( + f"Encoding objects of type {type(obj).__name__} is unsupported" + ) + + class SettingsConfigDict(msgspec.Struct): """Configuration options for BaseSettings.""" diff --git a/src/msgspec_ext/types.py b/src/msgspec_ext/types.py index 67e633d..df6ea36 100644 --- a/src/msgspec_ext/types.py +++ b/src/msgspec_ext/types.py @@ -12,24 +12,34 @@ class AppSettings(BaseSettings): max_connections: PositiveInt """ +import ipaddress import os import re -from typing import Annotated +from datetime import date, datetime +from typing import Annotated, ClassVar import msgspec __all__ = [ "AnyUrl", + "ByteSize", + "ConStr", "DirectoryPath", "EmailStr", "FilePath", + "FutureDate", "HttpUrl", + "IPv4Address", + "IPv6Address", + "IPvAnyAddress", + "MacAddress", "NegativeFloat", "NegativeInt", "NonNegativeFloat", "NonNegativeInt", "NonPositiveFloat", "NonPositiveInt", + "PastDate", "PaymentCardNumber", "PositiveFloat", "PositiveInt", @@ -496,6 +506,380 @@ def __repr__(self) -> str: return f"DirectoryPath({str.__repr__(self)})" +# ============================================================================== +# IP Address Validation Types +# ============================================================================== + + +class _IPv4Address(str): + """IPv4 address validation. + + Validates IPv4 addresses (e.g., 192.168.1.1). + """ + + __slots__ = () + + def __new__(cls, value: str) -> "_IPv4Address": + """Create and validate IPv4 address. + + Args: + value: IPv4 address string + + Returns: + Validated IPv4 address + + Raises: + ValueError: If address format is invalid + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + + value = value.strip() + + try: + # Validate using ipaddress module + addr = ipaddress.IPv4Address(value) + return str.__new__(cls, str(addr)) + except (ValueError, ipaddress.AddressValueError) as e: + raise ValueError(f"Invalid IPv4 address: {value!r}") from e + + def __repr__(self) -> str: + return f"IPv4Address({str.__repr__(self)})" + + +class _IPv6Address(str): + """IPv6 address validation. + + Validates IPv6 addresses (e.g., 2001:0db8:85a3::8a2e:0370:7334). + """ + + __slots__ = () + + def __new__(cls, value: str) -> "_IPv6Address": + """Create and validate IPv6 address. + + Args: + value: IPv6 address string + + Returns: + Validated IPv6 address + + Raises: + ValueError: If address format is invalid + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + + value = value.strip() + + try: + # Validate using ipaddress module + addr = ipaddress.IPv6Address(value) + return str.__new__(cls, str(addr)) + except (ValueError, ipaddress.AddressValueError) as e: + raise ValueError(f"Invalid IPv6 address: {value!r}") from e + + def __repr__(self) -> str: + return f"IPv6Address({str.__repr__(self)})" + + +class _IPvAnyAddress(str): + """IP address validation (IPv4 or IPv6). + + Validates both IPv4 and IPv6 addresses. + """ + + __slots__ = () + + def __new__(cls, value: str) -> "_IPvAnyAddress": + """Create and validate IP address. + + Args: + value: IP address string (IPv4 or IPv6) + + Returns: + Validated IP address + + Raises: + ValueError: If address format is invalid + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + + value = value.strip() + + try: + # Validate using ipaddress module (accepts both IPv4 and IPv6) + addr = ipaddress.ip_address(value) + return str.__new__(cls, str(addr)) + except (ValueError, ipaddress.AddressValueError) as e: + raise ValueError(f"Invalid IP address: {value!r}") from e + + def __repr__(self) -> str: + return f"IPvAnyAddress({str.__repr__(self)})" + + +# ============================================================================== +# JSON and Special String Types +# ============================================================================== + + +class _MacAddress(str): + """MAC address validation. + + Validates MAC addresses in common formats: + - 00:1B:44:11:3A:B7 + - 00-1B-44-11-3A-B7 + - 001B.4411.3AB7 + """ + + __slots__ = () + + # MAC address patterns + _MAC_PATTERNS: ClassVar[list] = [ + re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"), # 00:1B:44:11:3A:B7 + re.compile(r"^([0-9A-Fa-f]{4}\.){2}([0-9A-Fa-f]{4})$"), # 001B.4411.3AB7 + ] + + def __new__(cls, value: str) -> "_MacAddress": + """Create and validate MAC address. + + Args: + value: MAC address string + + Returns: + Validated MAC address + + Raises: + ValueError: If MAC address format is invalid + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + + value = value.strip() + + # Check against patterns + if not any(pattern.match(value) for pattern in cls._MAC_PATTERNS): + raise ValueError(f"Invalid MAC address format: {value!r}") + + return str.__new__(cls, value.upper()) + + def __repr__(self) -> str: + return f"MacAddress({str.__repr__(self)})" + + +class _ConStr(str): + """Constrained string with validation. + + String with optional min_length, max_length, and pattern constraints. + """ + + __slots__ = ("_max_length", "_min_length", "_pattern") + + def __new__( + cls, + value: str, + min_length: int | None = None, + max_length: int | None = None, + pattern: str | None = None, + ) -> "_ConStr": + """Create and validate constrained string. + + Args: + value: String value + min_length: Minimum length (optional) + max_length: Maximum length (optional) + pattern: Regex pattern (optional) + + Returns: + Validated constrained string + + Raises: + ValueError: If constraints are violated + """ + if not isinstance(value, str): + raise TypeError(f"Expected str, got {type(value).__name__}") + + # Check min_length + if min_length is not None and len(value) < min_length: + raise ValueError(f"String must be at least {min_length} characters") + + # Check max_length + if max_length is not None and len(value) > max_length: + raise ValueError(f"String must be at most {max_length} characters") + + # Check pattern + if pattern is not None and not re.match(pattern, value): + raise ValueError(f"String must match pattern: {pattern!r}") + + instance = str.__new__(cls, value) + # Store constraints (though they're not used after validation) + object.__setattr__(instance, "_min_length", min_length) + object.__setattr__(instance, "_max_length", max_length) + object.__setattr__(instance, "_pattern", pattern) + return instance + + def __repr__(self) -> str: + return f"ConStr({str.__repr__(self)})" + + +# ============================================================================== +# Byte Size Type +# ============================================================================== + + +class _ByteSize(int): + """Byte size with unit parsing. + + Accepts sizes with units: B, KB, MB, GB, TB, KiB, MiB, GiB, TiB. + """ + + __slots__ = () + + # Size multipliers + _UNITS: ClassVar[dict[str, int]] = { + "B": 1, + "KB": 1000, + "MB": 1000**2, + "GB": 1000**3, + "TB": 1000**4, + "KIB": 1024, + "MIB": 1024**2, + "GIB": 1024**3, + "TIB": 1024**4, + } + + def __new__(cls, value: str | int) -> "_ByteSize": + """Create and validate byte size. + + Args: + value: Size as int (bytes) or str with unit (e.g., "1MB", "500KB") + + Returns: + Validated byte size (as int) + + Raises: + ValueError: If format is invalid + """ + if isinstance(value, int): + if value < 0: + raise ValueError("Byte size must be non-negative") + return int.__new__(cls, value) + + if not isinstance(value, str): + raise TypeError(f"Expected str or int, got {type(value).__name__}") + + value = value.strip().upper() + + # Try to parse number + unit + match = re.match(r"^(\d+(?:\.\d+)?)\s*([A-Z]+)?$", value) + if not match: + raise ValueError(f"Invalid byte size format: {value!r}") + + number_str, unit = match.groups() + number = float(number_str) + + if unit is None or unit == "B": + bytes_value = int(number) + elif unit in cls._UNITS: + bytes_value = int(number * cls._UNITS[unit]) + else: + raise ValueError(f"Unknown unit: {unit!r}") + + if bytes_value < 0: + raise ValueError("Byte size must be non-negative") + + return int.__new__(cls, bytes_value) + + def __repr__(self) -> str: + return f"ByteSize({int.__repr__(self)})" + + +# ============================================================================== +# Date Validation Types +# ============================================================================== + + +class _PastDate(date): + """Date that must be in the past. + + Validates that the date is before today. + """ + + __slots__ = () + + def __new__(cls, value: date | str) -> "_PastDate": + """Create and validate past date. + + Args: + value: Date object or ISO format string (YYYY-MM-DD) + + Returns: + Validated past date + + Raises: + ValueError: If date is not in the past + """ + if isinstance(value, str): + try: + parsed_date = datetime.fromisoformat(value).date() + except ValueError as e: + raise ValueError(f"Invalid date format: {value!r}") from e + elif isinstance(value, date): + parsed_date = value + else: + raise TypeError(f"Expected date or str, got {type(value).__name__}") + + today = date.today() # noqa: DTZ011 + if parsed_date >= today: + raise ValueError(f"Date must be in the past: {parsed_date}") + + return date.__new__(cls, parsed_date.year, parsed_date.month, parsed_date.day) + + def __repr__(self) -> str: + return f"PastDate({date.__repr__(self)})" + + +class _FutureDate(date): + """Date that must be in the future. + + Validates that the date is after today. + """ + + __slots__ = () + + def __new__(cls, value: date | str) -> "_FutureDate": + """Create and validate future date. + + Args: + value: Date object or ISO format string (YYYY-MM-DD) + + Returns: + Validated future date + + Raises: + ValueError: If date is not in the future + """ + if isinstance(value, str): + try: + parsed_date = datetime.fromisoformat(value).date() + except ValueError as e: + raise ValueError(f"Invalid date format: {value!r}") from e + elif isinstance(value, date): + parsed_date = value + else: + raise TypeError(f"Expected date or str, got {type(value).__name__}") + + today = date.today() # noqa: DTZ011 + if parsed_date <= today: + raise ValueError(f"Date must be in the future: {parsed_date}") + + return date.__new__(cls, parsed_date.year, parsed_date.month, parsed_date.day) + + def __repr__(self) -> str: + return f"FutureDate({date.__repr__(self)})" + + # Export as type aliases for better DX EmailStr = _EmailStr HttpUrl = _HttpUrl @@ -506,3 +890,11 @@ def __repr__(self) -> str: PaymentCardNumber = _PaymentCardNumber FilePath = _FilePath DirectoryPath = _DirectoryPath +IPv4Address = _IPv4Address +IPv6Address = _IPv6Address +IPvAnyAddress = _IPvAnyAddress +MacAddress = _MacAddress +ConStr = _ConStr +ByteSize = _ByteSize +PastDate = _PastDate +FutureDate = _FutureDate diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..6416f11 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,310 @@ +"""Integration tests for validators with BaseSettings. + +Tests that all custom validator types work correctly when used with BaseSettings, +ensuring proper environment variable parsing and validation. +""" + +import os +import tempfile +from datetime import date, timedelta + +import pytest + +from msgspec_ext import ( + AnyUrl, + BaseSettings, + ByteSize, + DirectoryPath, + EmailStr, + FilePath, + FutureDate, + HttpUrl, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, + NegativeFloat, + NegativeInt, + NonNegativeFloat, + NonNegativeInt, + NonPositiveFloat, + NonPositiveInt, + PastDate, + PaymentCardNumber, + PositiveFloat, + PositiveInt, + PostgresDsn, + RedisDsn, + SecretStr, + SettingsConfigDict, +) + + +class TestNumericTypesIntegration: + """Integration tests for numeric validators with BaseSettings.""" + + def test_positive_int_from_env(self): + """PositiveInt should work with environment variables.""" + + class Settings(BaseSettings): + port: PositiveInt + + os.environ["PORT"] = "8080" + settings = Settings() + assert settings.port == 8080 + + def test_negative_int_from_env(self): + """NegativeInt should work with environment variables.""" + + class Settings(BaseSettings): + offset: NegativeInt + + os.environ["OFFSET"] = "-10" + settings = Settings() + assert settings.offset == -10 + + def test_positive_float_from_env(self): + """PositiveFloat should work with environment variables.""" + + class Settings(BaseSettings): + rate: PositiveFloat + + os.environ["RATE"] = "1.5" + settings = Settings() + assert settings.rate == 1.5 + + +class TestStringValidatorsIntegration: + """Integration tests for string validators with BaseSettings.""" + + def test_email_from_env(self): + """EmailStr should validate from environment variables.""" + + class Settings(BaseSettings): + email: EmailStr + + os.environ["EMAIL"] = "admin@example.com" + settings = Settings() + assert str(settings.email) == "admin@example.com" + + def test_http_url_from_env(self): + """HttpUrl should validate from environment variables.""" + + class Settings(BaseSettings): + api_url: HttpUrl + + os.environ["API_URL"] = "https://api.example.com" + settings = Settings() + assert str(settings.api_url) == "https://api.example.com" + + def test_secret_str_from_env(self): + """SecretStr should mask value when printed.""" + + class Settings(BaseSettings): + api_key: SecretStr + + os.environ["API_KEY"] = "secret123" + settings = Settings() + assert str(settings.api_key) == "**********" + assert settings.api_key.get_secret_value() == "secret123" + + +class TestDatabaseDsnIntegration: + """Integration tests for DSN validators with BaseSettings.""" + + def test_postgres_dsn_from_env(self): + """PostgresDsn should validate from environment variables.""" + + class Settings(BaseSettings): + database_url: PostgresDsn + + os.environ["DATABASE_URL"] = "postgresql://user:pass@localhost:5432/db" + settings = Settings() + assert str(settings.database_url).startswith("postgresql://") + + def test_redis_dsn_from_env(self): + """RedisDsn should validate from environment variables.""" + + class Settings(BaseSettings): + redis_url: RedisDsn + + os.environ["REDIS_URL"] = "redis://localhost:6379/0" + settings = Settings() + assert str(settings.redis_url).startswith("redis://") + + +class TestPathValidatorsIntegration: + """Integration tests for path validators with BaseSettings.""" + + def test_file_path_from_env(self, tmp_path): + """FilePath should validate existing files from env.""" + + class Settings(BaseSettings): + config_file: FilePath + + test_file = tmp_path / "config.txt" + test_file.write_text("test") + + os.environ["CONFIG_FILE"] = str(test_file) + settings = Settings() + assert str(settings.config_file) == str(test_file) + + def test_directory_path_from_env(self, tmp_path): + """DirectoryPath should validate existing directories from env.""" + + class Settings(BaseSettings): + data_dir: DirectoryPath + + os.environ["DATA_DIR"] = str(tmp_path) + settings = Settings() + assert str(settings.data_dir) == str(tmp_path) + + +class TestNetworkValidatorsIntegration: + """Integration tests for network validators with BaseSettings.""" + + def test_ipv4_from_env(self): + """IPv4Address should validate from environment variables.""" + + class Settings(BaseSettings): + server_ip: IPv4Address + + os.environ["SERVER_IP"] = "192.168.1.100" + settings = Settings() + assert str(settings.server_ip) == "192.168.1.100" + + def test_ipv6_from_env(self): + """IPv6Address should validate from environment variables.""" + + class Settings(BaseSettings): + server_ipv6: IPv6Address + + os.environ["SERVER_IPV6"] = "::1" + settings = Settings() + assert str(settings.server_ipv6) == "::1" + + def test_ipvany_from_env(self): + """IPvAnyAddress should accept both IPv4 and IPv6.""" + + class Settings(BaseSettings): + proxy_ip: IPvAnyAddress + + # Test IPv4 + os.environ["PROXY_IP"] = "10.0.0.1" + settings = Settings() + assert str(settings.proxy_ip) == "10.0.0.1" + + # Test IPv6 + os.environ["PROXY_IP"] = "2001:db8::1" + settings = Settings() + assert str(settings.proxy_ip) == "2001:db8::1" + + def test_mac_address_from_env(self): + """MacAddress should validate from environment variables.""" + + class Settings(BaseSettings): + device_mac: MacAddress + + os.environ["DEVICE_MAC"] = "AA:BB:CC:DD:EE:FF" + settings = Settings() + assert str(settings.device_mac) == "AA:BB:CC:DD:EE:FF" + + +class TestStorageAndDateValidatorsIntegration: + """Integration tests for storage and date validators with BaseSettings.""" + + def test_bytesize_from_env(self): + """ByteSize should parse storage units from environment variables.""" + + class Settings(BaseSettings): + max_upload: ByteSize + cache_size: ByteSize + + os.environ["MAX_UPLOAD"] = "10MB" + os.environ["CACHE_SIZE"] = "1GB" + settings = Settings() + assert int(settings.max_upload) == 10 * 1000**2 + assert int(settings.cache_size) == 1000**3 + + def test_past_date_from_env(self): + """PastDate should validate from environment variables.""" + + class Settings(BaseSettings): + founding_date: PastDate + + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + os.environ["FOUNDING_DATE"] = yesterday.isoformat() + settings = Settings() + assert settings.founding_date == yesterday + + def test_future_date_from_env(self): + """FutureDate should validate from environment variables.""" + + class Settings(BaseSettings): + launch_date: FutureDate + + tomorrow = date.today() + timedelta(days=1) # noqa: DTZ011 + os.environ["LAUNCH_DATE"] = tomorrow.isoformat() + settings = Settings() + assert settings.launch_date == tomorrow + + +class TestCompleteIntegration: + """Integration test with all validator types combined.""" + + def test_all_validators_together(self, tmp_path): + """All validators should work together in one settings class.""" + + class AppSettings(BaseSettings): + # Numeric + port: PositiveInt + max_connections: NonNegativeInt + timeout: PositiveFloat + + # String validators + admin_email: EmailStr + api_url: HttpUrl + api_key: SecretStr + + # Network + server_ip: IPv4Address + device_mac: MacAddress + + # Storage & Dates + max_upload: ByteSize + founding_date: PastDate + + # Create temp file + test_file = tmp_path / "test.txt" + test_file.write_text("test") + + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + + os.environ.update( + { + "PORT": "8000", + "MAX_CONNECTIONS": "100", + "TIMEOUT": "30.5", + "ADMIN_EMAIL": "admin@example.com", + "API_URL": "https://api.example.com", + "API_KEY": "secret123", + "SERVER_IP": "192.168.1.1", + "DEVICE_MAC": "AA:BB:CC:DD:EE:FF", + "MAX_UPLOAD": "50MB", + "FOUNDING_DATE": yesterday.isoformat(), + } + ) + + settings = AppSettings() + + # Verify all fields + assert settings.port == 8000 + assert settings.max_connections == 100 + assert settings.timeout == 30.5 + assert str(settings.admin_email) == "admin@example.com" + assert str(settings.api_url) == "https://api.example.com" + assert settings.api_key.get_secret_value() == "secret123" + assert str(settings.server_ip) == "192.168.1.1" + assert str(settings.device_mac) == "AA:BB:CC:DD:EE:FF" + assert int(settings.max_upload) == 50 * 1000**2 + assert settings.founding_date == yesterday diff --git a/tests/test_types.py b/tests/test_types.py index baa4a0f..39bd96e 100644 --- a/tests/test_types.py +++ b/tests/test_types.py @@ -1,19 +1,29 @@ """Tests for custom types and validators in msgspec_ext.types.""" +from datetime import date, timedelta + import pytest from msgspec_ext.types import ( AnyUrl, + ByteSize, + ConStr, DirectoryPath, EmailStr, FilePath, + FutureDate, HttpUrl, + IPv4Address, + IPv6Address, + IPvAnyAddress, + MacAddress, NegativeFloat, NegativeInt, NonNegativeFloat, NonNegativeInt, NonPositiveFloat, NonPositiveInt, + PastDate, PaymentCardNumber, PositiveFloat, PositiveInt, @@ -679,3 +689,416 @@ def test_directory_type_error(self): """Should reject non-string inputs.""" with pytest.raises(TypeError): DirectoryPath(123) # type: ignore + + +# ============================================================================== +# IPv4Address Tests +# ============================================================================== + + +class TestIPv4Address: + """Tests for IPv4Address type.""" + + def test_valid_ipv4_addresses(self): + """Should accept valid IPv4 addresses.""" + valid_ips = [ + "192.168.1.1", + "10.0.0.1", + "172.16.0.1", + "0.0.0.0", + "255.255.255.255", + ] + for ip in valid_ips: + result = IPv4Address(ip) + assert str(result) == ip + + def test_ipv4_strips_whitespace(self): + """Should strip leading/trailing whitespace.""" + result = IPv4Address(" 192.168.1.1 ") + assert str(result) == "192.168.1.1" + + def test_reject_invalid_ipv4(self): + """Should reject invalid IPv4 addresses.""" + invalid_ips = [ + "256.1.1.1", # Out of range + "192.168.1", # Too few octets + "192.168.1.1.1", # Too many octets + "192.168.1.a", # Non-numeric + "not-an-ip", + ] + for ip in invalid_ips: + with pytest.raises(ValueError, match="Invalid IPv4 address"): + IPv4Address(ip) + + def test_reject_ipv6(self): + """Should reject IPv6 addresses.""" + with pytest.raises(ValueError): + IPv4Address("::1") + + def test_ipv4_type_error(self): + """Should reject non-string inputs.""" + with pytest.raises(TypeError): + IPv4Address(123) # type: ignore + + +# ============================================================================== +# IPv6Address Tests +# ============================================================================== + + +class TestIPv6Address: + """Tests for IPv6Address type.""" + + def test_valid_ipv6_addresses(self): + """Should accept valid IPv6 addresses.""" + valid_ips = [ + "::1", # Loopback + "2001:db8::1", + "fe80::1", + "2001:0db8:0000:0000:0000:0000:0000:0001", + "::", # All zeros + ] + for ip in valid_ips: + result = IPv6Address(ip) + # IPv6 addresses may be normalized + assert isinstance(result, str) + + def test_ipv6_strips_whitespace(self): + """Should strip leading/trailing whitespace.""" + result = IPv6Address(" ::1 ") + assert str(result) == "::1" + + def test_reject_invalid_ipv6(self): + """Should reject invalid IPv6 addresses.""" + invalid_ips = [ + "gggg::1", # Invalid hex + "::1::2", # Multiple :: + "not-an-ip", + ] + for ip in invalid_ips: + with pytest.raises(ValueError, match="Invalid IPv6 address"): + IPv6Address(ip) + + def test_reject_ipv4(self): + """Should reject IPv4 addresses.""" + with pytest.raises(ValueError): + IPv6Address("192.168.1.1") + + def test_ipv6_type_error(self): + """Should reject non-string inputs.""" + with pytest.raises(TypeError): + IPv6Address(123) # type: ignore + + +# ============================================================================== +# IPvAnyAddress Tests +# ============================================================================== + + +class TestIPvAnyAddress: + """Tests for IPvAnyAddress type.""" + + def test_valid_ipv4_addresses(self): + """Should accept valid IPv4 addresses.""" + valid_ips = ["192.168.1.1", "10.0.0.1", "127.0.0.1"] + for ip in valid_ips: + result = IPvAnyAddress(ip) + assert str(result) == ip + + def test_valid_ipv6_addresses(self): + """Should accept valid IPv6 addresses.""" + valid_ips = ["::1", "2001:db8::1", "fe80::1"] + for ip in valid_ips: + result = IPvAnyAddress(ip) + assert isinstance(result, str) + + def test_ip_strips_whitespace(self): + """Should strip leading/trailing whitespace.""" + result = IPvAnyAddress(" 192.168.1.1 ") + assert str(result) == "192.168.1.1" + + def test_reject_invalid_ip(self): + """Should reject invalid IP addresses.""" + invalid_ips = [ + "256.1.1.1", + "not-an-ip", + "192.168.1", + "", + ] + for ip in invalid_ips: + with pytest.raises(ValueError, match="Invalid IP address"): + IPvAnyAddress(ip) + + def test_ip_type_error(self): + """Should reject non-string inputs.""" + with pytest.raises(TypeError): + IPvAnyAddress(123) # type: ignore + + +# ============================================================================== +# MacAddress Tests +# ============================================================================== + + +class TestMacAddress: + """Tests for MacAddress type.""" + + def test_valid_mac_colon_format(self): + """Should accept MAC addresses in colon format.""" + valid_macs = [ + "00:1B:44:11:3A:B7", + "00-1B-44-11-3A-B7", # Also with dashes + ] + for mac in valid_macs: + result = MacAddress(mac) + # Should be uppercase + assert str(result).upper() == str(result) + + def test_valid_mac_dot_format(self): + """Should accept MAC addresses in dot format.""" + result = MacAddress("001B.4411.3AB7") + assert str(result).upper() == str(result) + + def test_mac_uppercase_conversion(self): + """Should convert MAC to uppercase.""" + result = MacAddress("aa:bb:cc:dd:ee:ff") + assert str(result) == "AA:BB:CC:DD:EE:FF" + + def test_reject_invalid_mac(self): + """Should reject invalid MAC addresses.""" + invalid_macs = [ + "00:1B:44:11:3A", # Too short + "00:1B:44:11:3A:B7:C8", # Too long + "GG:1B:44:11:3A:B7", # Invalid hex + "not-a-mac", + ] + for mac in invalid_macs: + with pytest.raises(ValueError, match="Invalid MAC address"): + MacAddress(mac) + + def test_mac_type_error(self): + """Should reject non-string inputs.""" + with pytest.raises(TypeError): + MacAddress(123) # type: ignore + + +# ============================================================================== +# ConStr Tests +# ============================================================================== + + +class TestConStr: + """Tests for ConStr type.""" + + def test_constr_no_constraints(self): + """Should accept any string with no constraints.""" + result = ConStr("any string") + assert str(result) == "any string" + + def test_constr_min_length(self): + """Should enforce minimum length.""" + result = ConStr("hello", min_length=3) + assert str(result) == "hello" + + with pytest.raises(ValueError, match="at least"): + ConStr("hi", min_length=5) + + def test_constr_max_length(self): + """Should enforce maximum length.""" + result = ConStr("hi", max_length=5) + assert str(result) == "hi" + + with pytest.raises(ValueError, match="at most"): + ConStr("too long", max_length=3) + + def test_constr_pattern(self): + """Should enforce regex pattern.""" + result = ConStr("abc123", pattern=r"^[a-z0-9]+$") + assert str(result) == "abc123" + + with pytest.raises(ValueError, match="must match pattern"): + ConStr("ABC", pattern=r"^[a-z]+$") + + def test_constr_all_constraints(self): + """Should enforce all constraints together.""" + result = ConStr("abc123", min_length=3, max_length=10, pattern=r"^[a-z0-9]+$") + assert str(result) == "abc123" + + # Too short + with pytest.raises(ValueError): + ConStr("ab", min_length=3, max_length=10, pattern=r"^[a-z0-9]+$") + + # Too long + with pytest.raises(ValueError): + ConStr("abcdefghijk", min_length=3, max_length=10, pattern=r"^[a-z0-9]+$") + + # Pattern mismatch + with pytest.raises(ValueError): + ConStr("ABC", min_length=3, max_length=10, pattern=r"^[a-z0-9]+$") + + def test_constr_type_error(self): + """Should reject non-string inputs.""" + with pytest.raises(TypeError): + ConStr(123) # type: ignore + + +# ============================================================================== +# ByteSize Tests +# ============================================================================== + + +class TestByteSize: + """Tests for ByteSize type.""" + + def test_bytesize_from_int(self): + """Should accept integer bytes.""" + result = ByteSize(1024) + assert int(result) == 1024 + + def test_bytesize_from_string_bytes(self): + """Should parse byte strings.""" + result = ByteSize("100B") + assert int(result) == 100 + + def test_bytesize_kb(self): + """Should parse KB units.""" + result = ByteSize("1KB") + assert int(result) == 1000 + + def test_bytesize_mb(self): + """Should parse MB units.""" + result = ByteSize("1MB") + assert int(result) == 1000**2 + + def test_bytesize_gb(self): + """Should parse GB units.""" + result = ByteSize("1GB") + assert int(result) == 1000**3 + + def test_bytesize_kib(self): + """Should parse KiB (binary) units.""" + result = ByteSize("1KiB") + assert int(result) == 1024 + + def test_bytesize_mib(self): + """Should parse MiB (binary) units.""" + result = ByteSize("1MiB") + assert int(result) == 1024**2 + + def test_bytesize_gib(self): + """Should parse GiB (binary) units.""" + result = ByteSize("1GiB") + assert int(result) == 1024**3 + + def test_bytesize_case_insensitive(self): + """Should handle case-insensitive units.""" + assert int(ByteSize("1mb")) == 1000**2 + assert int(ByteSize("1MB")) == 1000**2 + assert int(ByteSize("1Mb")) == 1000**2 + + def test_bytesize_with_spaces(self): + """Should handle sizes with spaces.""" + result = ByteSize("100 MB") + assert int(result) == 100 * 1000**2 + + def test_reject_invalid_bytesize(self): + """Should reject invalid byte sizes.""" + invalid_sizes = [ + "abc", + "100XB", # Invalid unit + "", + ] + for size in invalid_sizes: + with pytest.raises(ValueError): + ByteSize(size) + + def test_bytesize_type_error(self): + """Should reject invalid input types.""" + with pytest.raises(TypeError): + ByteSize([]) # type: ignore + + +# ============================================================================== +# PastDate Tests +# ============================================================================== + + +class TestPastDate: + """Tests for PastDate type.""" + + def test_valid_past_date_from_date(self): + """Should accept dates in the past.""" + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + result = PastDate(yesterday) + assert result == yesterday + + def test_valid_past_date_from_string(self): + """Should accept ISO date strings in the past.""" + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + result = PastDate(yesterday.isoformat()) + assert result == yesterday + + def test_reject_today(self): + """Should reject today's date.""" + today = date.today() # noqa: DTZ011 + with pytest.raises(ValueError, match="must be in the past"): + PastDate(today) + + def test_reject_future_date(self): + """Should reject future dates.""" + tomorrow = date.today() + timedelta(days=1) # noqa: DTZ011 + with pytest.raises(ValueError, match="must be in the past"): + PastDate(tomorrow) + + def test_past_date_invalid_string(self): + """Should reject invalid date strings.""" + with pytest.raises(ValueError, match="Invalid date format"): + PastDate("not-a-date") + + def test_past_date_type_error(self): + """Should reject invalid input types.""" + with pytest.raises(TypeError): + PastDate(123) # type: ignore + + +# ============================================================================== +# FutureDate Tests +# ============================================================================== + + +class TestFutureDate: + """Tests for FutureDate type.""" + + def test_valid_future_date_from_date(self): + """Should accept dates in the future.""" + tomorrow = date.today() + timedelta(days=1) # noqa: DTZ011 + result = FutureDate(tomorrow) + assert result == tomorrow + + def test_valid_future_date_from_string(self): + """Should accept ISO date strings in the future.""" + tomorrow = date.today() + timedelta(days=1) # noqa: DTZ011 + result = FutureDate(tomorrow.isoformat()) + assert result == tomorrow + + def test_reject_today(self): + """Should reject today's date.""" + today = date.today() # noqa: DTZ011 + with pytest.raises(ValueError, match="must be in the future"): + FutureDate(today) + + def test_reject_past_date(self): + """Should reject past dates.""" + yesterday = date.today() - timedelta(days=1) # noqa: DTZ011 + with pytest.raises(ValueError, match="must be in the future"): + FutureDate(yesterday) + + def test_future_date_invalid_string(self): + """Should reject invalid date strings.""" + with pytest.raises(ValueError, match="Invalid date format"): + FutureDate("not-a-date") + + def test_future_date_type_error(self): + """Should reject invalid input types.""" + with pytest.raises(TypeError): + FutureDate(123) # type: ignore