Skip to content

Commit 9696699

Browse files
committed
Tighten dataclass runtime and Windows fuzz harness
1 parent 876c0b7 commit 9696699

5 files changed

Lines changed: 93 additions & 6 deletions

File tree

pybindings/jsoncompat/codegen/dataclasses.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@ def _jsoncompat_construct_value(annotation: Any, value: Any) -> Any:
390390
if isinstance(value, bool):
391391
return value
392392
raise TypeError(f"expected bool, got {type(value).__name__}")
393-
if annotation is type(None):
393+
if annotation is None or annotation is type(None):
394394
if value is None:
395395
return None
396396
raise TypeError(f"expected null, got {type(value).__name__}")
@@ -408,14 +408,26 @@ def _jsoncompat_construct_value(annotation: Any, value: Any) -> Any:
408408
_jsoncompat_construct_value(item_annotation, item)
409409
for item in value_items
410410
]
411+
if origin is dict:
412+
key_annotation, value_annotation = _jsoncompat_dict_annotations(annotation)
413+
if not isinstance(value, dict):
414+
raise TypeError(f"expected dict, got {type(value).__name__}")
415+
value_object = cast(dict[Any, Any], value)
416+
return {
417+
_jsoncompat_construct_value(key_annotation, key): _jsoncompat_construct_value(
418+
value_annotation,
419+
item,
420+
)
421+
for key, item in value_object.items()
422+
}
411423
if origin in {types.UnionType, Union}:
412424
return _jsoncompat_construct_union(get_args(annotation), value)
413425
if origin is Literal:
414426
if value in get_args(annotation):
415427
return value
416428
raise TypeError(f"expected one of {get_args(annotation)!r}, got {value!r}")
417429

418-
return value
430+
raise TypeError(f"unsupported runtime annotation {annotation!r}")
419431

420432

421433
def _jsoncompat_construct_union(branches: tuple[Any, ...], value: Any) -> Any:
@@ -504,6 +516,13 @@ def _jsoncompat_extra_value_annotation(annotation: Any) -> Any:
504516
return Any
505517

506518

519+
def _jsoncompat_dict_annotations(annotation: Any) -> tuple[Any, Any]:
520+
args = get_args(annotation)
521+
if len(args) == 2:
522+
return args[0], args[1]
523+
return Any, Any
524+
525+
507526
def _jsoncompat_validate_python_value(annotation: Any, value: Any) -> None:
508527
if annotation is Any:
509528
return
@@ -527,7 +546,7 @@ def _jsoncompat_validate_python_value(annotation: Any, value: Any) -> None:
527546
if isinstance(value, bool):
528547
return
529548
raise TypeError(f"expected bool, got {type(value).__name__}")
530-
if annotation is type(None):
549+
if annotation is None or annotation is type(None):
531550
if value is None:
532551
return
533552
raise TypeError(f"expected null, got {type(value).__name__}")
@@ -548,6 +567,15 @@ def _jsoncompat_validate_python_value(annotation: Any, value: Any) -> None:
548567
for item in value_items:
549568
_jsoncompat_validate_python_value(item_annotation, item)
550569
return
570+
if origin is dict:
571+
key_annotation, value_annotation = _jsoncompat_dict_annotations(annotation)
572+
if not isinstance(value, dict):
573+
raise TypeError(f"expected dict, got {type(value).__name__}")
574+
value_object = cast(dict[Any, Any], value)
575+
for key, item in value_object.items():
576+
_jsoncompat_validate_python_value(key_annotation, key)
577+
_jsoncompat_validate_python_value(value_annotation, item)
578+
return
551579
if origin in {types.UnionType, Union}:
552580
for branch in get_args(annotation):
553581
try:
@@ -561,6 +589,8 @@ def _jsoncompat_validate_python_value(annotation: Any, value: Any) -> None:
561589
return
562590
raise TypeError(f"expected one of {get_args(annotation)!r}, got {value!r}")
563591

592+
raise TypeError(f"unsupported runtime annotation {annotation!r}")
593+
564594

565595
def _jsoncompat_new_unchecked[JSONCOMPAT_MODEL_T: DataclassModel](
566596
model_type: type[JSONCOMPAT_MODEL_T],

tests/dataclasses_backcompat.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,14 +233,18 @@ fn assert_codegen_error_snapshot(
233233
});
234234
let actual = format!("{error}\n");
235235
assert_eq!(
236-
expected,
237-
actual,
236+
normalized_newlines(&expected),
237+
normalized_newlines(&actual),
238238
"dataclass backcompat example codegen error snapshot is stale for {case_name}/{side}: {}",
239239
snapshot_path.display(),
240240
);
241241
Ok(())
242242
}
243243

244+
fn normalized_newlines(contents: &str) -> String {
245+
contents.replace("\r\n", "\n")
246+
}
247+
244248
fn round_trip_valid(
245249
case_name: &str,
246250
side: &str,

tests/dataclasses_fuzz.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ fn assert_codegen_error_snapshot(
7979
)
8080
})?;
8181
let actual = format!("{error}\n");
82-
if expected != actual {
82+
if normalized_newlines(&expected) != normalized_newlines(&actual) {
8383
return Err(format!(
8484
"dataclass fuzz codegen error snapshot is stale for schema #{} in {}: {}\n\nexpected:\n{}\nactual:\n{}",
8585
schema_case.index,
@@ -92,3 +92,7 @@ fn assert_codegen_error_snapshot(
9292
}
9393
Ok(())
9494
}
95+
96+
fn normalized_newlines(contents: &str) -> String {
97+
contents.replace("\r\n", "\n")
98+
}

tests/python_dataclasses_runtime.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,47 @@ for factory in (
7373
raise AssertionError("invalid dataclass payload was accepted")
7474
7575
76+
@dataclass(frozen=True, slots=True, kw_only=True)
77+
class AuditContext(DataclassModel):
78+
__jsoncompat_schema__: ClassVar[str] = '{"type":"object","properties":{"tags":{"type":"object","additionalProperties":{"type":"string"}}},"required":["tags"],"additionalProperties":false}'
79+
80+
tags: dict[str, str] = field("tags")
81+
82+
83+
context = AuditContext(tags={"team": "schema"})
84+
assert context.to_json() == {"tags": {"team": "schema"}}
85+
assert AuditContext.from_json({"tags": {"team": "schema"}}).tags == {
86+
"team": "schema"
87+
}
88+
89+
for factory in (
90+
lambda: AuditContext(tags="oops"),
91+
lambda: AuditContext(tags={1: "schema"}),
92+
lambda: AuditContext(tags={"team": 1}),
93+
):
94+
try:
95+
factory()
96+
except (TypeError, ValueError):
97+
pass
98+
else:
99+
raise AssertionError("mapping annotations accepted an invalid value")
100+
101+
102+
@dataclass(frozen=True, slots=True, kw_only=True)
103+
class UnsupportedRuntimeAnnotation(DataclassModel):
104+
__jsoncompat_schema__: ClassVar[str] = '{"type":"object","properties":{"tags":{"type":"array","items":{"type":"string"}}},"required":["tags"],"additionalProperties":false}'
105+
106+
tags: tuple[str, ...] = field("tags")
107+
108+
109+
try:
110+
UnsupportedRuntimeAnnotation(tags=("schema",))
111+
except TypeError as error:
112+
assert "unsupported runtime annotation" in str(error)
113+
else:
114+
raise AssertionError("unsupported runtime annotations must fail loudly")
115+
116+
76117
@dataclass(frozen=True, slots=True, kw_only=True)
77118
class ProfileRoot(DataclassRootModel):
78119
__jsoncompat_schema__: ClassVar[str] = '{"type":"string","minLength":1}'

tests/support/python_env.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use std::process::Command;
55
pub fn python_command() -> Command {
66
let mut command = Command::new("uv");
77
command.env_remove("VIRTUAL_ENV");
8+
configure_utf8_python_io(&mut command);
89
command
910
.arg("run")
1011
.arg("--project")
@@ -18,6 +19,7 @@ pub fn python_command() -> Command {
1819
pub fn pyright_command() -> Command {
1920
let mut command = Command::new("uv");
2021
command.env_remove("VIRTUAL_ENV");
22+
configure_utf8_python_io(&mut command);
2123
command
2224
.arg("run")
2325
.arg("--project")
@@ -40,6 +42,12 @@ pub fn add_repo_python_path(command: &mut Command) -> &mut Command {
4042
)
4143
}
4244

45+
fn configure_utf8_python_io(command: &mut Command) -> &mut Command {
46+
command
47+
.env("PYTHONUTF8", "1")
48+
.env("PYTHONIOENCODING", "utf-8")
49+
}
50+
4351
fn repo_pybindings_path() -> PathBuf {
4452
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("pybindings")
4553
}

0 commit comments

Comments
 (0)