slint-compiler: Add support for generating Python stubs (#10123)

The generated file offers the following two pieces of functionality:

- Convenient front-end to slint.load_file() by loading the .slint file
  (replaces use of auto-loader)
- More importantly: Type information for exported properties, callbacks,
  functions, and globals

On loading, the previously generated API is compared to what's in the
.slint file and if the .slint file was changed in an incompatible way,
an exception is thrown.

A Python test driver is added which performs three steps with each test
case:

1. Generate python stubs and checks that they can be loaded with the
python interpreter and that `uv ty check` passes.
2. Extract expected python API out of pyi sections and compares them.
3. Appends any python code from python sections and runs them (this is
combined with step 1)

Fixes #4136
This commit is contained in:
Simon Hausmann 2025-11-20 17:17:01 +01:00 committed by GitHub
parent 65fb303b4b
commit 16ceb115a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 2286 additions and 33 deletions

View file

@ -44,8 +44,8 @@ accessibility = ["slint-interpreter/accessibility"]
[dependencies]
i-slint-backend-selector = { workspace = true }
i-slint-core = { workspace = true, features = ["tr"] }
slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal"] }
i-slint-compiler = { workspace = true }
slint-interpreter = { workspace = true, features = ["default", "display-diagnostics", "internal", "internal-highlight"] }
i-slint-compiler = { workspace = true, features = ["python"] }
pyo3 = { version = "0.26", features = ["extension-module", "indexmap", "chrono", "abi3-py311"] }
indexmap = { version = "2.1.0" }
chrono = "0.4"

View file

@ -340,6 +340,60 @@ For the common use case of interacting with REST APIs, we recommend the [`aiohtt
- Pipes and sub-processes are only supported on Unix-like platforms.
## Type Hints
[PEP 484](https://peps.python.org/pep-0484/) introduces a standard syntax for type annotations to Python, enabling static analysis for
type checking, refactoring, and code completion. Popular type checkers include [mypy](http://mypy-lang.org/), [Pyre](https://pyre-check.org),
and Astral's [ty](https://docs.astral.sh/ty/).
Use Slint's [slint-compiler](https://pypi.org/project/slint-compiler/) to generate stub `.py` files for `.slint` files, which are annotated with
type information. These replace the need to call `load_file` or any use of `slint.loader`.
1. Create a new project with `uv init`.
2. Add the Slint Python package to your Python project: `uv add slint`
3. Create a file called `app-window.slint`:
```slint
import { Button, VerticalBox } from "std-widgets.slint";
export component AppWindow inherits Window {
in-out property<int> counter: 42;
callback request-increase-value();
VerticalBox {
Text {
text: "Counter: \{root.counter}";
}
Button {
text: "Increase value";
clicked => {
root.request-increase-value();
}
}
}
}
```
4. Run the [slint-compiler](https://pypi.org/project/slint-compiler/) to generate `app_window.py`:
`uvx slint-compiler -f python -o app_window.py app-window.slint`
5. Create a file called `main.py`:
```python
import slint
import app_window
class App(app_window.AppWindow):
@slint.callback
def request_increase_value(self):
self.counter = self.counter + 1
app = App()
app.run()
```
5. Run it with `uv run main.py`
## Third-Party Licenses
For a list of the third-party licenses of all dependencies, see the separate [Third-Party Licenses page](thirdparty.html).

View file

@ -0,0 +1,63 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
use std::path::PathBuf;
use pyo3::prelude::*;
use pyo3_stub_gen::{derive::gen_stub_pyclass, derive::gen_stub_pymethods};
#[gen_stub_pyclass]
#[pyclass(name = "GeneratedAPI", unsendable)]
pub struct PyGeneratedAPI {
pub(crate) path: PathBuf,
pub(crate) module: i_slint_compiler::generator::python::PyModule,
}
#[gen_stub_pymethods]
#[pymethods]
impl PyGeneratedAPI {
#[new]
fn new(path: PathBuf, json: &str) -> PyResult<Self> {
let module = i_slint_compiler::generator::python::PyModule::load_from_json(json)
.map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?;
Ok(Self { path, module })
}
#[staticmethod]
fn compare_generated_vs_actual(generated: &Self, actual: &Self) -> PyResult<()> {
let changed_globals = generated.module.changed_globals(&actual.module);
let changed_components = generated.module.changed_components(&actual.module);
let changed_structs_or_enums = generated.module.changed_structs_or_enums(&actual.module);
let diff = changed_globals.is_some()
|| changed_components.is_some()
|| changed_structs_or_enums.is_some();
let incompatible_changes =
changed_globals.as_ref().map_or(false, |c| c.incompatible_changes())
|| changed_components.as_ref().map_or(false, |c| c.incompatible_changes())
|| changed_structs_or_enums.as_ref().map_or(false, |c| c.incompatible_changes());
if diff {
let slint_file = actual.path.display();
let python_file = generated.path.display();
eprintln!(
r#"Changes detected between {slint_file} and {python_file}
Re-run the slint compiler to re-generate the file, for example:
uxv slint-compiler -f python -o {slint_file} {python_file}
"#,
)
}
if incompatible_changes {
Err(pyo3::exceptions::PyRuntimeError::new_err(format!(
"Incompatible API changes detected between {} and {}",
generated.path.display(),
actual.path.display()
)))
} else {
Ok(())
}
}
}

View file

@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::rc::Rc;
use i_slint_compiler::generator::python::ident;
use pyo3::IntoPyObjectExt;
use pyo3_stub_gen::derive::{gen_stub_pyclass, gen_stub_pyclass_enum, gen_stub_pymethods};
use slint_interpreter::{ComponentHandle, Value};
@ -19,6 +20,7 @@ use pyo3::prelude::*;
use pyo3::types::PyTuple;
use pyo3::PyTraverseError;
use crate::api_match::PyGeneratedAPI;
use crate::errors::{
PyGetPropertyError, PyInvokeError, PyPlatformError, PySetCallbackError, PySetPropertyError,
};
@ -74,7 +76,8 @@ impl Compiler {
}
fn build_from_path(&mut self, py: Python<'_>, path: PathBuf) -> CompilationResult {
CompilationResult::new(spin_on::spin_on(self.compiler.build_from_path(path)), py)
let result = spin_on::spin_on(self.compiler.build_from_path(&path));
CompilationResult::new(result, path, py)
}
fn build_from_source(
@ -83,10 +86,8 @@ impl Compiler {
source_code: String,
path: PathBuf,
) -> CompilationResult {
CompilationResult::new(
spin_on::spin_on(self.compiler.build_from_source(source_code, path)),
py,
)
let result = spin_on::spin_on(self.compiler.build_from_source(source_code, path.clone()));
CompilationResult::new(result, path, py)
}
}
@ -145,12 +146,13 @@ pub enum PyDiagnosticLevel {
pub struct CompilationResult {
result: slint_interpreter::CompilationResult,
type_collection: TypeCollection,
path: PathBuf,
}
impl CompilationResult {
fn new(result: slint_interpreter::CompilationResult, py: Python<'_>) -> Self {
fn new(result: slint_interpreter::CompilationResult, path: PathBuf, py: Python<'_>) -> Self {
let type_collection = TypeCollection::new(&result, py);
Self { result, type_collection }
Self { result, type_collection, path }
}
}
@ -188,14 +190,14 @@ impl CompilationResult {
self.type_collection.struct_to_py(slint_interpreter::Struct::from_iter(
s.fields.iter().map(|(name, field_type)| {
(
name.to_string(),
ident(&name).into(),
slint_interpreter::default_value_for_type(field_type),
)
}),
));
structs.insert(
s.name.slint_name().unwrap().to_string(),
ident(&s.name.slint_name().unwrap()).into(),
struct_instance.into_bound_py_any(py).unwrap(),
);
}
@ -216,6 +218,35 @@ impl CompilationResult {
fn named_exports(&self) -> Vec<(String, String)> {
self.result.named_exports(i_slint_core::InternalToken {}).cloned().collect::<Vec<_>>()
}
#[getter]
fn generated_api(&self) -> PyResult<PyGeneratedAPI> {
let type_loader = self
.result
.components()
.next()
.ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err(
"Cannot generated API for empty slint file",
)
})?
.type_loader();
let doc = type_loader.get_document(&self.path).ok_or_else(|| {
pyo3::exceptions::PyRuntimeError::new_err(
"Failed to load document from cache for API generation",
)
})?;
i_slint_compiler::generator::python::generate_py_module(
doc,
&i_slint_compiler::CompilerConfiguration::new(
i_slint_compiler::generator::OutputFormat::Python,
),
)
.map_err(|e| {
pyo3::exceptions::PyRuntimeError::new_err(format!("Error generating pymodule: {}", e))
})
.map(|module| PyGeneratedAPI { path: self.path.clone(), module })
}
}
#[gen_stub_pyclass]
@ -344,6 +375,7 @@ impl From<i_slint_compiler::langtype::Type> for PyValueType {
| i_slint_compiler::langtype::Type::PhysicalLength
| i_slint_compiler::langtype::Type::LogicalLength
| i_slint_compiler::langtype::Type::Percent
| i_slint_compiler::langtype::Type::Rem
| i_slint_compiler::langtype::Type::UnitProduct(_) => PyValueType::Number,
i_slint_compiler::langtype::Type::String => PyValueType::String,
i_slint_compiler::langtype::Type::Array(..) => PyValueType::Model,

View file

@ -11,6 +11,7 @@ use interpreter::{
CompilationResult, Compiler, ComponentDefinition, ComponentInstance, PyDiagnostic,
PyDiagnosticLevel, PyValueType,
};
mod api_match;
mod async_adapter;
mod brush;
mod errors;
@ -183,6 +184,7 @@ fn slint(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<models::PyModelBase>()?;
m.add_class::<value::PyStruct>()?;
m.add_class::<async_adapter::AsyncAdapter>()?;
m.add_class::<api_match::PyGeneratedAPI>()?;
m.add_function(wrap_pyfunction!(run_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(quit_event_loop, m)?)?;
m.add_function(wrap_pyfunction!(set_xdg_app_id, m)?)?;

View file

@ -21,6 +21,8 @@ from pathlib import Path
from collections.abc import Coroutine
import asyncio
import gettext
import gzip
import base64
Struct = native.PyStruct
@ -268,7 +270,7 @@ def _build_struct(name: str, struct_prototype: native.PyStruct) -> type:
return type(name, (), type_dict)
def load_file(
def _load_file(
path: str | os.PathLike[Any] | pathlib.Path,
quiet: bool = False,
style: typing.Optional[str] = None,
@ -277,7 +279,7 @@ def load_file(
typing.Dict[str, os.PathLike[Any] | pathlib.Path]
] = None,
translation_domain: typing.Optional[str] = None,
) -> types.SimpleNamespace:
) -> typing.Tuple[types.SimpleNamespace, native.CompilationResult]:
"""This function is the low-level entry point into Slint for instantiating components. It loads the `.slint` file at
the specified `path` and returns a namespace with all exported components as Python classes, as well as enums, and structs.
@ -339,6 +341,57 @@ def load_file(
new_name = _normalize_prop(new_name)
setattr(module, new_name, getattr(module, orig_name))
return (module, result)
def load_file(
path: str | os.PathLike[Any] | pathlib.Path,
quiet: bool = False,
style: typing.Optional[str] = None,
include_paths: typing.Optional[typing.List[os.PathLike[Any] | pathlib.Path]] = None,
library_paths: typing.Optional[
typing.Dict[str, os.PathLike[Any] | pathlib.Path]
] = None,
translation_domain: typing.Optional[str] = None,
) -> types.SimpleNamespace:
"""This function is the low-level entry point into Slint for instantiating components. It loads the `.slint` file at
the specified `path` and returns a namespace with all exported components as Python classes, as well as enums, and structs.
* `quiet`: Set to true to prevent any warnings during compilation from being printed to stderr.
* `style`: Specify a widget style.
* `include_paths`: Additional include paths used to look up `.slint` files imported from other `.slint` files.
* `library_paths`: A dictionary that maps library names to their location in the file system. This is then used to look up
library imports, such as `import { MyButton } from "@mylibrary";`.
* `translation_domain`: The domain to use for looking up the catalogue run-time translations. This must match the
translation domain used when extracting translations with `slint-tr-extractor`.
"""
return _load_file(
path, quiet, style, include_paths, library_paths, translation_domain
)[0]
def _load_file_checked(
path: str | os.PathLike[Any] | pathlib.Path,
expected_api_base64_compressed: str,
generated_file: str | os.PathLike[Any] | pathlib.Path,
) -> types.SimpleNamespace:
"""@private"""
module, compilation_result = _load_file(path)
expected_api = gzip.decompress(
base64.standard_b64decode(expected_api_base64_compressed)
).decode("utf-8")
generated_api_module = native.GeneratedAPI(path=generated_file, json=expected_api)
actual_api_module = compilation_result.generated_api
generated_api_module.compare_generated_vs_actual(
generated=generated_api_module, actual=actual_api_module
)
return module
@ -420,7 +473,7 @@ def _callback_decorator(
def callback(
global_name: str | None = None, name: str | None = None
global_name: typing.Callable[..., Any] | str | None = None, name: str | None = None
) -> typing.Callable[..., Any]:
"""Use the callback decorator to mark a method as a callback that can be invoked from the Slint component.
@ -553,6 +606,7 @@ __all__ = [
"CompileError",
"Component",
"load_file",
"_load_file_checked",
"loader",
"Image",
"Color",

View file

@ -223,6 +223,7 @@ class CompilationResult:
diagnostics: list[PyDiagnostic]
named_exports: list[typing.Tuple[str, str]]
structs_and_enums: typing.Tuple[typing.Dict[str, PyStruct], typing.Dict[str, Enum]]
generated_api: GeneratedAPI
def component(self, name: str) -> ComponentDefinition: ...
class Compiler:
@ -244,3 +245,12 @@ class AsyncAdapter:
) -> "AsyncAdapter": ...
def wait_for_readable(self, callback: typing.Callable[[int], None]) -> None: ...
def wait_for_writable(self, callback: typing.Callable[[int], None]) -> None: ...
class GeneratedAPI:
def __new__(
cls, path: str | os.PathLike[Any] | pathlib.Path, json: str
) -> "GeneratedAPI": ...
@staticmethod
def compare_generated_vs_actual(
generated: "GeneratedAPI", actual: "GeneratedAPI"
) -> None: ...

View file

@ -0,0 +1,6 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
export component Test inherits Window {
in-out property <string> name;
}

View file

@ -27,6 +27,7 @@ export { Secret-Struct as Public-Struct }
export enum TestEnum {
Variant1,
Variant2,
Variant-three,
}
export component App inherits Window {

View file

@ -0,0 +1,81 @@
# Copyright © SixtyFPS GmbH <info@slint.dev>
# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
import pytest
from slint import _load_file_checked
from pathlib import Path
import base64
import gzip
def base_dir() -> Path:
origin = __spec__.origin
assert origin is not None
base_dir = Path(origin).parent
assert base_dir is not None
return base_dir
def compress_and_encode(json: str) -> str:
return base64.standard_b64encode(gzip.compress(json.encode("utf-8"))).decode(
"utf-8"
)
def test_no_change() -> None:
_load_file_checked(
base_dir() / "api-match.slint",
expected_api_base64_compressed=compress_and_encode(r"""
{
"version":"1.0",
"globals":[],
"components":[
{
"name":"Test",
"properties":[
{
"name": "name",
"ty": "str"
}
],
"aliases":[]
}
],
"structs_and_enums":[]
}"""),
generated_file="/some/path.py",
)
def test_incompatible_changes() -> None:
with pytest.raises(RuntimeError) as excinfo:
_load_file_checked(
base_dir() / "api-match.slint",
expected_api_base64_compressed=compress_and_encode(r"""
{
"version":"1.0",
"globals":[],
"components":[
{
"name":"Test",
"properties":[
{
"name": "name",
"ty": "str"
},
{
"name": "not_there_anymore",
"ty": "str"
}
],
"aliases":[]
}
],
"structs_and_enums":[]
}"""),
generated_file="/some/path.py",
)
assert (
f"Incompatible API changes detected between /some/path.py and {base_dir() / 'api-match.slint'}"
== str(excinfo.value)
)

View file

@ -34,6 +34,9 @@ def test_enums() -> None:
instance.enum_property = TestEnum.Variant1
assert instance.enum_property == TestEnum.Variant1
assert instance.enum_property.__class__ is TestEnum
instance.enum_property = TestEnum.Variant_three
assert instance.enum_property == TestEnum.Variant_three
assert instance.enum_property.__class__ is TestEnum
model_with_enums = instance.model_with_enums
assert len(model_with_enums) == 2

View file

@ -1,6 +1,7 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
use i_slint_compiler::generator::python::ident;
use pyo3::types::PyDict;
use pyo3::{prelude::*, PyVisit};
use pyo3::{IntoPyObjectExt, PyTraverseError};
@ -216,17 +217,14 @@ impl TypeCollection {
en.name.to_string(),
en.values
.iter()
.map(|val| {
let val = val.to_string();
(val.clone(), val)
})
.map(|val| (ident(&val).to_string(), val.to_string()))
.collect::<Vec<_>>(),
),
None,
)
.unwrap();
enum_classes.insert(en.name.to_string(), enum_type);
enum_classes.insert(ident(&en.name).into(), enum_type);
}
_ => {}
}
@ -250,7 +248,7 @@ impl TypeCollection {
enum_value: &str,
py: Python<'_>,
) -> Result<Py<PyAny>, PyErr> {
let enum_cls = self.enum_classes.get(enum_name).ok_or_else(|| {
let enum_cls = self.enum_classes.get(ident(enum_name).as_str()).ok_or_else(|| {
PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
"Slint provided enum {enum_name} is unknown"
))