@@ -89,6 +89,25 @@ async def picoschema_to_json_schema(schema: Any, schema_resolver: SchemaResolver
8989 return await PicoschemaParser (schema_resolver ).parse (schema )
9090
9191
92+ def picoschema_to_json_schema_sync (schema : Any ) -> JsonSchema | None :
93+ """Parses a Picoschema definition into a JSON Schema (synchronous).
94+
95+ This sync version works for schemas using only built-in scalar types
96+ (string, number, integer, boolean, null, any). Use the async version
97+ if you need to resolve named schema references.
98+
99+ Args:
100+ schema: The Picoschema definition (can be a dict or string).
101+
102+ Returns:
103+ The equivalent JSON Schema, or None if the input schema is None.
104+
105+ Raises:
106+ ValueError: If the schema references a named type that requires resolution.
107+ """
108+ return PicoschemaParser (schema_resolver = None ).parse_sync (schema )
109+
110+
92111class PicoschemaParser :
93112 """Parses Picoschema definitions into JSON Schema.
94113
@@ -160,6 +179,38 @@ async def parse(self, schema: Any) -> JsonSchema | None:
160179 # If the schema is not a JSON Schema, parse it as Picoschema.
161180 return await self .parse_pico (schema )
162181
182+ def parse_sync (self , schema : Any ) -> JsonSchema | None :
183+ """Parses a schema synchronously (no schema resolution support).
184+
185+ Args:
186+ schema: The schema definition to parse.
187+
188+ Returns:
189+ The resulting JSON Schema, or None if the input is None.
190+
191+ Raises:
192+ ValueError: If the schema references a named type that requires resolution.
193+ """
194+ if not schema :
195+ return None
196+
197+ if isinstance (schema , str ):
198+ type_name , description = extract_description (schema )
199+ if type_name in JSON_SCHEMA_SCALAR_TYPES :
200+ out : JsonSchema = {'type' : type_name }
201+ if description :
202+ out ['description' ] = description
203+ return out
204+ raise ValueError (f"Picoschema: unsupported scalar type '{ type_name } '. Use async version for schema resolution." )
205+
206+ if isinstance (schema , dict ) and _is_json_schema (schema ):
207+ return cast (JsonSchema , schema )
208+
209+ if isinstance (schema , dict ) and isinstance (schema .get ('properties' ), dict ):
210+ return {** cast (JsonSchema , schema ), 'type' : 'object' }
211+
212+ return self .parse_pico_sync (schema )
213+
163214 async def parse_pico (self , obj : Any , path : list [str ] | None = None ) -> JsonSchema :
164215 """Recursively parses a Picoschema object or string fragment.
165216
@@ -244,6 +295,89 @@ async def parse_pico(self, obj: Any, path: list[str] | None = None) -> JsonSchem
244295 del schema ['required' ]
245296 return schema
246297
298+ def parse_pico_sync (self , obj : Any , path : list [str ] | None = None ) -> JsonSchema :
299+ """Recursively parses a Picoschema object synchronously.
300+
301+ Args:
302+ obj: The Picoschema fragment (dict or string).
303+ path: The current path within the schema structure (for error reporting).
304+
305+ Returns:
306+ The JSON Schema representation of the fragment.
307+
308+ Raises:
309+ ValueError: If the schema references a named type or is invalid.
310+ """
311+ if path is None :
312+ path = []
313+
314+ if isinstance (obj , str ):
315+ type_name , description = extract_description (obj )
316+ if type_name not in JSON_SCHEMA_SCALAR_TYPES :
317+ raise ValueError (f"Picoschema: unsupported scalar type '{ type_name } '. Use async version for schema resolution." )
318+
319+ if type_name == 'any' :
320+ return {'description' : description } if description else {}
321+
322+ return {'type' : type_name , 'description' : description } if description else {'type' : type_name }
323+ elif not isinstance (obj , dict ):
324+ raise ValueError (f'Picoschema: only consists of objects and strings. Got: { obj } ' )
325+
326+ schema : dict [str , Any ] = {
327+ 'type' : 'object' ,
328+ 'properties' : {},
329+ 'required' : [],
330+ 'additionalProperties' : False ,
331+ }
332+
333+ for key , value in obj .items ():
334+ if key == WILDCARD_PROPERTY_NAME :
335+ schema ['additionalProperties' ] = self .parse_pico_sync (value , [* path , key ])
336+ continue
337+
338+ parts = key .split ('(' )
339+ name = parts [0 ]
340+ type_info = parts [1 ][:- 1 ] if len (parts ) > 1 else None
341+ is_optional = name .endswith ('?' )
342+ property_name = name [:- 1 ] if is_optional else name
343+
344+ if not is_optional :
345+ schema ['required' ].append (property_name )
346+
347+ if not type_info :
348+ prop = self .parse_pico_sync (value , [* path , key ])
349+ if is_optional and isinstance (prop .get ('type' ), str ):
350+ prop ['type' ] = [prop ['type' ], 'null' ]
351+ schema ['properties' ][property_name ] = prop
352+ continue
353+
354+ type_name , description = extract_description (type_info )
355+ if type_name == 'array' :
356+ prop = self .parse_pico_sync (value , [* path , key ])
357+ schema ['properties' ][property_name ] = {
358+ 'type' : ['array' , 'null' ] if is_optional else 'array' ,
359+ 'items' : prop ,
360+ }
361+ elif type_name == 'object' :
362+ prop = self .parse_pico_sync (value , [* path , key ])
363+ if is_optional :
364+ prop ['type' ] = [prop ['type' ], 'null' ]
365+ schema ['properties' ][property_name ] = prop
366+ elif type_name == 'enum' :
367+ prop = {'enum' : value }
368+ if is_optional and None not in prop ['enum' ]:
369+ prop ['enum' ].append (None )
370+ schema ['properties' ][property_name ] = prop
371+ else :
372+ raise ValueError (f"Picoschema: parenthetical types must be 'object' or 'array', got: { type_name } " )
373+
374+ if description :
375+ schema ['properties' ][property_name ]['description' ] = description
376+
377+ if not schema ['required' ]:
378+ del schema ['required' ]
379+ return schema
380+
247381
248382def extract_description (input_str : str ) -> tuple [str , str | None ]:
249383 """Extracts the type/name and optional description from a Picoschema string.
0 commit comments