Skip to content

Commit 7a323d6

Browse files
committed
major addition of multiple classes for handling safe Int, UInt and Float
1 parent 8255c47 commit 7a323d6

27 files changed

+1855
-6
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import math
2+
from decimal import Decimal, ROUND_HALF_UP, InvalidOperation
3+
from typing import Optional, Union
4+
5+
class Safe_Float(float): # Base class for type-safe floats with validation rules
6+
7+
min_value : Optional[float] = None
8+
max_value : Optional[float] = None
9+
allow_none : bool = True
10+
allow_bool : bool = False
11+
allow_str : bool = True
12+
allow_int : bool = True
13+
strict_type : bool = False
14+
decimal_places : Optional[int] = None
15+
16+
# Precision handling options
17+
use_decimal : bool = False
18+
epsilon : float = 1e-9
19+
round_output : bool = True
20+
clamp_to_range : bool = False
21+
22+
def __new__(cls, value: Optional[Union[float, int, str]] = None) -> 'Safe_Float':
23+
if value is None:
24+
if cls.allow_none:
25+
return super().__new__(cls, 0.0)
26+
else:
27+
raise ValueError(f"{cls.__name__} does not allow None values")
28+
29+
# Store original value for range checking
30+
original_value = value
31+
32+
# Convert to float
33+
if isinstance(value, str):
34+
if not cls.allow_str:
35+
raise TypeError(f"{cls.__name__} does not allow string conversion")
36+
try:
37+
if cls.use_decimal:
38+
value = Decimal(value)
39+
else:
40+
value = float(value)
41+
except (ValueError, InvalidOperation):
42+
raise ValueError(f"Cannot convert '{value}' to float")
43+
elif isinstance(value, bool):
44+
if not cls.allow_bool:
45+
raise TypeError(f"{cls.__name__} does not allow boolean values")
46+
value = float(value)
47+
elif isinstance(value, int):
48+
if not cls.allow_int:
49+
raise TypeError(f"{cls.__name__} does not allow integer conversion")
50+
if cls.use_decimal:
51+
value = Decimal(value)
52+
else:
53+
value = float(value)
54+
elif isinstance(value, float):
55+
if math.isinf(value):
56+
raise ValueError(f"{cls.__name__} does not allow infinite values")
57+
if math.isnan(value):
58+
raise ValueError(f"{cls.__name__} does not allow NaN values")
59+
60+
if cls.use_decimal:
61+
value = Decimal(str(value))
62+
elif not isinstance(value, (float, Decimal)):
63+
raise TypeError(f"{cls.__name__} requires a float value, got {type(value).__name__}")
64+
65+
# Get numeric value for range checking (before rounding)
66+
check_value = float(value) if isinstance(value, Decimal) else value
67+
68+
# Range validation BEFORE rounding (unless clamping)
69+
if not cls.clamp_to_range:
70+
if cls.min_value is not None and check_value < cls.min_value:
71+
raise ValueError(f"{cls.__name__} must be >= {cls.min_value}, got {check_value}")
72+
if cls.max_value is not None and check_value > cls.max_value:
73+
raise ValueError(f"{cls.__name__} must be <= {cls.max_value}, got {check_value}")
74+
75+
# NOW do rounding
76+
if isinstance(value, Decimal) and cls.decimal_places is not None:
77+
value = value.quantize(Decimal(f'0.{"0" * cls.decimal_places}'), rounding=ROUND_HALF_UP)
78+
79+
if isinstance(value, Decimal):
80+
value = float(value)
81+
82+
# Check again for special values
83+
if math.isinf(value):
84+
raise ValueError(f"{cls.__name__} does not allow infinite values")
85+
if math.isnan(value):
86+
raise ValueError(f"{cls.__name__} does not allow NaN values")
87+
88+
# Clean up floating point errors (only if not already handled by Decimal)
89+
if cls.round_output and cls.decimal_places is not None and not cls.use_decimal:
90+
value = cls.__clean_float(value, cls.decimal_places)
91+
92+
# Handle clamping AFTER rounding
93+
if cls.clamp_to_range:
94+
if cls.min_value is not None and value < cls.min_value:
95+
value = cls.min_value
96+
if cls.max_value is not None and value > cls.max_value:
97+
value = cls.max_value
98+
99+
return super().__new__(cls, value)
100+
101+
def __truediv__(self, other):
102+
# Simple and safe - no special handling for infinity
103+
if float(other) == 0:
104+
raise ZeroDivisionError(f"{self.__class__.__name__} division by zero")
105+
106+
if self.use_decimal:
107+
result = float(Decimal(str(float(self))) / Decimal(str(float(other))))
108+
else:
109+
result = float(self) / float(other)
110+
111+
# Check for overflow/underflow
112+
if math.isinf(result) or math.isnan(result):
113+
raise OverflowError(f"Division resulted in {result}")
114+
115+
if self.round_output and self.decimal_places is not None:
116+
result = self.__clean_float(result, self.decimal_places)
117+
118+
try:
119+
return self.__class__(result)
120+
except (ValueError, TypeError):
121+
return result
122+
123+
@classmethod
124+
def __clean_float(cls, value: float, decimal_places: int) -> float: # Clean up floating point representation errors
125+
rounded = round(value, decimal_places + 2) # First, round to eliminate tiny errors
126+
127+
# Check if very close to a clean decimal
128+
str_val = f"{rounded:.{decimal_places + 2}f}"
129+
if str_val.endswith('999999') or str_val.endswith('000001'):
130+
# Use Decimal for exact rounding
131+
d = Decimal(str(value))
132+
return float(d.quantize(Decimal(f'0.{"0" * decimal_places}'), rounding=ROUND_HALF_UP))
133+
134+
return round(value, decimal_places) if decimal_places else value
135+
136+
def __mul__(self, other):
137+
if self.use_decimal:
138+
result = float(Decimal(str(float(self))) * Decimal(str(float(other))))
139+
else:
140+
result = float(self) * float(other)
141+
142+
if self.round_output and self.decimal_places is not None:
143+
if not (math.isinf(result) or math.isnan(result)):
144+
result = self.__clean_float(result, self.decimal_places)
145+
146+
try:
147+
return self.__class__(result)
148+
except (ValueError, TypeError):
149+
return result
150+
151+
def __eq__(self, other):
152+
"""Equality with epsilon tolerance"""
153+
if isinstance(other, (int, float)):
154+
return abs(float(self) - float(other)) < self.epsilon
155+
return super().__eq__(other)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from osbot_utils.helpers.safe_float.Safe_Float import Safe_Float
2+
3+
class Safe_Float__Engineering(Safe_Float): # Engineering calculations with controlled precision
4+
#decimal_places = 6
5+
epsilon = 1e-6
6+
round_output = True
7+
use_decimal = False # Performance over exactness
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Safe_Float__Money.py
2+
from osbot_utils.helpers.safe_float.Safe_Float import Safe_Float
3+
4+
5+
class Safe_Float__Money(Safe_Float): # Money calculations with exact decimal arithmetic
6+
decimal_places = 2
7+
use_decimal = True # Use Decimal internally
8+
allow_inf = False
9+
allow_nan = False
10+
min_value = 0.0
11+
round_output = True
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from osbot_utils.helpers.safe_float.Safe_Float import Safe_Float
2+
3+
4+
class Safe_Float__Percentage_Exact(Safe_Float): # Exact percentage calculations
5+
min_value = 0.0
6+
max_value = 100.0
7+
decimal_places = 2
8+
use_decimal = True
9+
round_output = True

osbot_utils/helpers/safe_float/__init__.py

Whitespace-only changes.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from typing import Optional, Union
2+
3+
from osbot_utils.helpers.safe_float.Safe_Float import Safe_Float
4+
5+
6+
class Safe_Int(int): # Base class for type-safe integers with validation rules
7+
8+
min_value : Optional[int] = None # Minimum allowed value (inclusive)
9+
max_value : Optional[int] = None # Maximum allowed value (inclusive)
10+
allow_none : bool = True # Whether None is allowed as input
11+
allow_bool : bool = False # Whether bool is allowed as input
12+
allow_str : bool = True # Whether string conversion is allowed
13+
strict_type : bool = False # If True, only accept int type (no conversions)
14+
15+
def __new__(cls, value: Optional[Union[int, str]] = None) -> 'Safe_Int':
16+
# Handle None input
17+
if value is None:
18+
if cls.allow_none:
19+
return super().__new__(cls, 0) # Default to 0 for None
20+
else:
21+
raise ValueError(f"{cls.__name__} does not allow None values")
22+
23+
# Strict type checking
24+
if cls.strict_type and not isinstance(value, int):
25+
raise TypeError(f"{cls.__name__} requires int type, got {type(value).__name__}")
26+
27+
# Type conversion
28+
if isinstance(value, str):
29+
if not cls.allow_str:
30+
raise TypeError(f"{cls.__name__} does not allow string conversion")
31+
try:
32+
value = int(value)
33+
except ValueError:
34+
raise ValueError(f"Cannot convert '{value}' to integer")
35+
36+
elif isinstance(value, bool):
37+
if not cls.allow_bool:
38+
raise TypeError(f"{cls.__name__} does not allow boolean values")
39+
value = int(value)
40+
41+
elif not isinstance(value, int):
42+
raise TypeError(f"{cls.__name__} requires an integer value, got {type(value).__name__}")
43+
44+
# Range validation
45+
if cls.min_value is not None and value < cls.min_value:
46+
raise ValueError(f"{cls.__name__} must be >= {cls.min_value}, got {value}")
47+
48+
if cls.max_value is not None and value > cls.max_value:
49+
raise ValueError(f"{cls.__name__} must be <= {cls.max_value}, got {value}")
50+
51+
return super().__new__(cls, value)
52+
53+
# Arithmetic operations that maintain type safety
54+
def __add__(self, other):
55+
result = super().__add__(other)
56+
try:
57+
return self.__class__(result)
58+
except (ValueError, TypeError):
59+
return result # Return plain int if validation fails
60+
61+
def __sub__(self, other):
62+
result = super().__sub__(other)
63+
try:
64+
return self.__class__(result)
65+
except (ValueError, TypeError):
66+
return result
67+
68+
def __mul__(self, other):
69+
result = super().__mul__(other)
70+
try:
71+
return self.__class__(result)
72+
except (ValueError, TypeError):
73+
return result
74+
75+
def __truediv__(self, other):
76+
result = super().__truediv__(other)
77+
return Safe_Float(result)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
TYPE_SAFE_INT__BYTE__MIN_VALUE = 0
4+
TYPE_SAFE_INT__BYTE__MAX_VALUE = 255
5+
6+
class Safe_Int__Byte(Safe_Int):
7+
"""Single byte value (0-255)"""
8+
9+
min_value = TYPE_SAFE_INT__BYTE__MIN_VALUE
10+
max_value = TYPE_SAFE_INT__BYTE__MAX_VALUE
11+
allow_bool = False
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
TYPE_SAFE_INT__FILE_SIZE__MIN_VALUE = 0
4+
TYPE_SAFE_INT__FILE_SIZE__MAX_VALUE = 2**63 - 1 # Max file size on most systems
5+
6+
class Safe_Int__FileSize(Safe_Int): # File size in bytes
7+
8+
min_value = TYPE_SAFE_INT__FILE_SIZE__MIN_VALUE
9+
max_value = TYPE_SAFE_INT__FILE_SIZE__MAX_VALUE
10+
allow_bool = False
11+
12+
def to_kb(self) -> float:
13+
return self / 1024
14+
15+
def to_mb(self) -> float:
16+
return self / (1024 * 1024)
17+
18+
def to_gb(self) -> float:
19+
return self / (1024 * 1024 * 1024)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
TYPE_SAFE_INT__PERCENTAGE__MIN_VALUE = 0
4+
TYPE_SAFE_INT__PERCENTAGE__MAX_VALUE = 100
5+
6+
class Safe_Int__Percentage(Safe_Int): # Percentage value (0-100)
7+
8+
min_value = TYPE_SAFE_INT__PERCENTAGE__MIN_VALUE
9+
max_value = TYPE_SAFE_INT__PERCENTAGE__MAX_VALUE
10+
allow_bool = False
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
TYPE_SAFE_INT__PORT__MIN_VALUE = 0
4+
TYPE_SAFE_INT__PORT__MAX_VALUE = 65535
5+
6+
class Safe_Int__Port(Safe_Int): # Network port number (0-65535)
7+
8+
min_value = TYPE_SAFE_INT__PORT__MIN_VALUE
9+
max_value = TYPE_SAFE_INT__PORT__MAX_VALUE
10+
allow_bool = False
11+
allow_none = False # don't allow 0 as port value since that is a really weird value for a port
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
class Safe_Int__UInt(Safe_Int): # Unsigned Integer - only accepts non-negative integer values
4+
5+
min_value = 0 # Unsigned means >= 0
6+
max_value = None # No upper limit by default
7+
allow_bool = False # Don't allow True/False as 1/0
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from osbot_utils.helpers.safe_int.Safe_Int import Safe_Int
2+
3+
TYPE_SAFE_INT__BYTE__MIN_VALUE = 0
4+
TYPE_SAFE_INT__BYTE__MAX_VALUE = 255
5+
6+
class Safe_Int__Byte(Safe_Int): # Single byte value (0-255)
7+
8+
min_value = TYPE_SAFE_INT__BYTE__MIN_VALUE
9+
max_value = TYPE_SAFE_INT__BYTE__MAX_VALUE
10+
allow_bool = False

osbot_utils/helpers/safe_str/Safe_Str.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def __new__(cls, value: Optional[str] = None) -> 'Safe_Str':
2121
if cls.allow_empty:
2222
value = ""
2323
else:
24-
raise ValueError("Value cannot be None when allow_empty is False")
24+
raise ValueError(f"in {cls.__name__}, value cannot be None when allow_empty is False") from None
2525

2626
if not isinstance(value, str): # Convert to string if not already
2727
value = str(value)

osbot_utils/helpers/safe_str/http/__init__.py

Whitespace-only changes.

osbot_utils/type_safe/shared/Type_Safe__Validation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def validate_type_immutability(self, var_name: str, var_type: Any) -> None:
285285
if self.obj_is_type_union_compatible(var_type, IMMUTABLE_TYPES) is False: # if var_type is not something like Optional[Union[int, str]]
286286
if var_type not in IMMUTABLE_TYPES or type(var_type) not in IMMUTABLE_TYPES:
287287
if not isinstance(var_type, EnumMeta):
288-
if not issubclass(var_type, str):
288+
if not issubclass(var_type, (int,str, float)):
289289
type_safe_raise_exception.immutable_type_error(var_name, var_type)
290290

291291
def validate_variable_type(self, var_name, var_type, var_value): # Validate type compatibility

tests/unit/helpers/llms/actions/test_Safe_Str__Hash.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def test_invalid_values(self):
4141
Safe_Str__Hash('12345678901') # Too long
4242

4343
# Empty or None
44-
with pytest.raises(ValueError, match="Value cannot be None when allow_empty is False") as exc_info:
44+
with pytest.raises(ValueError, match="in Safe_Str__Hash, value cannot be None when allow_empty is Fals") as exc_info:
4545
Safe_Str__Hash(None)
4646

4747
with pytest.raises(ValueError, match=f"Value cannot be empty when allow_empty is False"):

0 commit comments

Comments
 (0)