Skip to content

Commit ddb323e

Browse files
committed
fixed bug with Type_Safe to BaseModel handing of None values that were not marked as Optional
1 parent 200debc commit ddb323e

11 files changed

Lines changed: 1671 additions & 76 deletions

docs/llm-brief/v3.28.0__osbot-utils-safe-primitives__reference-guide.md

Lines changed: 1455 additions & 0 deletions
Large diffs are not rendered by default.

osbot_fast_api/api/Fast_API.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ def client(self):
106106
from starlette.testclient import TestClient # moved here for performance reasons
107107
return TestClient(self.app())
108108

109+
def config__no_default_routes(self): self.config.default_routes = False ; return self
110+
def config__no_api_key (self): self.config.enable_api_key = False ; return self
111+
def config__no_docs_offline (self): self.config.docs_offline = False ; return self
112+
113+
109114
def enable_api_key(self):
110115
self.config.enable_api_key = True
111116
return self

osbot_fast_api/api/routes/Routes__Config.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77

88
# todo: these are actually routes, so we should move them into a better location
99
# maybe 'default_routes' or something similar
10+
11+
ROUTES_PATHS__CONFIG = ['/config/info' ,
12+
'/config/openapi.py' ,
13+
'/config/status' ,
14+
'/config/version' ]
15+
1016
class Routes__Config(Fast_API__Routes):
1117
tag = 'config'
1218

osbot_fast_api/api/routes/Routes__Set_Cookie.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ class Schema__Set_Cookie(Type_Safe):
1212
# todo: these are actually routes, so we should move them into a better location
1313
# maybe 'default_routes' or something similar
1414

15+
ROUTES_PATHS__SET_COOKIE = ['/auth/set-auth-cookie' ,
16+
'/auth/set-cookie-form']
17+
18+
1519
class Routes__Set_Cookie(Fast_API__Routes):
1620
tag: str = 'auth'
1721

osbot_fast_api/api/transformers/Type_Safe__To__BaseModel.py

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,22 @@ def convert_class(self, type_safe_class: Type[Type_Safe]
2020

2121
annotations = type_safe_cache.get_class_annotations(type_safe_class) # Get class annotations (using type_safe_cache)
2222
pydantic_fields = {} # Build Pydantic fields
23+
cls_kwargs = type_safe_class.__cls_kwargs__() # Get class kwargs with defaults once
2324

2425
for field_name, field_type in annotations:
2526
pydantic_type = self.convert_type(field_type) # Convert Type_Safe types to Pydantic
26-
default_value = self.get_default_value(type_safe_class, field_name) # Get default value if exists
27-
28-
if default_value is not None: # Create field with proper config
29-
pydantic_fields[field_name] = (pydantic_type, default_value)
30-
else:
27+
has_default = field_name in cls_kwargs # Check if field has any default (including None)
28+
29+
if has_default:
30+
default_value = cls_kwargs[field_name]
31+
default_value = self.normalize_default_value(default_value) # Normalize collections
32+
33+
if default_value is None: # Explicit None default - make Optional
34+
pydantic_type = Optional[pydantic_type]
35+
pydantic_fields[field_name] = (pydantic_type, None)
36+
else:
37+
pydantic_fields[field_name] = (pydantic_type, default_value)
38+
else: # No default - required field
3139
pydantic_fields[field_name] = (pydantic_type, Field(...))
3240

3341
model_name = f"{type_safe_class.__name__}__BaseModel" # Generate model name
@@ -93,23 +101,17 @@ def convert_type(self, type_safe_type: Any # Type a
93101

94102
return type_safe_type # Return as-is for standard types
95103

96-
def get_default_value(self, type_safe_class: Type[Type_Safe], # Class to get default from
97-
field_name: str # Field name to check
98-
) -> Any: # Returns default value or None
99-
cls_kwargs = type_safe_class.__cls_kwargs__() # Get class kwargs with defaults
104+
def get_default_value(self, type_safe_class: Type[Type_Safe], # Class to get default from
105+
field_name: str # Field name to check
106+
) -> Any: # Returns default value or None
107+
cls_kwargs = type_safe_class.__cls_kwargs__() # Get class kwargs with defaults
100108

101109
if field_name in cls_kwargs:
102-
value = cls_kwargs[field_name]
103-
if isinstance(value, Type_Safe__List): # Convert Type_Safe collections
104-
return list(value)
105-
elif isinstance(value, Type_Safe__Dict):
106-
return dict(value)
107-
elif isinstance(value, Type_Safe__Set):
108-
return list(value) # Convert set to list for Pydantic
109-
return value
110+
return self.normalize_default_value(cls_kwargs[field_name])
110111

111112
return None
112113

114+
113115
def extract_instance_data(self, type_safe_instance: Type_Safe # Instance to extract data from
114116
) -> Dict[str, Any]: # Returns dict of instance data
115117
data = {}
@@ -163,5 +165,15 @@ def convert_dict(self, type_safe_dict: Type_Safe__Dict
163165

164166
return result
165167

168+
def normalize_default_value(self, value: Any # Default value to normalize
169+
) -> Any: # Returns normalized value
170+
if isinstance(value, Type_Safe__List): # Convert Type_Safe collections
171+
return list(value)
172+
elif isinstance(value, Type_Safe__Dict):
173+
return dict(value)
174+
elif isinstance(value, Type_Safe__Set):
175+
return list(value) # Convert set to list for Pydantic
176+
return value
177+
166178

167179
type_safe__to__basemodel = Type_Safe__To__BaseModel() # Singleton instance for convenience (and to have a more global model_cache)

poetry.lock

Lines changed: 23 additions & 36 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from unittest import TestCase
1+
from unittest import TestCase
2+
23

34
class test_Fast_API__Routes__bugs(TestCase):
45

5-
pass
6+
pass

tests/unit/api/_bugs/test_Fast_API__Routes__regression.py

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1-
from typing import Union
2-
from unittest import TestCase
3-
from osbot_fast_api.client.Fast_API__Route__Extractor import Fast_API__Route__Extractor
4-
from osbot_utils.type_safe.Type_Safe import Type_Safe
5-
from osbot_fast_api.api.Fast_API import Fast_API
1+
from typing import Union
2+
from unittest import TestCase
3+
from osbot_fast_api.client.Fast_API__Route__Extractor import Fast_API__Route__Extractor
4+
from osbot_utils.type_safe.Type_Safe import Type_Safe
5+
from osbot_fast_api.api.Fast_API import Fast_API
6+
from osbot_fast_api.api.routes.Fast_API__Routes import Fast_API__Routes
7+
from osbot_fast_api.api.schemas.Schema__Fast_API__Config import Schema__Fast_API__Config
8+
from osbot_fast_api.api.transformers.Type_Safe__To__BaseModel import Type_Safe__To__BaseModel
9+
from osbot_utils.type_safe.primitives.core.Safe_Str import Safe_Str
610

711

812
class test_Fast_API__Routes__regression(TestCase):
@@ -64,4 +68,67 @@ def concrete_return_type() -> Schema__Response:
6468
client = fast_api.client() # Verify the route works
6569
response = client.get('/concrete-return-type')
6670
assert response.status_code == 200
67-
assert response.json() == {"message": "test"}
71+
assert response.json() == {"message": "test"}
72+
73+
def test__regression__fast_api__response__base_model__not_handing__none_values__in_type_safe_primitives(self):
74+
from osbot_fast_api.api.Fast_API import Fast_API
75+
76+
class An_Response_Class(Type_Safe):
77+
an_str : Safe_Str = None
78+
79+
class Routes__ABC(Fast_API__Routes):
80+
def an_post__fails(self) -> An_Response_Class:
81+
return An_Response_Class()
82+
83+
def an_post__ok_1(self) -> An_Response_Class:
84+
return An_Response_Class(an_str='')
85+
86+
def an_post__ok_2(self) -> An_Response_Class:
87+
return An_Response_Class(an_str='ok')
88+
89+
def setup_routes(self):
90+
self.add_routes_post(self.an_post__fails,
91+
self.an_post__ok_1 ,
92+
self.an_post__ok_2 )
93+
94+
config = Schema__Fast_API__Config(default_routes=False)
95+
class Fast_API__Abc(Fast_API):
96+
def setup_routes(self):
97+
self.add_routes(Routes__ABC)
98+
return self
99+
100+
fast_api_abc = Fast_API__Abc(config=config).setup()
101+
assert fast_api_abc.routes_paths() == ['/an-post/fails',
102+
'/an-post/ok-1',
103+
'/an-post/ok-2']
104+
105+
with fast_api_abc.client() as _:
106+
# error_message = "1 validation error for An_Response_Class__BaseModel\nan_str\n Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]\n For further information visit https://errors.pydantic.dev/2.12/v/string_type"
107+
# with pytest.raises(ValueError, match=re.escape(error_message)):
108+
# _.post(url='/an-post/fails') # BUG: should have worked
109+
110+
assert _.post(url='/an-post/fails').json() == {'an_str': None}
111+
112+
assert _.post(url='/an-post/ok-1').json() == {'an_str': '' }
113+
assert _.post(url='/an-post/ok-2').json() == {'an_str': 'ok'}
114+
115+
def test__regression__type_safe_to_basemodel__converter__handle__none_return_values__no_optional(self):
116+
117+
class An_Class(Type_Safe):
118+
#an_str : Safe_Str = None
119+
an_str : str = None # when we make this optional , it fails
120+
121+
122+
# error_message = ("1 validation error for An_Class__BaseModel\nan_str\n "
123+
# "Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]\n "
124+
# "For further information visit https://errors.pydantic.dev/2.12/v/string_type")
125+
# with pytest.raises(ValueError, match=re.escape(error_message)):
126+
# result = Type_Safe__To__BaseModel().convert_instance(An_Class()) # BUG
127+
128+
result = Type_Safe__To__BaseModel().convert_instance(An_Class()) # FIXED
129+
assert result.model_json_schema() == {'properties': {'an_str': {'anyOf': [{'type': 'string'},
130+
{'type': 'null'}],
131+
'default': None,
132+
'title': 'An Str'}},
133+
'title': 'An_Class__BaseModel',
134+
'type': 'object'}

tests/unit/api/routes/test_Fast_API__Routes__with_Type_Safe.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,30 @@ def setup_routes(self):
356356
assert response.status_code == 200
357357
assert response.json() == {'user_id': 'user-abc', 'user_name': 'abc'}
358358

359+
360+
def test__8__with_Type_Safe__return_value(self):
361+
class An_Response(Type_Safe):
362+
an_str : str = None
363+
364+
class Routes__POST(Fast_API__Routes):
365+
def an_response(self) -> An_Response:
366+
return An_Response()
367+
368+
def setup_routes(self):
369+
self.add_route_post(self.an_response)
370+
371+
class An_Fast_API(Fast_API):
372+
def setup_routes(self):
373+
self.add_routes(Routes__POST)
374+
an_fast_api = An_Fast_API().config__no_default_routes().setup()
375+
376+
assert an_fast_api.routes_paths() == ['/an-response']
377+
378+
with an_fast_api.client() as _:
379+
assert _.post('/an-response').json() == {'an_str': None}
380+
381+
382+
359383
def test__regression__primitive_type__not_supported_on__init(self):
360384
class An_Class(Type_Safe):
361385
tag : Safe_Str__File__Path

0 commit comments

Comments
 (0)