Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [Python] Fix printf.cont() not applying continuation function when currying (by @dbrattli)

### Added

* [Python] Added support for Pydantic serialization of core numeric and array types (by @dbrattli)

## 5.0.0-alpha.18 - 2025-12-03

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

* [Python] Fix printf.cont() not applying continuation function when currying (by @dbrattli)

### Added

* [Python] Added support for Pydantic serialization of core numeric and array types (by @dbrattli)

## 5.0.0-alpha.17 - 2025-12-03

### Fixed
Expand Down
20 changes: 10 additions & 10 deletions src/fable-library-py/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/fable-library-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ name = "_core"
crate-type = ["cdylib"]

[dependencies]
pyo3 = { version = "0.27.1", features = ["extension-module", "chrono"] }
pyo3 = { version = "0.27.2", features = ["extension-module", "chrono"] }
byteorder = "1.5.0"
chrono = "0.4.42"
regex = "1.12.1"
Expand Down
2 changes: 2 additions & 0 deletions src/fable-library-py/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ authors = [
requires-python = ">= 3.10, < 4.0"
readme = "README.md"
license = "MIT License"
dependencies = []

[project.urls]
Homepage = "https://fable.io"
Expand All @@ -19,6 +20,7 @@ dev = [
"hypothesis>=6.131.9,<7",
"pytest-benchmark>=5.1.0,<6",
"pyright>=1.1.401,<2",
"pydantic>=2.12.5",
]

[tool.hatch.build.targets.sdist]
Expand Down
82 changes: 82 additions & 0 deletions src/fable-library-py/src/array.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,88 @@ impl FSharpArray {
iter.into_py_any(py)
}

/// Pydantic v2 integration for schema generation.
///
/// This method is called by Pydantic when building a model that uses FSharpArray.
/// It returns a pydantic-core schema that enables:
/// - Validation of input values (any iterable)
/// - Serialization to JSON-compatible lists
/// - JSON Schema generation for OpenAPI documentation
///
/// The pydantic_core module is imported lazily - pydantic is only required
/// if this method is actually called (i.e., when used in a Pydantic model).
#[classmethod]
#[pyo3(name = "__get_pydantic_core_schema__")]
fn get_pydantic_core_schema(
cls: &Bound<'_, PyType>,
_source_type: &Bound<'_, PyAny>,
_handler: &Bound<'_, PyAny>,
py: Python<'_>,
) -> PyResult<Py<PyAny>> {
// Lazy import of pydantic_core - only fails if pydantic is not installed
// AND this type is used in a Pydantic model
let core_schema = py.import("pydantic_core")?.getattr("core_schema")?;

// Create a validator function that wraps the constructor
let validator_fn = cls.getattr("_pydantic_validator")?;

// Create a serializer function that converts to list
let serializer_fn = cls.getattr("_pydantic_serializer")?;

// Build the serialization schema
let ser_schema = core_schema.call_method1(
"plain_serializer_function_ser_schema",
(serializer_fn,),
)?;

// Create a list schema as the base - this enables JSON Schema generation
let list_schema = core_schema.call_method0("list_schema")?;

// Wrap with validator function, keeping list_schema for JSON Schema
let validator_kwargs = pyo3::types::PyDict::new(py);
validator_kwargs.set_item("serialization", ser_schema)?;

let validator_schema = core_schema.call_method(
"no_info_after_validator_function",
(validator_fn, list_schema),
Some(&validator_kwargs),
)?;

Ok(validator_schema.unbind())
}

/// Pydantic validator function.
///
/// Called by Pydantic during validation to convert input values to FSharpArray.
/// Accepts any iterable.
#[staticmethod]
#[pyo3(name = "_pydantic_validator")]
fn pydantic_validator(py: Python<'_>, value: &Bound<'_, PyAny>) -> PyResult<Self> {
// If already a FSharpArray, return a clone
if let Ok(array) = value.extract::<PyRef<'_, FSharpArray>>() {
return Ok(array.clone());
}
// Otherwise create from iterable
FSharpArray::new(py, Some(value), None)
}

/// Pydantic serializer function.
///
/// Called by Pydantic during serialization to convert FSharpArray to a
/// JSON-compatible list.
#[staticmethod]
#[pyo3(name = "_pydantic_serializer")]
fn pydantic_serializer(py: Python<'_>, instance: &Self) -> PyResult<Py<PyAny>> {
// Convert FSharpArray to a Python list for JSON serialization
let len = instance.storage.len();
let list = PyList::empty(py);
for i in 0..len {
let item = instance.storage.get(py, i)?;
list.append(item)?;
}
Ok(list.into())
}

pub fn __bytes__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
match &self.storage {
// For UInt8/Int8 arrays, we can create bytes directly
Expand Down
95 changes: 95 additions & 0 deletions src/fable-library-py/src/floats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,101 @@ macro_rules! float_variant {
self.0
}

/// Pydantic v2 integration for schema generation.
///
/// This method is called by Pydantic when building a model that uses this type.
/// It returns a pydantic-core schema that enables:
/// - Validation of input values
/// - Serialization to JSON-compatible floats
/// - JSON Schema generation for OpenAPI documentation
///
/// The pydantic_core module is imported lazily - pydantic is only required
/// if this method is actually called (i.e., when used in a Pydantic model).
///
/// The schema uses a chained validator approach:
/// 1. Before validator: Extract Python float from custom types
/// 2. float_schema: Pydantic's native float validation (enables JSON Schema)
/// 3. After validator: Wrap result back in our custom type
#[classmethod]
#[pyo3(name = "__get_pydantic_core_schema__")]
fn get_pydantic_core_schema(
cls: &Bound<'_, pyo3::types::PyType>,
_source_type: &Bound<'_, PyAny>,
_handler: &Bound<'_, PyAny>,
py: Python<'_>,
) -> PyResult<Py<PyAny>> {
// Lazy import of pydantic_core - only fails if pydantic is not installed
// AND this type is used in a Pydantic model
let core_schema = py.import("pydantic_core")?.getattr("core_schema")?;

// Get all validator/serializer functions from the class
let extractor_fn = cls.getattr("_pydantic_extractor")?;
let validator_fn = cls.getattr("_pydantic_validator")?;
let serializer_fn = cls.getattr("_pydantic_serializer")?;

// Build the serialization schema
let ser_schema = core_schema
.call_method1("plain_serializer_function_ser_schema", (serializer_fn,))?;

// Create a float schema as the base - this enables JSON Schema generation
let float_schema = core_schema.call_method0("float_schema")?;

// Build the schema chain:
// 1. After validator wraps float_schema, converting Python float -> our type
let after_kwargs = pyo3::types::PyDict::new(py);
after_kwargs.set_item("serialization", ser_schema)?;

let after_schema = core_schema.call_method(
"no_info_after_validator_function",
(validator_fn, float_schema),
Some(&after_kwargs),
)?;

// 2. Before validator extracts Python float from our type
let full_schema = core_schema.call_method1(
"no_info_before_validator_function",
(extractor_fn, after_schema),
)?;

Ok(full_schema.unbind())
}

/// Pydantic extractor function (before validator).
///
/// Called by Pydantic before float_schema validation to extract the underlying
/// Python float from our custom type.
#[staticmethod]
#[pyo3(name = "_pydantic_extractor")]
fn pydantic_extractor(value: &Bound<'_, PyAny>) -> PyResult<Py<PyAny>> {
let py = value.py();
// If the value has __float__, extract it as a Python float
if value.hasattr("__float__")? {
Ok(value.call_method0("__float__")?.into_pyobject(py).map(|o| o.unbind())?)
} else {
Ok(value.clone().unbind())
}
}

/// Pydantic validator function (after validator).
///
/// Called by Pydantic after float_schema validation to wrap the validated
/// Python float back into our custom type.
#[staticmethod]
#[pyo3(name = "_pydantic_validator")]
fn pydantic_validator(value: $type) -> Self {
Self(value)
}

/// Pydantic serializer function.
///
/// Called by Pydantic during serialization to convert this type to a
/// JSON-compatible primitive (Python float).
#[staticmethod]
#[pyo3(name = "_pydantic_serializer")]
fn pydantic_serializer(instance: &Self) -> $type {
instance.0
}

// Check if the value is NaN (Not a Number)
pub fn is_nan(&self) -> bool {
self.0.is_nan()
Expand Down
Loading
Loading