Skip to content

Commit f409270

Browse files
committed
Merge dev into main
2 parents 2e7522c + d0bb8db commit f409270

32 files changed

+1861
-13
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
Powerful Python util methods and classes that simplify common apis and tasks.
44

5-
![Current Release](https://img.shields.io/badge/release-v2.59.0-blue)
5+
![Current Release](https://img.shields.io/badge/release-v2.59.3-blue)
66
[![codecov](https://codecov.io/gh/owasp-sbot/OSBot-Utils/graph/badge.svg?token=GNVW0COX1N)](https://codecov.io/gh/owasp-sbot/OSBot-Utils)
77

88

osbot_utils/helpers/html/Html__To__Html_Document.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,12 @@
33
from osbot_utils.helpers.html.schemas.Schema__Html_Document import Schema__Html_Document
44
from osbot_utils.type_safe.Type_Safe import Type_Safe
55

6-
76
class Html__To__Html_Document(Type_Safe):
87
html: str
98
html__dict : dict
109
html__document: Schema__Html_Document
1110

12-
def convert(self):
11+
def convert(self) -> Schema__Html_Document:
1312
if self.html:
1413
html__dict = Html__To__Html_Dict(self.html).convert()
1514
if html__dict:

osbot_utils/helpers/html/schemas/Schema__Html_Node.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55

66
class Schema__Html_Node(Type_Safe):
7-
attrs : Dict[str, Optional[str]] # HTML attributes (e.g., {'class': 'container'})
7+
attrs : Dict[str, Optional[str]] # HTML attributes (e.g., {'class': 'container'}) # todo: see what Safe_Ster we can use for these name attrs
88
nodes : List[Union['Schema__Html_Node', Schema__Html_Node__Data]] # Child nodes (recursive structure)
9-
tag : str # HTML tag name (e.g., 'div', 'meta', 'title')
9+
tag : str # HTML tag name (e.g., 'div', 'meta', 'title') # todo: see what Safe_Ster we can use for the tag
1010

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.

0 commit comments

Comments
 (0)