Skip to content

Commit bb3f985

Browse files
aschwakpwtxt
authored andcommitted
Fix failing test, add full coverage, docs, examples
Remove compatibility mode
1 parent 35ef0f2 commit bb3f985

7 files changed

Lines changed: 355 additions & 124 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Use the `dottxt` CLI for login, model discovery, and one-off generation.
4545
- CLI reference: [docs/cli.md](docs/cli.md)
4646
- Client reference: [docs/client.md](docs/client.md)
4747
- Schema validation: `dottxt schema check schema.json`
48+
- Pydantic conditionals: [docs/pydantic.md](docs/pydantic.md)
4849

4950
## Client Surfaces
5051

@@ -275,6 +276,8 @@ The compatibility surface expects the wrapped OpenAI-style
275276
- [Use a dataclass type to generate](examples/generate_dataclass.py)
276277
- [Use a TypedDict type to generate](examples/generate_typed_dict.py)
277278
- [Use a Genson schema builder to generate](examples/generate_genson.py)
279+
- [Pydantic conditionals: value-based `when`](examples/pydantic_conditionals_when.py)
280+
- [Pydantic conditionals: presence-based `when_present`](examples/pydantic_conditionals_when_present.py)
278281
- [List available models](examples/list_models.py)
279282
- [OpenAI-Compatible chat completions](examples/openai_chat_completions.py)
280283
- [Stream fields as they arrive](examples/stream_field_printer.py)

docs/pydantic.md

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Pydantic Conditional Schemas
2+
3+
`dottxt.pydantic_conditionals` adds JSON Schema conditionals to Pydantic models.
4+
Use it when you need schema-level `if`/`then`/`else` or dependency rules that are
5+
enforced through a Pydantic response model passed to `DotTxt.generate(...)`.
6+
7+
## Imports
8+
9+
`ConditionalModel` must be listed before `BaseModel` in class inheritance:
10+
`class MyModel(ConditionalModel, BaseModel): ...`.
11+
12+
```python
13+
from typing import Annotated, ClassVar
14+
15+
from pydantic import BaseModel, Field
16+
17+
from dottxt import DotTxt
18+
from dottxt.pydantic_conditionals import (
19+
ConditionalModel,
20+
RequiredWith,
21+
when,
22+
when_present,
23+
)
24+
```
25+
26+
## Pattern 1: Value-Based Conditional (`when`) with `generate(...)`
27+
28+
Use `when(...)` to trigger constraints when a field has a specific value.
29+
30+
```python
31+
class Address(ConditionalModel, BaseModel):
32+
country: str
33+
postal_code: str | None = None
34+
35+
model_conditions: ClassVar = (
36+
when(country="USA")
37+
.require("postal_code")
38+
.constrain(postal_code=Field(pattern=r"^\d{5}$")),
39+
)
40+
41+
42+
client = DotTxt()
43+
result = client.generate(
44+
model="openai/gpt-oss-20b",
45+
input=(
46+
"Return a JSON object for a US shipping address. "
47+
"Set country to USA and include a valid 5-digit postal_code."
48+
),
49+
response_format=Address,
50+
)
51+
print(result.model_dump())
52+
```
53+
54+
## Pattern 2: Presence-Based Conditional (`when_present`) with `generate(...)`
55+
56+
Use `when_present(...)` to trigger constraints when a field exists, independent
57+
of its value.
58+
59+
```python
60+
class Payment(ConditionalModel, BaseModel):
61+
credit_card: str | None = None
62+
billing_address: str | None = None
63+
64+
model_conditions: ClassVar = (
65+
when_present("credit_card").require("billing_address"),
66+
)
67+
68+
69+
client = DotTxt()
70+
result = client.generate(
71+
model="openai/gpt-oss-20b",
72+
input=(
73+
"Return a JSON object representing a payment payload. "
74+
"Include credit_card and billing_address fields."
75+
),
76+
response_format=Payment,
77+
)
78+
print(result.model_dump())
79+
```
80+
81+
## Pattern 3: Annotation-Based Dependency (`RequiredWith`)
82+
83+
Use `RequiredWith` when one field must be present whenever another field is
84+
present.
85+
86+
```python
87+
class FileOperation(ConditionalModel, BaseModel):
88+
content: str | None = None
89+
create_parents: Annotated[bool | None, RequiredWith("content")] = None
90+
```
91+
92+
## Runnable Examples
93+
94+
- [examples/pydantic_conditionals_when.py](../examples/pydantic_conditionals_when.py)
95+
- [examples/pydantic_conditionals_when_present.py](../examples/pydantic_conditionals_when_present.py)
96+
97+
## References
98+
99+
- JSON Schema conditionals: <https://json-schema.org/understanding-json-schema/reference/conditionals>
100+
- JSON Schema object dependencies: <https://json-schema.org/understanding-json-schema/reference/conditionals#dependentrequired>
101+
- Pydantic JSON schema docs: <https://docs.pydantic.dev/latest/concepts/json_schema/>
102+
- Pydantic `Field` constraints: <https://docs.pydantic.dev/latest/concepts/fields/>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
from typing import ClassVar
4+
5+
from pydantic import BaseModel, Field
6+
7+
from dottxt import DotTxt
8+
from dottxt.pydantic_conditionals import ConditionalModel, when
9+
10+
11+
class Address(ConditionalModel, BaseModel):
12+
country: str
13+
postal_code: str | None = None
14+
15+
model_conditions: ClassVar = (
16+
when(country="USA")
17+
.require("postal_code")
18+
.constrain(postal_code=Field(pattern=r"^\d{5}$")),
19+
)
20+
21+
22+
if __name__ == "__main__":
23+
client = DotTxt()
24+
result = client.generate(
25+
model="openai/gpt-oss-20b",
26+
input=(
27+
"Return a JSON object for a US shipping address. "
28+
"Set country to USA and include a valid 5-digit postal_code."
29+
),
30+
response_format=Address,
31+
)
32+
print(result)
33+
print(result.model_dump())
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
from typing import ClassVar
4+
5+
from pydantic import BaseModel
6+
7+
from dottxt import DotTxt
8+
from dottxt.pydantic_conditionals import ConditionalModel, when_present
9+
10+
11+
class Payment(ConditionalModel, BaseModel):
12+
credit_card: str | None = None
13+
billing_address: str | None = None
14+
15+
model_conditions: ClassVar = (
16+
when_present("credit_card").require("billing_address"),
17+
)
18+
19+
20+
if __name__ == "__main__":
21+
client = DotTxt()
22+
result = client.generate(
23+
model="openai/gpt-oss-20b",
24+
input=(
25+
"Return a JSON object representing a payment payload. "
26+
"Include credit_card and billing_address fields."
27+
),
28+
response_format=Payment,
29+
)
30+
print(result)
31+
print(result.model_dump())

src/dottxt/pydantic_conditionals/compatibility.py

Lines changed: 0 additions & 83 deletions
This file was deleted.

src/dottxt/pydantic_conditionals/conditional.py

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from pydantic import BaseModel
1818
from pydantic.annotated_handlers import GetJsonSchemaHandler
1919

20-
from .compatibility import apply_compatibility_mode
2120
from .constraint import Constraint, build_constraints, compute_constraints
2221

2322
CONDITIONALS_REF_MARKER = "$$ref"
@@ -67,7 +66,7 @@ def _normalize_require_fields(field: str | tuple[str] | list[str]) -> list[str]:
6766
return list(field)
6867
if isinstance(field, list):
6968
return field
70-
raise ValueError("Parameter should be list or str.")
69+
raise ValueError("Parameter must be a list or str.")
7170

7271

7372
class QueryBuilder:
@@ -137,8 +136,8 @@ def __and__(self, other: QueryBuilder | DependentSchemaBuilder) -> QueryBuilder:
137136
and self.conditions
138137
):
139138
raise ValueError(
140-
"Canot combine wheres specified with Schemas with ones specified "
141-
"with conditions."
139+
"Cannot combine where clauses specified with schemas with "
140+
"ones specified with conditions."
142141
)
143142

144143
conditions = self.conditions[0] if len(self.conditions) == 1 else {}
@@ -179,8 +178,8 @@ def __or__(self, other: QueryBuilder | DependentSchemaBuilder) -> QueryBuilder:
179178
and self.conditions
180179
):
181180
raise ValueError(
182-
"Canot combine wheres specified with Schemas with ones specified "
183-
"with conditions."
181+
"Cannot combine where clauses specified with schemas with "
182+
"ones specified with conditions."
184183
)
185184

186185
if isinstance(other, DependentSchemaBuilder):
@@ -227,7 +226,7 @@ def then_apply(self, schema: type[BaseModel]) -> Self:
227226

228227
def then_apply_only(self, schema: type[BaseModel]) -> Self:
229228
"""Mixes in this schema when the condition is met (``then`` clause).
230-
"additionalPropertes" is set to false!
229+
``additionalProperties`` is set to ``False``.
231230
232231
This does not combine with ``constrain`` or other ``then_apply`` methods.
233232
The last ``then_apply`` will take precedence.
@@ -254,7 +253,7 @@ def else_apply(self, schema: type[BaseModel]) -> Self:
254253

255254
def else_apply_only(self, schema: type[BaseModel]) -> Self:
256255
"""Mixes in this schema when the condition is *not* met (``else`` clause).
257-
"additionalPropertes" is set to false!
256+
``additionalProperties`` is set to ``False``.
258257
259258
This does not combine with ``otherwise`` or other ``else_apply`` methods.
260259
The last ``else_apply`` will take precedence.
@@ -561,22 +560,10 @@ def __get_pydantic_json_schema__(
561560
def model_json_schema(
562561
cls,
563562
*args,
564-
compatibility_mode: str | bool = False,
565563
**kwargs: Any,
566564
) -> dict[str, Any]:
567-
"""Generate JSON Schema for this model, applying compatibility mode if needed.
568-
569-
Args:
570-
compatibility_mode: Controls schema conversion.
571-
``False`` (default) -- no conversion.
572-
``True`` -- full conversion (dependentRequired/Schemas -> if/then/else).
573-
to if/then/else only.
574-
"""
565+
"""Generate JSON Schema for this model."""
575566
result = super().model_json_schema(*args, **kwargs) # type: ignore[misc]
576567
result = _remove_conditionals_ref_marker(result)
577568

578-
mode = compatibility_mode or getattr(cls, "model_compatibility_mode", False)
579-
if mode is True:
580-
result = apply_compatibility_mode(result)
581-
582569
return result

0 commit comments

Comments
 (0)