Skip to content

Commit 1c89b62

Browse files
committed
feat(python): Support for decimal.Decimal type in both schemas and instances
Signed-off-by: Dmitry Dygalo <[email protected]>
1 parent eff59e8 commit 1c89b62

File tree

5 files changed

+72
-2
lines changed

5 files changed

+72
-2
lines changed

crates/jsonschema-py/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Support for `decimal.Decimal` type in both schemas and instances. [#319](https://github.com/Stranger6667/jsonschema/issues/319)
8+
59
### Performance
610

711
- `jsonschema_rs.validate()` and `Validator.validate()` run 5–10% faster in some workloads thanks to slimmer `ValidationError` objects.

crates/jsonschema-py/python/jsonschema_rs/__init__.pyi

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ from typing import Any, Callable, List, Protocol, TypeAlias, TypeVar, TypedDict,
44

55
_SchemaT = TypeVar("_SchemaT", bool, dict[str, Any])
66
_FormatFunc = TypeVar("_FormatFunc", bound=Callable[[str], bool])
7-
JSONType: TypeAlias = dict[str, Any] | list | str | int | float | bool | None
8-
JSONPrimitive: TypeAlias = str | int | float | bool | None
7+
JSONType: TypeAlias = dict[str, Any] | list | str | int | float | Decimal | bool | None
8+
JSONPrimitive: TypeAlias = str | int | float | Decimal | bool | None
99

1010
class EvaluationAnnotation(TypedDict):
1111
schemaLocation: str

crates/jsonschema-py/src/ser.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ pub enum ObjectType {
3636
Dict,
3737
Tuple,
3838
Enum,
39+
Decimal,
3940
Unknown,
4041
}
4142

@@ -203,6 +204,8 @@ pub fn get_object_type(object_type: *mut pyo3::ffi::PyTypeObject) -> ObjectType
203204
ObjectType::Float
204205
} else if object_type == unsafe { types::NONE_TYPE } {
205206
ObjectType::None
207+
} else if object_type == unsafe { types::DECIMAL_TYPE } {
208+
ObjectType::Decimal
206209
} else if is_dict_subclass(object_type) {
207210
ObjectType::Dict
208211
} else if object_type == unsafe { types::TUPLE_TYPE } {
@@ -448,6 +451,33 @@ impl Serialize for SerializePyObject {
448451
sequence.end()
449452
}
450453
}
454+
ObjectType::Decimal => {
455+
// Convert Decimal to string and parse as serde_json::Number
456+
// With arbitrary_precision enabled, this preserves exact decimal precision
457+
let str_obj = unsafe { pyo3::ffi::PyObject_Str(self.object) };
458+
if str_obj.is_null() {
459+
return Err(ser::Error::custom("Failed to convert Decimal to string"));
460+
}
461+
let mut str_size: pyo3::ffi::Py_ssize_t = 0;
462+
let ptr = unsafe { pyo3::ffi::PyUnicode_AsUTF8AndSize(str_obj, &raw mut str_size) };
463+
if ptr.is_null() {
464+
unsafe { pyo3::ffi::Py_DecRef(str_obj) };
465+
return Err(ser::Error::custom("Failed to get UTF-8 representation"));
466+
}
467+
let slice = unsafe {
468+
std::str::from_utf8_unchecked(std::slice::from_raw_parts(
469+
ptr.cast::<u8>(),
470+
str_size as usize,
471+
))
472+
};
473+
let result = if let Ok(num) = serde_json::Number::from_str(slice) {
474+
serializer.serialize_some(&num)
475+
} else {
476+
Err(ser::Error::custom("Failed to parse Decimal as number"))
477+
};
478+
unsafe { pyo3::ffi::Py_DecRef(str_obj) };
479+
result
480+
}
451481
ObjectType::Enum => {
452482
let value = unsafe { PyObject_GetAttr(self.object, types::VALUE_STR) };
453483
#[allow(clippy::arithmetic_side_effects)]

crates/jsonschema-py/src/types.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ pub static mut DICT_TYPE: *mut PyTypeObject = std::ptr::null_mut::<PyTypeObject>
2020
pub static mut TUPLE_TYPE: *mut PyTypeObject = std::ptr::null_mut::<PyTypeObject>();
2121
pub static mut ENUM_TYPE: *mut PyTypeObject = std::ptr::null_mut::<PyTypeObject>();
2222
pub static mut ENUM_BASE: *mut PyTypeObject = std::ptr::null_mut::<PyTypeObject>();
23+
pub static mut DECIMAL_TYPE: *mut PyTypeObject = std::ptr::null_mut::<PyTypeObject>();
2324
pub static mut VALUE_STR: *mut PyObject = std::ptr::null_mut::<PyObject>();
2425

2526
static INIT: PyOnceLock<()> = PyOnceLock::new();
@@ -41,6 +42,18 @@ fn look_up_enum_types(py: Python<'_>) -> (*mut PyTypeObject, *mut PyTypeObject)
4142
(enum_meta.as_type_ptr(), enum_base.as_type_ptr())
4243
}
4344

45+
fn look_up_decimal_type(py: Python<'_>) -> *mut PyTypeObject {
46+
let module = py
47+
.import("decimal")
48+
.expect("failed to import the stdlib decimal module");
49+
let decimal_type = module
50+
.getattr("Decimal")
51+
.expect("decimal.Decimal is missing")
52+
.cast_into::<PyType>()
53+
.expect("decimal.Decimal is not a type");
54+
decimal_type.as_type_ptr()
55+
}
56+
4457
/// Set empty type object pointers with their actual values.
4558
/// We need these Python-side type objects for direct comparison during conversion to serde types
4659
/// NOTE. This function should be called before any serialization logic
@@ -58,6 +71,7 @@ pub fn init(py: Python<'_>) {
5871
let (enum_meta, enum_base) = look_up_enum_types(py);
5972
ENUM_TYPE = enum_meta;
6073
ENUM_BASE = enum_base;
74+
DECIMAL_TYPE = look_up_decimal_type(py);
6175
VALUE_STR = ffi::PyUnicode_InternFromString(c"value".as_ptr());
6276
});
6377
}

crates/jsonschema-py/tests-py/test_jsonschema.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -848,3 +848,25 @@ def test_float_precision_from_string_schema(instance, expected):
848848
# Schema values are no longer converted to f64, maintaining better precision
849849
validator = validator_for('{"multipleOf": 0.01}')
850850
assert validator.is_valid(instance) == expected
851+
852+
853+
@pytest.mark.parametrize(
854+
"instance, expected",
855+
[
856+
(Decimal("10.5"), True),
857+
(Decimal("5.5"), False),
858+
(Decimal("99999999999999999999.123456789"), True),
859+
],
860+
)
861+
def test_decimal_support(instance, expected):
862+
# Python's Decimal type should be supported, preserving precision
863+
validator = validator_for({"type": "number", "minimum": 10})
864+
assert validator.is_valid(instance) == expected
865+
866+
867+
def test_decimal_in_schema():
868+
# Test that Decimal can be used in schema definitions too
869+
validator = validator_for({"type": "number", "minimum": Decimal("10.5")})
870+
assert validator.is_valid(11) is True
871+
assert validator.is_valid(10) is False
872+
assert validator.is_valid(Decimal("10.6")) is True

0 commit comments

Comments
 (0)