A Python library for creating type-safe, self-validating value objects using a powerful transformation and validation pipeline.
Project description
Constrained Values
A Python library for creating type-safe, self-validating value objects using a powerful transformation and validation pipeline.
The Philosophy: Beyond Primitive Types
In many applications, especially when interacting with hardware or external systems, we often pass around primitive types like integers, strings, or floats. This can lead to problems:
- Primitive Obsession: Is
temperature = 25in Celsius or Fahrenheit? Isspi_mode = 2a valid mode? Raw values lack context and safety. - Lost Domain Knowledge: The rules governing these values are scattered throughout the codebase. An
Ageshouldn't be negative, and aTemperaturefrom a sensor might have a specific valid range. - Bugs and Unreliability: Passing an invalid value can lead to subtle bugs or crashes far from where the value was created.
The Constrained Values library solves this by embracing Object-Oriented principles. Instead of passing around a raw int, you create a rich, meaningful Age or Temperature object. This object encapsulates not just the value, but also the rules that govern it, ensuring that it can never exist in an invalid state.
This is particularly powerful for abstracting hardware domains. Instead of remembering that a Modbus register value of -32768 on a specific hardware means "no sensor detected," or that a valid Serial Peripheral Interface (SPI) "mode" is an integer between 0 and 3, you can create type-safe objects like VentilationTemperature or SPIMode that handle this complexity internally.
Features
- Create Rich Value Objects: Turn primitive data into meaningful, type-safe objects.
- Powerful Validation Pipelines: Chain multiple validation and transformation steps.
- Built-in Strategies: Includes common validators for ranges, enums, types, and more.
- Custom Logic: Easily extend the library with your own validation and transformation strategies.
- Clear Error Handling: Each constrained value clearly reports its status (
OKorEXCEPTION) and provides descriptive error messages. - Optional Error Throw: When constructing a constrained value you can make it throw immediately, so you know an object is valid.
- Type Safety: Enforces the final, canonical type of your value.
Installation
pip install constrained-values
Progressive Examples
Let's explore the library's features, starting with a simple case and building up to a complex real-world scenario.
Example 1: Simple Range Validation
The most basic use case is ensuring a value falls within a specific range. Instead of passing an integer around and checking its bounds everywhere, we create an Age type by defining a class.
# See: examples/readme_example_1.py
# Define an 'Age' type that must be an integer between 0 and 120.
class Age(ConstrainedRangeValue[int]):
def __init__(self, value):
super().__init__(value, 0, 120)
# Now, let's use our new Age type.
valid_age = Age(30)
invalid_age = Age(150)
invalid_age_by_type = Age("21")
another_age = Age(32)
print(f"Valid age: {valid_age.value}, Is OK: {valid_age.ok}")
# Output: Valid age: 30, Is OK: True
print(f"Invalid age: {invalid_age.value}, Is OK: {invalid_age.ok}")
# Output: Invalid age: None, Is OK: False
print(f"Error details: {invalid_age.details}")
# Output: Value must be less than or equal to 120, got 150
print(f"Error details: {invalid_age_by_type.details}")
# Output: Error details: Value must be one of 'int', got 'str'
print(f"another_age: {another_age.value}, is greater than valid age: {valid_age.value} ? {another_age > valid_age}")
#Output: another_age: 32, is greater than valid age: 30 ? True
Example 2: Using ConstrainedRangeValue with a custom transform (Fahrenheit → Celsius).
This demo shows how to subclass ConstrainedRangeValue and override
get_custom_strategies() to insert a transformation step into the pipeline.
- Input values are provided in Fahrenheit (int or float).
- A FahrenheitToCelsius transformation converts them to Celsius.
- The resulting Celsius values are validated against a range of -10°C .. 40°C.
- Results are rounded to two decimal places.
# See: examples/readme_example_2.py
from constrained_values import Response, Status, ConstrainedRangeValue
from constrained_values.constants import DEFAULT_SUCCESS_MESSAGE
from constrained_values.value import TransformationStrategy
class FahrenheitToCelsius(TransformationStrategy[float, float]):
"""
Define a transformation strategy for Fahrenheit.
input and output types are float
"""
def transform(self, value: float) -> Response[float]:
try:
c = round((float(value) - 32.0) * (5.0 / 9.0),2)
return Response(Status.OK, DEFAULT_SUCCESS_MESSAGE, c)
except Exception as e:
return Response(Status.EXCEPTION, str(e), None)
class FahrenheitToCelsiusValue(ConstrainedRangeValue[float]):
"""
Valid Celsius value between -10 and 40, inclusive.
Accepts input as Fahrenheit (int/float).
Fahrenheit is converted internally to Celsius before validation.
"""
def __init__(self, value: int | float):
super().__init__(value, -10.0, 40.0)
def get_custom_strategies(self):
return [FahrenheitToCelsius()]
print("\n=== Fahrenheit inputs (converted to Celsius) ===")
for val in [50, 50.36, 72]:
cv = FahrenheitToCelsiusValue(val)
print(f"Input {val!r}F → status={cv.status}, value={cv.value}°C")
print("\n=== Out of range examples ===")
for val in [-40, 10, 122]:
cv = FahrenheitToCelsiusValue(val)
print(f"Input {val!r} → status={cv.status}, details={cv.details}")
Level 3: Complex Pipelines for Hardware Data
This is where the library truly shines. Let's model a real-world hardware scenario: reading a temperature from a ventilation unit via the Modbus protocol.
The process involves multiple steps:
- The input is a register address (an
int). - We must validate that we are allowed to read from this register.
- We fetch the raw integer value from a list of all Modbus registers.
- The hardware uses special values (
-32768,32767) to signal errors like a missing or short-circuited sensor. We must detect these. - If the value is valid, it's not yet in Celsius. We need to divide it by
10.0to get the final temperature.
Here’s how you can model this entire chain of validation and transformation using a custom ConstrainedRangeValue.
from typing import List
from constrained_values import (Response, Status, ConstrainedRangeValue,
ValidationStrategy, TypeValidationStrategy, DEFAULT_SUCCESS_MESSAGE)
from constrained_values.response import StatusResponse
from constrained_values.value import TransformationStrategy, PipeLineStrategy
# --- Define Custom Strategies for our Pipeline ---
class AllowedInputRegister(ValidationStrategy[int]):
"""Checks if the selected register address is valid."""
def validate(self, value: int) -> StatusResponse:
valid_registers = {0, 1, 2, 3}
if value in valid_registers:
return StatusResponse(status=Status.OK, details=DEFAULT_SUCCESS_MESSAGE)
return StatusResponse(status=Status.EXCEPTION, details="Invalid temperature register selected")
class GetValueFromRegister(TransformationStrategy[int, int]):
"""Fetches the raw integer from the Modbus data list."""
def __init__(self, input_register: List[int]):
self.input_register = input_register
def transform(self, value: int) -> Response[int]:
raw_sensor_value = self.input_register[value]
return Response(status=Status.OK, details=DEFAULT_SUCCESS_MESSAGE, value=raw_sensor_value)
class DetectSensorErrors(ValidationStrategy[int]):
"""Checks for hardware-specific error codes."""
NO_SENSOR = -32768
SENSOR_SHORT = 32767
def validate(self, value: int) -> StatusResponse:
if value == self.NO_SENSOR:
return StatusResponse(status=Status.EXCEPTION, details="No sensor detected")
if value == self.SENSOR_SHORT:
return StatusResponse(status=Status.EXCEPTION, details="Sensor short circuit")
return StatusResponse(status=Status.OK, details=DEFAULT_SUCCESS_MESSAGE)
class RawToCelsius(TransformationStrategy[int, float]):
"""Transforms the raw integer to a float in degrees Celsius."""
def transform(self, value: int) -> Response[float]:
celsius = float(value) / 10.0
return Response(status=Status.OK, details=DEFAULT_SUCCESS_MESSAGE, value=celsius)
class VentilationTemperature(ConstrainedRangeValue[float]):
"""
This value object encapsulates the full pipeline of reading and validating
temperature data from Modbus (https://www.modbus.org/) input registers, converting to Celsius, and
enforcing an allowed range.
"""
__slots__ = ("_getValueFromRegister",)
def __init__(self, input_register: Response[int], selected_register: int):
object.__setattr__(self, "_getValueFromRegister", GetValueFromRegister(input_register))
super().__init__(selected_register, -10.0, 40.0)
def get_strategies(self) -> List[PipeLineStrategy]:
return [TypeValidationStrategy(int),
AllowedInputRegister(),
self._getValueFromRegister,
DetectSensorErrors(),
RawToCelsius()] + super().get_strategies()
-- Test Cases --
registers = [215, -32768, 32767, 402] # Example Modbus register values
print("=== Valid register 0 ===")
v = VentilationTemperature(registers, 0)
print(f"status={v.status}, details={v.details}, value={v.value}") # → 21.5°C
# Output # status=Status.OK, details=validation successful, value=21.5
print("\n=== Invalid: No sensor detected (register 1) ===")
v = VentilationTemperature(registers, 1)
print(f"status={v.status}, details={v.details}")
# Output # status=Status.EXCEPTION, details=No sensor detected
print("\n=== Invalid: Sensor short circuit (register 2) ===")
v = VentilationTemperature(registers, 2)
print(f"status={v.status}, details={v.details}")
# Output # status=Status.EXCEPTION, details=Sensor short circuit
print("\n=== Out of range (register 3) ===")
v = VentilationTemperature(registers, 3)
print(f"status={v.status}, details={v.details}")
# Output # status=Status.EXCEPTION, details=Value must be less than or equal to 40.0, got 40.2
This example demonstrates how constrained-values can tame complexity by creating a clean, reliable interface over messy, real-world data.
Further Examples
For more examples, please see the examples/ directory, which includes:
- Chained transformations (
09_chained_transforms.py) - Type coercion and validation (
10_type_validation_strategy.py,17_coerce_to_type.py) - Enum validation (
13_enum_with_class.py) - And many more.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file constrained_values-0.1.1.tar.gz.
File metadata
- Download URL: constrained_values-0.1.1.tar.gz
- Upload date:
- Size: 27.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c98576cbdc843fd47fc68246c80b9175104044166e1b2aee545ebff3014b377d
|
|
| MD5 |
678444c32ba856fe2e630be32db0e1c4
|
|
| BLAKE2b-256 |
431ef1bab53b798185761d6492512f389e6cf63c2a5c21c973374c53e65811e3
|
File details
Details for the file constrained_values-0.1.1-py3-none-any.whl.
File metadata
- Download URL: constrained_values-0.1.1-py3-none-any.whl
- Upload date:
- Size: 16.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.12.3
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5c7043280cc2e77f3f51081e042451d0d0fa81c885de57cb9a43af0a9748edf3
|
|
| MD5 |
300ce3951eb64716f73dc5df25531111
|
|
| BLAKE2b-256 |
a8b37df50623103f4210e18714297dcf229ad5075abd763ce44bede5244cc227
|