Skip to content

Commit d1002ea

Browse files
committed
chore(py): Add sync method for pico => json schema conversion
1 parent e91a757 commit d1002ea

File tree

2 files changed

+258
-0
lines changed

2 files changed

+258
-0
lines changed

python/dotpromptz/src/dotpromptz/picoschema.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
92111
class 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

248382
def extract_description(input_str: str) -> tuple[str, str | None]:
249383
"""Extracts the type/name and optional description from a Picoschema string.

python/dotpromptz/tests/dotpromptz/picoschema_test.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,130 @@ async def test_invalid_input_type(self) -> None:
322322
await self.parser.parse_pico(123)
323323

324324

325+
class TestPicoschemaParserSync(unittest.TestCase):
326+
"""Synchronous picoschema parser tests."""
327+
328+
def setUp(self) -> None:
329+
"""Set up the test case."""
330+
self.parser = picoschema.PicoschemaParser()
331+
332+
def test_parse_sync_none(self) -> None:
333+
"""Test parsing None returns None."""
334+
self.assertIsNone(self.parser.parse_sync(None))
335+
336+
def test_parse_sync_scalar_type(self) -> None:
337+
"""Test parsing a scalar type string."""
338+
result = self.parser.parse_sync('string')
339+
self.assertEqual(result, {'type': 'string'})
340+
341+
def test_parse_sync_scalar_with_description(self) -> None:
342+
"""Test parsing a scalar type string with description."""
343+
result = self.parser.parse_sync('string, a string')
344+
self.assertEqual(result, {'type': 'string', 'description': 'a string'})
345+
346+
def test_parse_sync_object_schema(self) -> None:
347+
"""Test parsing a standard JSON object schema."""
348+
schema = {'type': 'object', 'properties': {'name': {'type': 'string'}}}
349+
result = self.parser.parse_sync(schema)
350+
self.assertEqual(result, schema)
351+
352+
def test_parse_sync_picoschema_object(self) -> None:
353+
"""Test parsing a picoschema object."""
354+
schema = {'name': 'string', 'age?': 'integer'}
355+
expected = {
356+
'type': 'object',
357+
'properties': {
358+
'name': {'type': 'string'},
359+
'age': {'type': ['integer', 'null']},
360+
},
361+
'required': ['name'],
362+
'additionalProperties': False,
363+
}
364+
result = self.parser.parse_sync(schema)
365+
self.assertEqual(result, expected)
366+
367+
def test_parse_sync_named_type_raises(self) -> None:
368+
"""Test that parsing a named type raises ValueError."""
369+
with self.assertRaises(ValueError) as context:
370+
self.parser.parse_sync('CustomType')
371+
self.assertIn('unsupported scalar type', str(context.exception))
372+
self.assertIn('Use async version', str(context.exception))
373+
374+
def test_parse_pico_sync_array(self) -> None:
375+
"""Test parsing array type synchronously."""
376+
schema = {'items(array)': 'string'}
377+
expected = {
378+
'type': 'object',
379+
'properties': {'items': {'type': 'array', 'items': {'type': 'string'}}},
380+
'required': ['items'],
381+
'additionalProperties': False,
382+
}
383+
result = self.parser.parse_pico_sync(schema)
384+
self.assertEqual(result, expected)
385+
386+
def test_parse_pico_sync_nested_object(self) -> None:
387+
"""Test parsing nested object synchronously."""
388+
schema = {'user(object)': {'name': 'string', 'email?': 'string'}}
389+
expected = {
390+
'type': 'object',
391+
'properties': {
392+
'user': {
393+
'type': 'object',
394+
'properties': {
395+
'name': {'type': 'string'},
396+
'email': {'type': ['string', 'null']},
397+
},
398+
'required': ['name'],
399+
'additionalProperties': False,
400+
}
401+
},
402+
'required': ['user'],
403+
'additionalProperties': False,
404+
}
405+
result = self.parser.parse_pico_sync(schema)
406+
self.assertEqual(result, expected)
407+
408+
def test_parse_pico_sync_enum(self) -> None:
409+
"""Test parsing enum type synchronously."""
410+
schema = {'status(enum)': ['active', 'inactive']}
411+
expected = {
412+
'type': 'object',
413+
'properties': {'status': {'enum': ['active', 'inactive']}},
414+
'required': ['status'],
415+
'additionalProperties': False,
416+
}
417+
result = self.parser.parse_pico_sync(schema)
418+
self.assertEqual(result, expected)
419+
420+
421+
class TestPicoschemaToJsonSchemaSync(unittest.TestCase):
422+
"""Tests for the top-level sync function."""
423+
424+
def test_basic_schema(self) -> None:
425+
"""Test converting a basic picoschema."""
426+
schema = {'diff': 'string', 'context?': 'string'}
427+
result = picoschema.picoschema_to_json_schema_sync(schema)
428+
expected = {
429+
'type': 'object',
430+
'properties': {
431+
'diff': {'type': 'string'},
432+
'context': {'type': ['string', 'null']},
433+
},
434+
'required': ['diff'],
435+
'additionalProperties': False,
436+
}
437+
self.assertEqual(result, expected)
438+
439+
def test_none_returns_none(self) -> None:
440+
"""Test that None input returns None."""
441+
self.assertIsNone(picoschema.picoschema_to_json_schema_sync(None))
442+
443+
def test_named_type_raises(self) -> None:
444+
"""Test that named types raise ValueError."""
445+
with self.assertRaises(ValueError):
446+
picoschema.picoschema_to_json_schema_sync({'field': 'CustomType'})
447+
448+
325449
class TestExtractDescription(unittest.TestCase):
326450
"""Extract description tests."""
327451

0 commit comments

Comments
 (0)