From 16ceb115a1bf05c012ac6505524a7dc84c8bd95c Mon Sep 17 00:00:00 2001 From: Simon Hausmann Date: Thu, 20 Nov 2025 17:17:01 +0100 Subject: [PATCH] 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 --- .../workflows/build_and_test_reusable.yaml | 6 +- .github/workflows/python_test_reusable.yaml | 2 + Cargo.toml | 1 + api/python/slint/Cargo.toml | 4 +- api/python/slint/README.md | 54 ++ api/python/slint/api_match.rs | 63 ++ api/python/slint/interpreter.rs | 50 +- api/python/slint/lib.rs | 2 + api/python/slint/slint/__init__.py | 60 +- api/python/slint/slint/slint.pyi | 10 + api/python/slint/tests/api-match.slint | 6 + api/python/slint/tests/test-load-file.slint | 1 + api/python/slint/tests/test_api_match.py | 81 ++ api/python/slint/tests/test_enums.py | 3 + api/python/slint/value.rs | 10 +- internal/compiler/Cargo.toml | 8 + internal/compiler/generator.rs | 15 + internal/compiler/generator/python.rs | 705 ++++++++++++++++ internal/compiler/generator/python/diff.rs | 778 ++++++++++++++++++ tests/cases/bindings/two_way_global.slint | 2 +- tests/cases/callbacks/callback_alias.slint | 7 + .../cases/elements/component_container.slint | 2 +- .../component_container_component.slint | 2 +- .../elements/component_container_init.slint | 2 +- .../elements/component_container_size.slint | 2 +- tests/cases/examples/hello.slint | 11 + tests/cases/exports/cpp_namespace.slint | 2 +- tests/cases/exports/named_exports.slint | 3 + tests/cases/globals/global_accessor_api.slint | 3 + tests/cases/imports/duplicated_name.slint | 3 + tests/cases/imports/external_globals.slint | 3 + .../imports/external_globals_nameclash.slint | 3 + tests/cases/imports/external_structs.slint | 3 + tests/cases/imports/external_type.slint | 3 + tests/cases/imports/just_import.slint | 3 + tests/cases/imports/library.slint | 3 + tests/cases/imports/reexport.slint | 3 + tests/cases/imports/reexport2.slint | 5 +- .../properties/animation_props_depends.slint | 3 + ...onentcontainer_font_size_propagation.slint | 2 +- tests/cases/types/bool.slint | 6 + tests/cases/types/enum_compare.slint | 4 + tests/cases/types/enums.slint | 40 + tests/cases/types/structs.slint | 32 + tests/driver/cpp/cppdriver.rs | 1 + tests/driver/python/Cargo.toml | 32 + tests/driver/python/build.rs | 45 + tests/driver/python/main.rs | 12 + tests/driver/python/python.rs | 203 +++++ tests/driver/rust/build.rs | 1 + tests/screenshots/build.rs | 1 + tools/compiler/Cargo.toml | 2 +- tools/compiler/main.rs | 11 +- 53 files changed, 2286 insertions(+), 33 deletions(-) create mode 100644 api/python/slint/api_match.rs create mode 100644 api/python/slint/tests/api-match.slint create mode 100644 api/python/slint/tests/test_api_match.py create mode 100644 internal/compiler/generator/python.rs create mode 100644 internal/compiler/generator/python/diff.rs create mode 100644 tests/driver/python/Cargo.toml create mode 100644 tests/driver/python/build.rs create mode 100644 tests/driver/python/main.rs create mode 100644 tests/driver/python/python.rs diff --git a/.github/workflows/build_and_test_reusable.yaml b/.github/workflows/build_and_test_reusable.yaml index e3e81a303a..bc6802b669 100644 --- a/.github/workflows/build_and_test_reusable.yaml +++ b/.github/workflows/build_and_test_reusable.yaml @@ -63,6 +63,8 @@ jobs: - uses: actions/setup-python@v6 with: python-version: '3.10' + - name: Install uv + uses: astral-sh/setup-uv@v7 - name: Install Qt if: runner.os != 'Windows' uses: jurplel/install-qt-action@v4 @@ -86,12 +88,12 @@ jobs: save_if: ${{ inputs.save_if }} cache: ${{ inputs.cache }} - name: Run tests - run: cargo test --verbose --all-features --workspace --timings ${{ inputs.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python -- --skip=_qt::t + run: cargo test --verbose --all-features --workspace --timings ${{ inputs.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude test-driver-python --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python -- --skip=_qt::t env: SLINT_CREATE_SCREENSHOTS: 1 shell: bash - name: Run tests (qt) - run: cargo test --verbose --all-features --workspace ${{ inputs.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python --bin test-driver-rust -- _qt --test-threads=1 + run: cargo test --verbose --all-features --workspace ${{ inputs.extra_args }} --exclude slint-node --exclude pyslint --exclude test-driver-node --exclude slint-node --exclude test-driver-nodejs --exclude test-driver-cpp --exclude test-driver-python --exclude mcu-board-support --exclude mcu-embassy --exclude printerdemo_mcu --exclude uefi-demo --exclude slint-cpp --exclude slint-python --bin test-driver-rust -- _qt --test-threads=1 shell: bash - name: live-preview for rust test env: diff --git a/.github/workflows/python_test_reusable.yaml b/.github/workflows/python_test_reusable.yaml index 23674e60a2..e76137054a 100644 --- a/.github/workflows/python_test_reusable.yaml +++ b/.github/workflows/python_test_reusable.yaml @@ -62,3 +62,5 @@ jobs: - name: Run ruff linter working-directory: api/python/briefcase run: uv tool run ruff check + - name: Run python test driver + run: cargo test -p test-driver-python diff --git a/Cargo.toml b/Cargo.toml index 10e04539d7..d2653cd6da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ members = [ 'tests/driver/driverlib', 'tests/driver/interpreter', 'tests/driver/nodejs', + 'tests/driver/python', 'tests/driver/rust', 'tests/screenshots', 'tests/manual/windowattributes', diff --git a/api/python/slint/Cargo.toml b/api/python/slint/Cargo.toml index 7a2d3117cd..8bd281f6a4 100644 --- a/api/python/slint/Cargo.toml +++ b/api/python/slint/Cargo.toml @@ -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" diff --git a/api/python/slint/README.md b/api/python/slint/README.md index db725e9efc..1a337d93ce 100644 --- a/api/python/slint/README.md +++ b/api/python/slint/README.md @@ -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 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). diff --git a/api/python/slint/api_match.rs b/api/python/slint/api_match.rs new file mode 100644 index 0000000000..e67516d074 --- /dev/null +++ b/api/python/slint/api_match.rs @@ -0,0 +1,63 @@ +// Copyright © SixtyFPS GmbH +// 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 { + 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(()) + } + } +} diff --git a/api/python/slint/interpreter.rs b/api/python/slint/interpreter.rs index 3680d6592b..6b6ad741f9 100644 --- a/api/python/slint/interpreter.rs +++ b/api/python/slint/interpreter.rs @@ -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::>() } + + #[getter] + fn generated_api(&self) -> PyResult { + 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 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, diff --git a/api/python/slint/lib.rs b/api/python/slint/lib.rs index b75a8cf93e..e9967f76dc 100644 --- a/api/python/slint/lib.rs +++ b/api/python/slint/lib.rs @@ -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::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; 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)?)?; diff --git a/api/python/slint/slint/__init__.py b/api/python/slint/slint/__init__.py index b90e134339..a255623c41 100644 --- a/api/python/slint/slint/__init__.py +++ b/api/python/slint/slint/__init__.py @@ -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", diff --git a/api/python/slint/slint/slint.pyi b/api/python/slint/slint/slint.pyi index c506ea4b4d..6703ceb528 100644 --- a/api/python/slint/slint/slint.pyi +++ b/api/python/slint/slint/slint.pyi @@ -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: ... diff --git a/api/python/slint/tests/api-match.slint b/api/python/slint/tests/api-match.slint new file mode 100644 index 0000000000..67938312fd --- /dev/null +++ b/api/python/slint/tests/api-match.slint @@ -0,0 +1,6 @@ +// Copyright © SixtyFPS GmbH +// 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 name; +} diff --git a/api/python/slint/tests/test-load-file.slint b/api/python/slint/tests/test-load-file.slint index 7f9c37be96..50fdcdafb2 100644 --- a/api/python/slint/tests/test-load-file.slint +++ b/api/python/slint/tests/test-load-file.slint @@ -27,6 +27,7 @@ export { Secret-Struct as Public-Struct } export enum TestEnum { Variant1, Variant2, + Variant-three, } export component App inherits Window { diff --git a/api/python/slint/tests/test_api_match.py b/api/python/slint/tests/test_api_match.py new file mode 100644 index 0000000000..5ac187317d --- /dev/null +++ b/api/python/slint/tests/test_api_match.py @@ -0,0 +1,81 @@ +# Copyright © SixtyFPS GmbH +# 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) + ) diff --git a/api/python/slint/tests/test_enums.py b/api/python/slint/tests/test_enums.py index 904107f58d..fd040eade0 100644 --- a/api/python/slint/tests/test_enums.py +++ b/api/python/slint/tests/test_enums.py @@ -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 diff --git a/api/python/slint/value.rs b/api/python/slint/value.rs index d152fa2ff7..6909943a06 100644 --- a/api/python/slint/value.rs +++ b/api/python/slint/value.rs @@ -1,6 +1,7 @@ // Copyright © SixtyFPS GmbH // 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::>(), ), 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, 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::(format!( "Slint provided enum {enum_name} is unknown" )) diff --git a/internal/compiler/Cargo.toml b/internal/compiler/Cargo.toml index 47f3c2d554..85f2741f62 100644 --- a/internal/compiler/Cargo.toml +++ b/internal/compiler/Cargo.toml @@ -20,6 +20,7 @@ path = "lib.rs" # Generators cpp = [] rust = ["quote", "proc-macro2"] +python = ["dep:pathdiff", "dep:serde", "smol_str/serde", "dep:serde_json", "dep:flate2", "dep:base64"] # Support for proc_macro spans in the token (only useful for use within a proc macro) proc_macro_span = ["quote", "proc-macro2"] @@ -72,6 +73,13 @@ rayon = { workspace = true, optional = true } # translations polib = { version = "0.2", optional = true } +pathdiff = { version = "0.2.3", optional = true } + +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +flate2 = { version = "1.1.5", optional = true } +base64 = { version = "0.22.1", optional = true } + [dev-dependencies] i-slint-parser-test-macro = { path = "./parser-test-macro" } diff --git a/internal/compiler/generator.rs b/internal/compiler/generator.rs index 033f7f34f4..0d27edb688 100644 --- a/internal/compiler/generator.rs +++ b/internal/compiler/generator.rs @@ -28,6 +28,9 @@ pub mod rust; #[cfg(feature = "rust")] pub mod rust_live_preview; +#[cfg(feature = "python")] +pub mod python; + #[derive(Clone, Debug, PartialEq)] pub enum OutputFormat { #[cfg(feature = "cpp")] @@ -36,6 +39,8 @@ pub enum OutputFormat { Rust, Interpreter, Llr, + #[cfg(feature = "python")] + Python, } impl OutputFormat { @@ -47,6 +52,8 @@ impl OutputFormat { } #[cfg(feature = "rust")] Some("rs") => Some(Self::Rust), + #[cfg(feature = "python")] + Some("py") => Some(Self::Python), _ => None, } } @@ -61,6 +68,8 @@ impl std::str::FromStr for OutputFormat { #[cfg(feature = "rust")] "rust" => Ok(Self::Rust), "llr" => Ok(Self::Llr), + #[cfg(feature = "python")] + "python" => Ok(Self::Python), _ => Err(format!("Unknown output format {s}")), } } @@ -69,6 +78,7 @@ impl std::str::FromStr for OutputFormat { pub fn generate( format: OutputFormat, destination: &mut impl std::io::Write, + destination_path: Option<&std::path::Path>, doc: &Document, compiler_config: &CompilerConfiguration, ) -> std::io::Result<()> { @@ -98,6 +108,11 @@ pub fn generate( crate::llr::pretty_print::pretty_print(&root, &mut output).unwrap(); write!(destination, "{output}")?; } + #[cfg(feature = "python")] + OutputFormat::Python => { + let output = python::generate(doc, compiler_config, destination_path)?; + write!(destination, "{output}")?; + } } Ok(()) } diff --git a/internal/compiler/generator/python.rs b/internal/compiler/generator/python.rs new file mode 100644 index 0000000000..abbda8532f --- /dev/null +++ b/internal/compiler/generator/python.rs @@ -0,0 +1,705 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +/*! module for the C++ code generator +*/ + +// cSpell:ignore cmath constexpr cstdlib decltype intptr itertools nullptr prepended struc subcomponent uintptr vals + +use std::collections::HashMap; +use std::sync::OnceLock; +use std::{collections::HashSet, rc::Rc}; + +use smol_str::{SmolStr, StrExt, format_smolstr}; + +use serde::{Deserialize, Serialize}; + +mod diff; + +// Check if word is one of Python keywords +// (https://docs.python.org/3/reference/lexical_analysis.html#keywords) +fn is_python_keyword(word: &str) -> bool { + static PYTHON_KEYWORDS: OnceLock> = OnceLock::new(); + let keywords = PYTHON_KEYWORDS.get_or_init(|| { + let keywords: HashSet<&str> = HashSet::from([ + "False", "await", "else", "import", "pass", "None", "break", "except", "in", "raise", + "True", "class", "finally", "is", "return", "and", "continue", "for", "lambda", "try", + "as", "def", "from", "nonlocal", "while", "assert", "del", "global", "not", "with", + "async", "elif", "if", "or", "yield", + ]); + keywords + }); + keywords.contains(word) +} + +pub fn ident(ident: &str) -> SmolStr { + let mut new_ident = SmolStr::from(ident); + if ident.contains('-') { + new_ident = ident.replace_smolstr("-", "_"); + } + if is_python_keyword(new_ident.as_str()) { + new_ident = format_smolstr!("{}_", new_ident); + } + new_ident +} + +#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)] +pub struct PyProperty { + name: SmolStr, + ty: SmolStr, +} + +impl From<&PyProperty> for python_ast::Field { + fn from(prop: &PyProperty) -> Self { + Field { + name: prop.name.clone(), + ty: Some(PyType { name: prop.ty.clone(), optional: false }), + default_value: None, + } + } +} + +impl From<&llr::PublicProperty> for PyProperty { + fn from(llr_prop: &llr::PublicProperty) -> Self { + Self { name: ident(&llr_prop.name), ty: python_type_name(&llr_prop.ty) } + } +} + +enum ComponentType<'a> { + Global, + Component { associated_globals: &'a [PyComponent] }, +} + +#[derive(Serialize, Deserialize)] +pub struct PyComponent { + name: SmolStr, + properties: Vec, + aliases: Vec, +} + +impl PyComponent { + fn generate(&self, ty: ComponentType<'_>, file: &mut File) { + let mut class = Class { + name: self.name.clone(), + super_class: if matches!(ty, ComponentType::Global) { + None + } else { + Some(SmolStr::new_static("slint.Component")) + }, + ..Default::default() + }; + + class.fields = self + .properties + .iter() + .map(From::from) + .chain( + match ty { + ComponentType::Global => None, + ComponentType::Component { associated_globals } => Some(associated_globals), + } + .into_iter() + .flat_map(|globals| globals.iter()) + .map(|glob| Field { + name: glob.name.clone(), + ty: Some(PyType { name: glob.name.clone(), optional: false }), + default_value: None, + }), + ) + .collect(); + + file.declarations.push(python_ast::Declaration::Class(class)); + + file.declarations.extend(self.aliases.iter().map(|exported_name| { + python_ast::Declaration::Variable(Variable { + name: ident(&exported_name), + value: self.name.clone(), + }) + })) + } +} + +impl From<&llr::PublicComponent> for PyComponent { + fn from(llr_compo: &llr::PublicComponent) -> Self { + Self { + name: ident(&llr_compo.name), + properties: llr_compo.public_properties.iter().map(From::from).collect(), + aliases: Vec::new(), + } + } +} + +impl From<&llr::GlobalComponent> for PyComponent { + fn from(llr_global: &llr::GlobalComponent) -> Self { + Self { + name: ident(&llr_global.name), + properties: llr_global.public_properties.iter().map(From::from).collect(), + aliases: llr_global.aliases.iter().map(|exported_name| ident(&exported_name)).collect(), + } + } +} + +#[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] +pub struct PyStructField { + name: SmolStr, + ty: SmolStr, +} + +#[derive(Serialize, Deserialize)] +pub struct PyStruct { + name: SmolStr, + fields: Vec, + aliases: Vec, +} + +pub struct AnonymousStruct; + +impl TryFrom<&Rc> for PyStruct { + type Error = AnonymousStruct; + + fn try_from(structty: &Rc) -> Result { + let StructName::User { name, .. } = &structty.name else { + return Err(AnonymousStruct); + }; + Ok(Self { + name: ident(&name), + fields: structty + .fields + .iter() + .map(|(name, ty)| PyStructField { name: ident(&name), ty: python_type_name(ty) }) + .collect(), + aliases: Vec::new(), + }) + } +} + +impl From<&PyStruct> for python_ast::Declaration { + fn from(py_struct: &PyStruct) -> Self { + let py_fields = py_struct + .fields + .iter() + .map(|field| Field { + name: field.name.clone(), + ty: Some(PyType { name: field.ty.clone(), optional: false }), + default_value: None, + }) + .collect::>(); + + let ctor = FunctionDeclaration { + name: SmolStr::new_static("__init__"), + positional_parameters: Vec::default(), + keyword_parameters: py_fields + .iter() + .cloned() + .map(|field| { + let mut kw_field = field.clone(); + kw_field.ty.as_mut().unwrap().optional = true; + kw_field.default_value = Some(SmolStr::new_static("None")); + kw_field + }) + .collect(), + return_type: None, + }; + + let struct_class = Class { + name: py_struct.name.clone(), + fields: py_fields, + function_declarations: vec![ctor], + ..Default::default() + }; + python_ast::Declaration::Class(struct_class) + } +} + +impl PyStruct { + fn generate_aliases(&self) -> impl ExactSizeIterator + use<'_> { + self.aliases.iter().map(|alias| { + python_ast::Declaration::Variable(Variable { + name: alias.clone(), + value: self.name.clone(), + }) + }) + } +} + +#[derive(Serialize, Deserialize)] +pub struct PyEnumVariant { + name: SmolStr, + strvalue: SmolStr, +} + +#[derive(Serialize, Deserialize)] +pub struct PyEnum { + name: SmolStr, + variants: Vec, + aliases: Vec, +} + +impl From<&Rc> for PyEnum { + fn from(enumty: &Rc) -> Self { + Self { + name: ident(&enumty.name), + variants: enumty + .values + .iter() + .map(|val| PyEnumVariant { name: ident(&val), strvalue: val.clone() }) + .collect(), + aliases: Vec::new(), + } + } +} + +impl From<&PyEnum> for python_ast::Declaration { + fn from(py_enum: &PyEnum) -> Self { + python_ast::Declaration::Class(Class { + name: py_enum.name.clone(), + super_class: Some(SmolStr::new_static("enum.StrEnum")), + fields: py_enum + .variants + .iter() + .map(|variant| Field { + name: variant.name.clone(), + ty: None, + default_value: Some(format_smolstr!("\"{}\"", variant.strvalue)), + }) + .collect(), + function_declarations: vec![], + }) + } +} + +impl PyEnum { + fn generate_aliases(&self) -> impl ExactSizeIterator + use<'_> { + self.aliases.iter().map(|alias| { + python_ast::Declaration::Variable(Variable { + name: alias.clone(), + value: self.name.clone(), + }) + }) + } +} + +#[derive(Serialize, Deserialize)] +pub enum PyStructOrEnum { + Struct(PyStruct), + Enum(PyEnum), +} + +impl From<&PyStructOrEnum> for python_ast::Declaration { + fn from(struct_or_enum: &PyStructOrEnum) -> Self { + match struct_or_enum { + PyStructOrEnum::Struct(py_struct) => py_struct.into(), + PyStructOrEnum::Enum(py_enum) => py_enum.into(), + } + } +} + +impl PyStructOrEnum { + fn generate_aliases(&self, file: &mut File) { + match self { + PyStructOrEnum::Struct(py_struct) => { + file.declarations.extend(py_struct.generate_aliases()) + } + PyStructOrEnum::Enum(py_enum) => file.declarations.extend(py_enum.generate_aliases()), + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct PyModule { + version: SmolStr, + globals: Vec, + components: Vec, + structs_and_enums: Vec, +} + +impl Default for PyModule { + fn default() -> Self { + Self { + version: SmolStr::new_static("1.0"), + globals: Default::default(), + components: Default::default(), + structs_and_enums: Default::default(), + } + } +} + +impl PyModule { + pub fn load_from_json(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| format!("{}", e)) + } +} + +pub fn generate_py_module( + doc: &Document, + compiler_config: &CompilerConfiguration, +) -> std::io::Result { + let mut module = PyModule::default(); + + let mut compo_aliases: HashMap> = Default::default(); + let mut struct_aliases: HashMap> = Default::default(); + let mut enum_aliases: HashMap> = Default::default(); + + for export in doc.exports.iter() { + match &export.1 { + Either::Left(component) if !component.is_global() => { + if export.0.name != component.id { + compo_aliases + .entry(component.id.clone()) + .or_default() + .push(export.0.name.clone()); + } + } + Either::Right(ty) => match &ty { + Type::Struct(s) if s.node().is_some() => { + if let StructName::User { name: orig_name, .. } = &s.name { + if export.0.name != *orig_name { + struct_aliases + .entry(orig_name.clone()) + .or_default() + .push(export.0.name.clone()); + } + } + } + Type::Enumeration(en) => { + if export.0.name != en.name { + enum_aliases + .entry(en.name.clone()) + .or_default() + .push(export.0.name.clone()); + } + } + _ => {} + }, + _ => {} + } + } + + for ty in &doc.used_types.borrow().structs_and_enums { + match ty { + Type::Struct(s) => module.structs_and_enums.extend( + PyStruct::try_from(s).ok().and_then(|mut pystruct| { + let StructName::User { name, .. } = &s.name else { + return None; + }; + pystruct.aliases = struct_aliases.remove(name).unwrap_or_default(); + Some(PyStructOrEnum::Struct(pystruct)) + }), + ), + Type::Enumeration(en) => { + module.structs_and_enums.push({ + let mut pyenum = PyEnum::from(en); + pyenum.aliases = enum_aliases.remove(&en.name).unwrap_or_default(); + PyStructOrEnum::Enum(pyenum) + }); + } + _ => {} + } + } + + let llr = llr::lower_to_item_tree::lower_to_item_tree(doc, compiler_config); + + let globals = llr.globals.iter().filter(|glob| glob.exported && glob.must_generate()); + + module.globals.extend(globals.clone().map(PyComponent::from)); + module.components.extend(llr.public_components.iter().map(|llr_compo| { + let mut pycompo = PyComponent::from(llr_compo); + pycompo.aliases = compo_aliases.remove(&llr_compo.name).unwrap_or_default(); + pycompo + })); + + Ok(module) +} + +/// This module contains some data structures that helps represent a Python file. +/// It is then rendered into an actual Python code using the Display trait +mod python_ast { + + use std::fmt::{Display, Error, Formatter}; + + use smol_str::SmolStr; + + ///A full Python file + #[derive(Default, Debug)] + pub struct File { + pub imports: Vec, + pub declarations: Vec, + pub trailing_code: Vec, + } + + impl Display for File { + fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), Error> { + writeln!(f, "# This file is auto-generated\n")?; + for import in &self.imports { + writeln!(f, "import {}", import)?; + } + writeln!(f, "")?; + for decl in &self.declarations { + writeln!(f, "{}", decl)?; + } + for code in &self.trailing_code { + writeln!(f, "{}", code)?; + } + Ok(()) + } + } + + #[derive(Debug, derive_more::Display)] + pub enum Declaration { + Class(Class), + Variable(Variable), + } + + #[derive(Debug, Default)] + pub struct Class { + pub name: SmolStr, + pub super_class: Option, + pub fields: Vec, + pub function_declarations: Vec, + } + + impl Display for Class { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if let Some(super_class) = self.super_class.as_ref() { + writeln!(f, "class {}({}):", self.name, super_class)?; + } else { + writeln!(f, "class {}:", self.name)?; + } + if self.fields.is_empty() && self.function_declarations.is_empty() { + writeln!(f, " pass")?; + return Ok(()); + } + + for field in &self.fields { + writeln!(f, " {}", field)?; + } + + if !self.fields.is_empty() { + writeln!(f, "")?; + } + + for fundecl in &self.function_declarations { + writeln!(f, " {}", fundecl)?; + } + + Ok(()) + } + } + + #[derive(Debug)] + pub struct Variable { + pub name: SmolStr, + pub value: SmolStr, + } + + impl Display for Variable { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + writeln!(f, "{} = {}", self.name, self.value) + } + } + + #[derive(Debug, Clone)] + pub struct PyType { + pub name: SmolStr, + pub optional: bool, + } + + impl Display for PyType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + if self.optional { + write!(f, "typing.Optional[{}]", self.name) + } else { + write!(f, "{}", self.name) + } + } + } + + #[derive(Debug, Clone)] + pub struct Field { + pub name: SmolStr, + pub ty: Option, + pub default_value: Option, + } + + impl Display for Field { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.name)?; + if let Some(ty) = &self.ty { + write!(f, ": {}", ty)?; + } + if let Some(default_value) = &self.default_value { + write!(f, " = {}", default_value)? + } + Ok(()) + } + } + + #[derive(Debug)] + pub struct FunctionDeclaration { + pub name: SmolStr, + pub positional_parameters: Vec, + pub keyword_parameters: Vec, + pub return_type: Option, + } + + impl Display for FunctionDeclaration { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "def {}(self", self.name)?; + + if !self.positional_parameters.is_empty() { + write!(f, ", {}", self.positional_parameters.join(","))?; + } + + if !self.keyword_parameters.is_empty() { + write!(f, ", *")?; + write!( + f, + ", {}", + self.keyword_parameters + .iter() + .map(ToString::to_string) + .collect::>() + .join(", ") + )?; + } + writeln!( + f, + ") -> {}: ...", + self.return_type.as_ref().map_or(std::borrow::Cow::Borrowed("None"), |ty| { + std::borrow::Cow::Owned(ty.to_string()) + }) + )?; + Ok(()) + } + } +} + +use crate::langtype::{StructName, Type}; + +use crate::CompilerConfiguration; +use crate::llr; +use crate::object_tree::Document; +use itertools::{Either, Itertools}; +use python_ast::*; + +/// Returns the text of the Python code produced by the given root component +pub fn generate( + doc: &Document, + compiler_config: &CompilerConfiguration, + destination_path: Option<&std::path::Path>, +) -> std::io::Result { + let mut file = File { ..Default::default() }; + file.imports.push(SmolStr::new_static("slint")); + file.imports.push(SmolStr::new_static("typing")); + + let pymodule = generate_py_module(doc, compiler_config)?; + + if pymodule.structs_and_enums.iter().any(|se| matches!(se, PyStructOrEnum::Enum(_))) { + file.imports.push(SmolStr::new_static("enum")); + } + + file.declarations.extend(pymodule.structs_and_enums.iter().map(From::from)); + + for global in &pymodule.globals { + global.generate(ComponentType::Global, &mut file); + } + + for public_component in &pymodule.components { + public_component.generate( + ComponentType::Component { associated_globals: &pymodule.globals }, + &mut file, + ); + } + + for struct_or_enum in &pymodule.structs_and_enums { + struct_or_enum.generate_aliases(&mut file); + } + + let main_file = std::path::absolute( + doc.node + .as_ref() + .ok_or_else(|| std::io::Error::other("Cannot determine path of the main file"))? + .source_file + .path(), + ) + .unwrap(); + + let destination_path = destination_path.and_then(|maybe_relative_destination_path| { + std::fs::canonicalize(maybe_relative_destination_path) + .ok() + .and_then(|p| p.parent().map(std::path::PathBuf::from)) + }); + + let relative_path_from_destination_to_main_file = + destination_path.and_then(|destination_path| { + pathdiff::diff_paths(main_file.parent().unwrap(), destination_path) + }); + + if let Some(relative_path_from_destination_to_main_file) = + relative_path_from_destination_to_main_file + { + use base64::engine::Engine; + use std::io::Write; + + let mut api_str_compressor = + flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + api_str_compressor.write_all(serde_json::to_string(&pymodule).unwrap().as_bytes())?; + let compressed_api_str = api_str_compressor.finish()?; + let base64_api_str = base64::engine::general_purpose::STANDARD.encode(&compressed_api_str); + + file.imports.push(SmolStr::new_static("os")); + file.trailing_code.push(format_smolstr!( + "globals().update(vars(slint._load_file_checked(path=os.path.join(os.path.dirname(__file__), r'{}'), expected_api_base64_compressed=r'{}', generated_file=__file__)))", + relative_path_from_destination_to_main_file.join(main_file.file_name().unwrap()).to_string_lossy(), + base64_api_str + )); + } + + Ok(file) +} + +fn python_type_name(ty: &Type) -> SmolStr { + match ty { + Type::Invalid => panic!("Invalid type encountered in llr output"), + Type::Void => SmolStr::new_static("None"), + Type::String => SmolStr::new_static("str"), + Type::Color => SmolStr::new_static("slint.Color"), + Type::Float32 + | Type::Int32 + | Type::Duration + | Type::Angle + | Type::PhysicalLength + | Type::LogicalLength + | Type::Percent + | Type::Rem + | Type::UnitProduct(_) => SmolStr::new_static("float"), + Type::Image => SmolStr::new_static("slint.Image"), + Type::Bool => SmolStr::new_static("bool"), + Type::Brush => SmolStr::new_static("slint.Brush"), + Type::Array(elem_type) => format_smolstr!("slint.Model[{}]", python_type_name(elem_type)), + Type::Struct(s) => match &s.name { + StructName::User { name, .. } => ident(name), + StructName::BuiltinPrivate(_) => SmolStr::new_static("None"), + StructName::BuiltinPublic(_) | StructName::None => { + let tuple_types = + s.fields.values().map(|ty| python_type_name(ty)).collect::>(); + format_smolstr!("typing.Tuple[{}]", tuple_types.join(", ")) + } + }, + Type::Enumeration(enumeration) => { + if enumeration.node.is_some() { + ident(&enumeration.name) + } else { + SmolStr::new_static("None") + } + } + Type::Callback(function) | Type::Function(function) => { + format_smolstr!( + "typing.Callable[[{}], {}]", + function.args.iter().map(|arg_ty| python_type_name(arg_ty)).join(", "), + python_type_name(&function.return_type) + ) + } + ty @ _ => unimplemented!("implemented type conversion {:#?}", ty), + } +} diff --git a/internal/compiler/generator/python/diff.rs b/internal/compiler/generator/python/diff.rs new file mode 100644 index 0000000000..7c77d1cde6 --- /dev/null +++ b/internal/compiler/generator/python/diff.rs @@ -0,0 +1,778 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::collections::{BTreeMap, BTreeSet}; + +use smol_str::SmolStr; + +use super::{PyComponent, PyEnum, PyModule, PyProperty, PyStruct, PyStructField, PyStructOrEnum}; + +#[cfg(test)] +use super::PyEnumVariant; + +impl PyModule { + pub fn changed_globals(&self, other: &Self) -> Option { + PyComponentsDifference::compare(&self.globals, &other.globals) + } + + pub fn changed_components(&self, other: &Self) -> Option { + PyComponentsDifference::compare(&self.components, &other.components) + } + + pub fn changed_structs_or_enums(&self, other: &Self) -> Option { + PyStructsOrEnumsDifference::compare(&self.structs_and_enums, &other.structs_and_enums) + } +} + +pub struct PyComponentsDifference { + pub added_components: Vec, + pub removed_components: Vec, + pub changed_components: Vec<(SmolStr, ComponentDifference)>, +} + +impl PyComponentsDifference { + fn compare(orig: &[PyComponent], new: &[PyComponent]) -> Option { + let orig_components = orig + .iter() + .map(|compo| (compo.name.as_str(), compo)) + .collect::>(); + + let new_components = new + .iter() + .map(|compo| (compo.name.as_str(), compo)) + .collect::>(); + + let added_components = new_components + .iter() + .filter_map(|(name, _)| { + if orig_components.contains_key(name) { None } else { Some((*name).into()) } + }) + .collect::>(); + + let removed_components = + orig_components + .iter() + .filter_map(|(name, _)| { + if new_components.contains_key(name) { None } else { Some((*name).into()) } + }) + .collect::>(); + + let changed_components = orig_components + .iter() + .filter_map(|(name, orig_global)| { + let new_glob = new_components.get(name)?; + + let diff = ComponentDifference::compare(&orig_global, &new_glob); + + diff.map(|diff| ((*name).into(), diff)) + }) + .collect::>(); + + if !added_components.is_empty() + || !removed_components.is_empty() + || !changed_components.is_empty() + { + Some(PyComponentsDifference { + added_components, + removed_components, + changed_components, + }) + } else { + None + } + } + + pub fn incompatible_changes(&self) -> bool { + !self.removed_components.is_empty() + || self.changed_components.iter().any(|(_, change)| change.incompatible_changes()) + } +} + +#[derive(PartialEq, Debug)] +pub struct TypeChange { + pub name: SmolStr, + pub old_type: SmolStr, + pub new_type: SmolStr, +} + +#[derive(PartialEq, Debug)] +pub struct ComponentDifference { + // TODO: represent callbacks and functions differently? + pub added_properties: Vec, + pub removed_properties: Vec, + pub type_changed_properties: Vec, + pub added_aliases: Vec, + pub removed_aliases: Vec, +} + +impl ComponentDifference { + fn compare(old_compo: &PyComponent, new_compo: &PyComponent) -> Option { + let orig_props = old_compo + .properties + .iter() + .map(|p| (p.name.as_str(), p)) + .collect::>(); + let new_props = new_compo + .properties + .iter() + .map(|p| (p.name.as_str(), p)) + .collect::>(); + + let added_properties = new_props + .iter() + .filter_map(|(name, new_prop)| { + if orig_props.contains_key(name) { None } else { Some((*new_prop).clone()) } + }) + .collect::>(); + + let removed_properties = + orig_props + .iter() + .filter_map(|(name, old_prop)| { + if new_props.contains_key(name) { None } else { Some((*old_prop).clone()) } + }) + .collect::>(); + + let type_changed_properties = orig_props + .iter() + .filter_map(|(name, orig_prop)| { + let new_prop = new_props.get(name)?; + + if orig_prop.ty != new_prop.ty { + Some(TypeChange { + name: (*name).into(), + old_type: orig_prop.ty.clone(), + new_type: new_prop.ty.clone(), + }) + } else { + None + } + }) + .collect::>(); + + let old_aliases = old_compo.aliases.iter().collect::>(); + let new_aliases = new_compo.aliases.iter().collect::>(); + + let added_aliases = + new_aliases.difference(&old_aliases).map(|s| (*s).clone()).collect::>(); + let removed_aliases = + old_aliases.difference(&new_aliases).map(|s| (*s).clone()).collect::>(); + + let diff = Self { + added_properties, + removed_properties, + type_changed_properties, + added_aliases, + removed_aliases, + }; + if diff.has_difference() { Some(diff) } else { None } + } + + fn has_difference(&self) -> bool { + !self.added_properties.is_empty() + || !self.removed_properties.is_empty() + || !self.type_changed_properties.is_empty() + || !self.added_aliases.is_empty() + || !self.removed_aliases.is_empty() + } + + fn incompatible_changes(&self) -> bool { + !self.removed_properties.is_empty() + || !self.type_changed_properties.is_empty() + || !self.removed_aliases.is_empty() + } +} + +pub struct PyStructsOrEnumsDifference { + pub added_structs: Vec, + pub removed_structs: Vec, + pub changed_structs: Vec<(SmolStr, StructDifference)>, + pub added_enums: Vec, + pub removed_enums: Vec, + pub changed_enums: Vec<(SmolStr, EnumDifference)>, +} + +impl PyStructsOrEnumsDifference { + fn compare(orig: &[PyStructOrEnum], new: &[PyStructOrEnum]) -> Option { + let mut orig_structs = BTreeMap::new(); + let mut orig_enums = BTreeMap::new(); + for struct_or_enum in orig { + match struct_or_enum { + PyStructOrEnum::Struct(py_struct) => { + orig_structs.insert(py_struct.name.as_str(), py_struct); + } + PyStructOrEnum::Enum(py_enum) => { + orig_enums.insert(py_enum.name.as_str(), py_enum); + } + } + } + + let mut new_structs = BTreeMap::new(); + let mut new_enums = BTreeMap::new(); + for struct_or_enum in new { + match struct_or_enum { + PyStructOrEnum::Struct(py_struct) => { + new_structs.insert(py_struct.name.as_str(), py_struct); + } + PyStructOrEnum::Enum(py_enum) => { + new_enums.insert(py_enum.name.as_str(), py_enum); + } + } + } + + let added_structs = + new_structs + .iter() + .filter_map(|(name, _)| { + if orig_structs.contains_key(name) { None } else { Some((*name).into()) } + }) + .collect::>(); + + let added_enums = new_enums + .iter() + .filter_map( + |(name, _)| { + if orig_enums.contains_key(name) { None } else { Some((*name).into()) } + }, + ) + .collect::>(); + + let removed_structs = + orig_structs + .iter() + .filter_map(|(name, _)| { + if new_structs.contains_key(name) { None } else { Some((*name).into()) } + }) + .collect::>(); + + let removed_enums = orig_enums + .iter() + .filter_map( + |(name, _)| { + if new_enums.contains_key(name) { None } else { Some((*name).into()) } + }, + ) + .collect::>(); + + let changed_structs = orig_structs + .iter() + .filter_map(|(name, orig_struct)| { + let new_struct = new_structs.get(name)?; + + let diff = StructDifference::compare(&orig_struct, &new_struct); + + diff.map(|diff| ((*name).into(), diff)) + }) + .collect::>(); + + let changed_enums = orig_enums + .iter() + .filter_map(|(name, orig_enum)| { + let new_enum = new_enums.get(name)?; + + let diff = EnumDifference::compare(&orig_enum, &new_enum); + + diff.map(|diff| ((*name).into(), diff)) + }) + .collect::>(); + + if !added_structs.is_empty() + || !removed_structs.is_empty() + || !changed_structs.is_empty() + || !added_enums.is_empty() + || !removed_enums.is_empty() + || !changed_enums.is_empty() + { + Some(Self { + added_structs, + removed_structs, + changed_structs, + added_enums, + removed_enums, + changed_enums, + }) + } else { + None + } + } + + pub fn incompatible_changes(&self) -> bool { + !self.removed_structs.is_empty() + || !self.removed_enums.is_empty() + || self.changed_structs.iter().any(|(_, c)| c.incompatible_changes()) + || self.changed_enums.iter().any(|(_, c)| c.incompatible_changes()) + } +} + +#[derive(PartialEq, Debug)] +pub struct StructDifference { + pub added_fields: Vec, + pub removed_fields: Vec, + pub type_changed_fields: Vec, + pub added_aliases: Vec, + pub removed_aliases: Vec, +} + +impl StructDifference { + fn compare(old_struct: &PyStruct, new_struct: &PyStruct) -> Option { + let orig_fields = old_struct + .fields + .iter() + .map(|f| (f.name.as_str(), f)) + .collect::>(); + let new_fields = new_struct + .fields + .iter() + .map(|f| (f.name.as_str(), f)) + .collect::>(); + + let added_fields = new_fields + .iter() + .filter_map(|(name, new_field)| { + if orig_fields.contains_key(name) { None } else { Some((*new_field).clone()) } + }) + .collect::>(); + + let removed_fields = orig_fields + .iter() + .filter_map(|(name, old_field)| { + if new_fields.contains_key(name) { None } else { Some((*old_field).clone()) } + }) + .collect::>(); + + let type_changed_fields = orig_fields + .iter() + .filter_map(|(name, orig_field)| { + let new_field = new_fields.get(name)?; + + if orig_field.ty != new_field.ty { + Some(TypeChange { + name: (*name).into(), + old_type: orig_field.ty.clone(), + new_type: new_field.ty.clone(), + }) + } else { + None + } + }) + .collect::>(); + + let old_aliases = old_struct.aliases.iter().collect::>(); + let new_aliases = new_struct.aliases.iter().collect::>(); + + let added_aliases = + new_aliases.difference(&old_aliases).map(|s| (*s).clone()).collect::>(); + let removed_aliases = + old_aliases.difference(&new_aliases).map(|s| (*s).clone()).collect::>(); + + let diff = Self { + added_fields, + removed_fields, + type_changed_fields, + added_aliases, + removed_aliases, + }; + if diff.has_difference() { Some(diff) } else { None } + } + + fn has_difference(&self) -> bool { + !self.added_fields.is_empty() + || !self.removed_fields.is_empty() + || !self.type_changed_fields.is_empty() + || !self.added_aliases.is_empty() + || !self.removed_aliases.is_empty() + } + + fn incompatible_changes(&self) -> bool { + !self.removed_fields.is_empty() + || !self.removed_aliases.is_empty() + || !self.type_changed_fields.is_empty() + } +} + +#[derive(Debug, PartialEq)] +pub struct EnumDifference { + pub added_variants: Vec, + pub removed_variants: Vec, + pub added_aliases: Vec, + pub removed_aliases: Vec, +} + +impl EnumDifference { + fn compare(old_enum: &PyEnum, new_enum: &PyEnum) -> Option { + let old_variants = old_enum.variants.iter().map(|v| &v.name).collect::>(); + let new_variants = new_enum.variants.iter().map(|v| &v.name).collect::>(); + + let added_variants = + new_variants.difference(&old_variants).map(|s| (*s).clone()).collect::>(); + let removed_variants = + old_variants.difference(&new_variants).map(|s| (*s).clone()).collect::>(); + + let old_aliases = old_enum.aliases.iter().collect::>(); + let new_aliases = new_enum.aliases.iter().collect::>(); + + let added_aliases = + new_aliases.difference(&old_aliases).map(|s| (*s).clone()).collect::>(); + let removed_aliases = + old_aliases.difference(&new_aliases).map(|s| (*s).clone()).collect::>(); + + let diff = Self { added_variants, removed_variants, added_aliases, removed_aliases }; + if diff.has_difference() { Some(diff) } else { None } + } + + fn has_difference(&self) -> bool { + !self.added_variants.is_empty() + || !self.removed_variants.is_empty() + || !self.added_aliases.is_empty() + || !self.removed_aliases.is_empty() + } + + fn incompatible_changes(&self) -> bool { + !self.removed_variants.is_empty() || !self.removed_aliases.is_empty() + } +} + +#[test] +fn globals() { + let old = super::PyModule { + globals: vec![ + PyComponent { + name: SmolStr::new_static("SameGlobal"), + properties: vec![PyProperty { + name: SmolStr::new_static("str_prop"), + ty: SmolStr::new_static("str"), + }], + aliases: vec![SmolStr::new_static("SameGlobalAlias")], + }, + PyComponent { + name: SmolStr::new_static("ChangedGlobal"), + properties: vec![ + PyProperty { + name: SmolStr::new_static("same_str_prop"), + ty: SmolStr::new_static("str"), + }, + PyProperty { + name: SmolStr::new_static("change_to_int_prop"), + ty: SmolStr::new_static("str"), + }, + PyProperty { + name: SmolStr::new_static("removed_prop"), + ty: SmolStr::new_static("int"), + }, + ], + aliases: vec![SmolStr::new_static("ChangedGlobalAlias")], + }, + PyComponent { + name: SmolStr::new_static("ToBeRemoved"), + properties: vec![], + aliases: vec![], + }, + ], + ..Default::default() + }; + + let new = super::PyModule { + globals: vec![ + PyComponent { + name: SmolStr::new_static("SameGlobal"), + properties: vec![PyProperty { + name: SmolStr::new_static("str_prop"), + ty: SmolStr::new_static("str"), + }], + aliases: vec![SmolStr::new_static("SameGlobalAlias")], + }, + PyComponent { + name: SmolStr::new_static("ChangedGlobal"), + properties: vec![ + PyProperty { + name: SmolStr::new_static("same_str_prop"), + ty: SmolStr::new_static("str"), + }, + PyProperty { + name: SmolStr::new_static("change_to_int_prop"), + ty: SmolStr::new_static("int"), + }, + PyProperty { + name: SmolStr::new_static("new_prop"), + ty: SmolStr::new_static("float"), + }, + ], + aliases: vec![SmolStr::new_static("NewGlobalAlias")], + }, + PyComponent { + name: SmolStr::new_static("NewGlobal"), + properties: vec![PyProperty { + name: SmolStr::new_static("str_prop"), + ty: SmolStr::new_static("str"), + }], + aliases: vec![], + }, + ], + ..Default::default() + }; + + assert!(old.changed_globals(&old).is_none()); + + let changed = old.changed_globals(&new); + assert!(changed.is_some()); + let changed = changed.unwrap(); + + assert_eq!(changed.added_components, vec![SmolStr::new_static("NewGlobal")]); + assert_eq!(changed.removed_components, vec![SmolStr::new_static("ToBeRemoved")]); + + let expected_glob_change = ComponentDifference { + added_properties: vec![PyProperty { + name: SmolStr::new_static("new_prop"), + ty: SmolStr::new_static("float"), + }], + removed_properties: vec![PyProperty { + name: SmolStr::new_static("removed_prop"), + ty: SmolStr::new_static("int"), + }], + type_changed_properties: vec![TypeChange { + name: SmolStr::new_static("change_to_int_prop"), + old_type: SmolStr::new_static("str"), + new_type: SmolStr::new_static("int"), + }], + added_aliases: vec![SmolStr::new_static("NewGlobalAlias")], + removed_aliases: vec![SmolStr::new_static("ChangedGlobalAlias")], + }; + + assert_eq!( + changed.changed_components, + vec![(SmolStr::new_static("ChangedGlobal"), expected_glob_change)] + ); +} + +#[test] +fn structs_and_enums() { + let old = super::PyModule { + structs_and_enums: vec![ + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("SameStruct"), + fields: vec![PyStructField { + name: SmolStr::new_static("intfield"), + ty: SmolStr::new_static("int"), + }], + aliases: vec![SmolStr::new_static("SameStructalias")], + }), + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("StructWithChangedFields"), + fields: vec![ + PyStructField { + name: SmolStr::new_static("removed_field"), + ty: SmolStr::new_static("str"), + }, + PyStructField { + name: SmolStr::new_static("unchanged_field"), + ty: SmolStr::new_static("str"), + }, + PyStructField { + name: SmolStr::new_static("to_int_field"), + ty: SmolStr::new_static("float"), + }, + ], + aliases: vec![SmolStr::new_static("RemovedAlias")], + }), + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("RemovedStruct"), + fields: vec![], + aliases: vec![], + }), + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("StructBecomesEnum"), + fields: vec![], + aliases: vec![], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("SameEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![SmolStr::new_static("SameEnumAlias")], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("ChangedEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![SmolStr::new_static("ChangedEnumRemovedAlias")], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("RemovedEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![], + }), + ], + ..Default::default() + }; + + let new = super::PyModule { + structs_and_enums: vec![ + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("SameStruct"), + fields: vec![PyStructField { + name: SmolStr::new_static("intfield"), + ty: SmolStr::new_static("int"), + }], + aliases: vec![SmolStr::new_static("SameStructalias")], + }), + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("StructWithChangedFields"), + fields: vec![ + PyStructField { + name: SmolStr::new_static("added_field"), + ty: SmolStr::new_static("str"), + }, + PyStructField { + name: SmolStr::new_static("unchanged_field"), + ty: SmolStr::new_static("str"), + }, + PyStructField { + name: SmolStr::new_static("to_int_field"), + ty: SmolStr::new_static("int"), + }, + ], + aliases: vec![SmolStr::new_static("NewAlias")], + }), + PyStructOrEnum::Struct(PyStruct { + name: SmolStr::new_static("AddedStruct"), + fields: vec![], + aliases: vec![], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("StructBecomesEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("SameEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![SmolStr::new_static("SameEnumAlias")], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("ChangedEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant3"), + strvalue: SmolStr::new_static("Variant3"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant4"), + strvalue: SmolStr::new_static("Variant4"), + }, + ], + aliases: vec![SmolStr::new_static("ChangedEnumAddedAlias")], + }), + PyStructOrEnum::Enum(PyEnum { + name: SmolStr::new_static("AddedEnum"), + variants: vec![ + PyEnumVariant { + name: SmolStr::new_static("Variant1"), + strvalue: SmolStr::new_static("Variant1"), + }, + PyEnumVariant { + name: SmolStr::new_static("Variant2"), + strvalue: SmolStr::new_static("Variant2"), + }, + ], + aliases: vec![], + }), + ], + ..Default::default() + }; + + assert!(old.changed_structs_or_enums(&old).is_none()); + + let changed = old.changed_structs_or_enums(&new); + assert!(changed.is_some()); + let changed = changed.unwrap(); + + assert_eq!(changed.added_structs, vec![SmolStr::new_static("AddedStruct")]); + assert_eq!( + changed.removed_structs, + vec![SmolStr::new_static("RemovedStruct"), SmolStr::new_static("StructBecomesEnum")] + ); + + assert_eq!( + changed.added_enums, + vec![SmolStr::new_static("AddedEnum"), SmolStr::new_static("StructBecomesEnum")] + ); + assert_eq!(changed.removed_enums, vec![SmolStr::new_static("RemovedEnum")]); + + let expected_struct_change = StructDifference { + added_fields: vec![PyStructField { + name: SmolStr::new_static("added_field"), + ty: SmolStr::new_static("str"), + }], + removed_fields: vec![PyStructField { + name: SmolStr::new_static("removed_field"), + ty: SmolStr::new_static("str"), + }], + type_changed_fields: vec![TypeChange { + name: SmolStr::new_static("to_int_field"), + old_type: SmolStr::new_static("float"), + new_type: SmolStr::new_static("int"), + }], + added_aliases: vec![SmolStr::new_static("NewAlias")], + removed_aliases: vec![SmolStr::new_static("RemovedAlias")], + }; + + assert_eq!( + changed.changed_structs, + vec![(SmolStr::new_static("StructWithChangedFields"), expected_struct_change)] + ); + + let expected_enum_change = EnumDifference { + added_variants: vec![SmolStr::new_static("Variant3"), SmolStr::new_static("Variant4")], + removed_variants: vec![SmolStr::new_static("Variant1"), SmolStr::new_static("Variant2")], + added_aliases: vec![SmolStr::new_static("ChangedEnumAddedAlias")], + removed_aliases: vec![SmolStr::new_static("ChangedEnumRemovedAlias")], + }; + + assert_eq!( + changed.changed_enums, + vec![(SmolStr::new_static("ChangedEnum"), expected_enum_change)] + ); +} diff --git a/tests/cases/bindings/two_way_global.slint b/tests/cases/bindings/two_way_global.slint index 5b5b2aefc5..56b489f7c4 100644 --- a/tests/cases/bindings/two_way_global.slint +++ b/tests/cases/bindings/two_way_global.slint @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 // See FIXME in the js test -//ignore: live-preview +//ignore: live-preview,pyi export global G1 { in-out property data: 42; diff --git a/tests/cases/callbacks/callback_alias.slint b/tests/cases/callbacks/callback_alias.slint index 706a593122..7fed954db4 100644 --- a/tests/cases/callbacks/callback_alias.slint +++ b/tests/cases/callbacks/callback_alias.slint @@ -60,4 +60,11 @@ assert.equal(instance.foo1_alias(100), 122); assert.equal(instance.foo2_alias(100), 188); assert.equal(instance.call_foo2(100), 188); ``` + +```python +instance = TestCase(); +assert instance.foo1_alias(100) == 122; +assert instance.foo2_alias(100) == 188; +assert instance.call_foo2(100) == 188; +``` */ diff --git a/tests/cases/elements/component_container.slint b/tests/cases/elements/component_container.slint index 9ed363fe21..2050055688 100644 --- a/tests/cases/elements/component_container.slint +++ b/tests/cases/elements/component_container.slint @@ -3,7 +3,7 @@ // FIXME: Skip embedding test on C++ and NodeJS since ComponentFactory is not // implemented there! -//ignore: cpp,js +//ignore: cpp,js,pyi import { Button } from "std-widgets.slint"; diff --git a/tests/cases/elements/component_container_component.slint b/tests/cases/elements/component_container_component.slint index 681c305e04..45e7c7620c 100644 --- a/tests/cases/elements/component_container_component.slint +++ b/tests/cases/elements/component_container_component.slint @@ -3,7 +3,7 @@ // FIXME: Skip embedding test on C++ and NodeJS since ComponentFactory is not // implemented there! -//ignore: cpp,js +//ignore: cpp,js,pyi import { Button } from "std-widgets.slint"; diff --git a/tests/cases/elements/component_container_init.slint b/tests/cases/elements/component_container_init.slint index 5c2f5e00a8..6571ce088a 100644 --- a/tests/cases/elements/component_container_init.slint +++ b/tests/cases/elements/component_container_init.slint @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 // FIXME: Skip embedding test on C++ and NodeJS since ComponentFactory is not implemented there! -//ignore: cpp,js +//ignore: cpp,js,pyi export component TestCase { HorizontalLayout { diff --git a/tests/cases/elements/component_container_size.slint b/tests/cases/elements/component_container_size.slint index bea5a4e2fa..4ae601f36d 100644 --- a/tests/cases/elements/component_container_size.slint +++ b/tests/cases/elements/component_container_size.slint @@ -3,7 +3,7 @@ // FIXME: Skip embedding test on C++ and NodeJS since ComponentFactory is not // implemented there! -//ignore: cpp,js +//ignore: cpp,js,pyi import { Button } from "std-widgets.slint"; diff --git a/tests/cases/examples/hello.slint b/tests/cases/examples/hello.slint index 717012c05a..97bb3f892e 100644 --- a/tests/cases/examples/hello.slint +++ b/tests/cases/examples/hello.slint @@ -209,4 +209,15 @@ instance.window.hide(); assert(!instance.window.visible); ``` +```pyi +class Hello(slint.Component): + arrow_down_commands: str + counter: float + foobar: typing.Callable[[], None] + funky_shape_commands: str + minus_clicked: typing.Callable[[], None] + plus_clicked: typing.Callable[[], None] + width2: float +``` + */ diff --git a/tests/cases/exports/cpp_namespace.slint b/tests/cases/exports/cpp_namespace.slint index 43d63358bd..e0681369f5 100644 --- a/tests/cases/exports/cpp_namespace.slint +++ b/tests/cases/exports/cpp_namespace.slint @@ -2,7 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 //cpp-namespace: my::ui -//ignore: rust,js +//ignore: rust,js,pyi struct TestStruct { condition: bool, } diff --git a/tests/cases/exports/named_exports.slint b/tests/cases/exports/named_exports.slint index 40050c6e9f..6b54c97eca 100644 --- a/tests/cases/exports/named_exports.slint +++ b/tests/cases/exports/named_exports.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { ExportedStruct, ExportEnum } from "export_structs.slint"; export { ExportedStruct as NamedStruct, ExportEnum as NamedEnum } diff --git a/tests/cases/globals/global_accessor_api.slint b/tests/cases/globals/global_accessor_api.slint index bdcf0ea35d..57412eb956 100644 --- a/tests/cases/globals/global_accessor_api.slint +++ b/tests/cases/globals/global_accessor_api.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { StyleMetrics } from "std-widgets.slint"; diff --git a/tests/cases/imports/duplicated_name.slint b/tests/cases/imports/duplicated_name.slint index 46395fb5fe..637f6f3296 100644 --- a/tests/cases/imports/duplicated_name.slint +++ b/tests/cases/imports/duplicated_name.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { ColorButton } from "test_button.slint"; import { TestButton as TheRealTestButton } from "re_export.slint"; diff --git a/tests/cases/imports/external_globals.slint b/tests/cases/imports/external_globals.slint index b522e506e0..37d03dc0d7 100644 --- a/tests/cases/imports/external_globals.slint +++ b/tests/cases/imports/external_globals.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { UseGlobal } from "export_globals.slint"; TestCase := Rectangle { diff --git a/tests/cases/imports/external_globals_nameclash.slint b/tests/cases/imports/external_globals_nameclash.slint index ffa41842c1..c7eccafc5b 100644 --- a/tests/cases/imports/external_globals_nameclash.slint +++ b/tests/cases/imports/external_globals_nameclash.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { UseGlobal, diff --git a/tests/cases/imports/external_structs.slint b/tests/cases/imports/external_structs.slint index a74ed8fb6e..b4050bd97d 100644 --- a/tests/cases/imports/external_structs.slint +++ b/tests/cases/imports/external_structs.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { UseStruct , ExportedStruct, ExportEnum , } from "export_structs.slint"; TestCase := Rectangle { diff --git a/tests/cases/imports/external_type.slint b/tests/cases/imports/external_type.slint index 4b942df881..f69464ab95 100644 --- a/tests/cases/imports/external_type.slint +++ b/tests/cases/imports/external_type.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { TestButton as RealButton, } from "test_button.slint"; import { ColorButton } from "../helper_components/test_button.slint"; diff --git a/tests/cases/imports/just_import.slint b/tests/cases/imports/just_import.slint index 9a3690a4a8..f6615514ce 100644 --- a/tests/cases/imports/just_import.slint +++ b/tests/cases/imports/just_import.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { MainWindow } from "main_window.slint"; diff --git a/tests/cases/imports/library.slint b/tests/cases/imports/library.slint index 721c16003b..ffd732d524 100644 --- a/tests/cases/imports/library.slint +++ b/tests/cases/imports/library.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //library_path(helper_components): ../../helper_components/ //library_path(helper_buttons): ../../helper_components/test_button.slint import { TestButton } from "@helper_components/test_button.slint"; diff --git a/tests/cases/imports/reexport.slint b/tests/cases/imports/reexport.slint index e1aaf6d02a..36ec1df820 100644 --- a/tests/cases/imports/reexport.slint +++ b/tests/cases/imports/reexport.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { TestButton, ColorButton } from "re_export_all.slint"; TestCase := Rectangle { diff --git a/tests/cases/imports/reexport2.slint b/tests/cases/imports/reexport2.slint index be2cc9c45d..a52d7733aa 100644 --- a/tests/cases/imports/reexport2.slint +++ b/tests/cases/imports/reexport2.slint @@ -1,8 +1,11 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + //include_path: ../../helper_components import { TestButton, TheWindow } from "re_export2.slint"; export component TestCase inherits TheWindow { - TestButton {} + TestButton { } } diff --git a/tests/cases/properties/animation_props_depends.slint b/tests/cases/properties/animation_props_depends.slint index 79c33323ff..157a47636e 100644 --- a/tests/cases/properties/animation_props_depends.slint +++ b/tests/cases/properties/animation_props_depends.slint @@ -1,6 +1,9 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 +// Python is ignored because include paths aren't forwarded in the generated stubs. +//ignore: pyi + component Slider { in-out property value; TouchArea { diff --git a/tests/cases/text/componentcontainer_font_size_propagation.slint b/tests/cases/text/componentcontainer_font_size_propagation.slint index 645648f11b..7fb649eeb2 100644 --- a/tests/cases/text/componentcontainer_font_size_propagation.slint +++ b/tests/cases/text/componentcontainer_font_size_propagation.slint @@ -1,7 +1,7 @@ // Copyright © SixtyFPS GmbH // SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 -//ignore: cpp,js +//ignore: cpp,js,pyi export component TestCase inherits Window { default-font-size: 24px; diff --git a/tests/cases/types/bool.slint b/tests/cases/types/bool.slint index bff3be126c..338b43f438 100644 --- a/tests/cases/types/bool.slint +++ b/tests/cases/types/bool.slint @@ -25,4 +25,10 @@ var instance = new slint.TestCase({}); assert(instance.truevar); assert(!instance.falsevar); ``` + +```python +instance = TestCase() +assert instance.truevar +assert not instance.falsevar +``` */ diff --git a/tests/cases/types/enum_compare.slint b/tests/cases/types/enum_compare.slint index 52190a5e1f..cc293c70e7 100644 --- a/tests/cases/types/enum_compare.slint +++ b/tests/cases/types/enum_compare.slint @@ -22,4 +22,8 @@ assert!(instance.get_test()); var instance = new slint.TestCase({}); assert(instance.test); ``` + +```python +instance = TestCase() +assert instance.test */ diff --git a/tests/cases/types/enums.slint b/tests/cases/types/enums.slint index 8b4c49bc73..f7bdfc9c3a 100644 --- a/tests/cases/types/enums.slint +++ b/tests/cases/types/enums.slint @@ -48,4 +48,44 @@ instance.set_dash(With_dash::Hallo); instance.set_dash(With_dash::HelloWorld); ``` +```pyi +class Foo(enum.StrEnum): + bli = "bli" + bla = "bla" + blu = "blu" + + +class InputType(enum.StrEnum): + Xxx = "Xxx" + Yyy = "Yyy" + Zzz = "Zzz" + + +class With_dash(enum.StrEnum): + hallo = "hallo" + hello_world = "hello-world" + halloWorld = "halloWorld" + + +class TestCase(slint.Component): + dash: With_dash + default: Foo + foo: Foo + input_type: InputType + test: bool +``` + +```python +instance = TestCase() + +assert instance.foo == Foo.bla +assert instance.test +instance.foo = Foo.blu +assert not instance.test + +assert instance.dash == With_dash.halloWorld +instance.dash = With_dash.hallo +instance.dash = With_dash.hello_world +``` + */ diff --git a/tests/cases/types/structs.slint b/tests/cases/types/structs.slint index 515387d173..8056ea389f 100644 --- a/tests/cases/types/structs.slint +++ b/tests/cases/types/structs.slint @@ -87,4 +87,36 @@ assert.equal(instance.player_2_score, 99); assert.equal(instance.player_2.energy_level, 0.4); ``` +```pyi +class Player: + energy_level: float + name: str + score: float + + def __init__(self, *, energy_level: typing.Optional[float] = None, name: typing.Optional[str] = None, score: typing.Optional[float] = None) -> None: ... + + +class Unused: + foo: float + + def __init__(self, *, foo: typing.Optional[float] = None) -> None: ... + + +class With_Dash_And_underscore: + foo_bar: float + + def __init__(self, *, foo_bar: typing.Optional[float] = None) -> None: ... + + +class TestCase(slint.Component): + player_1: Player + player_2: Player + player_2_alias: Player + player_2_score: float + player_list: slint.Model[Player] + players: slint.Model[Player] + test: bool + underscore: With_Dash_And_underscore +``` + */ diff --git a/tests/driver/cpp/cppdriver.rs b/tests/driver/cpp/cppdriver.rs index 961c0e3348..6758d70fcf 100644 --- a/tests/driver/cpp/cppdriver.rs +++ b/tests/driver/cpp/cppdriver.rs @@ -49,6 +49,7 @@ pub fn test(testcase: &test_driver_lib::TestCase) -> Result<(), Box> generator::generate( output_format, &mut generated_cpp, + None, &root_component, &loader.compiler_config, )?; diff --git a/tests/driver/python/Cargo.toml b/tests/driver/python/Cargo.toml new file mode 100644 index 0000000000..62c2302a33 --- /dev/null +++ b/tests/driver/python/Cargo.toml @@ -0,0 +1,32 @@ +# Copyright © SixtyFPS GmbH +# SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +[package] +name = "test-driver-python" +description = "Driver for the python type information in tests/cases in Slint" +authors.workspace = true +edition.workspace = true +homepage.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +version.workspace = true +publish = false + +[[bin]] +path = "main.rs" +name = "test-driver-python" + +[dependencies] +slint = { workspace = true, features = ["std", "compat-1-2"] } +i-slint-backend-testing = { workspace = true, features = ["internal"] } +slint-interpreter = { workspace = true, features = ["std", "compat-1-2", "internal"] } +spin_on = { workspace = true } +tempfile = "3" + +[dev-dependencies] +test_driver_lib = { path = "../driverlib" } +i-slint-compiler = { workspace = true, features = ["default", "python", "display-diagnostics", "bundle-translations"] } + +[build-dependencies] +test_driver_lib = { path = "../driverlib" } diff --git a/tests/driver/python/build.rs b/tests/driver/python/build.rs new file mode 100644 index 0000000000..32fe5ca54c --- /dev/null +++ b/tests/driver/python/build.rs @@ -0,0 +1,45 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use std::io::Write; + +fn main() -> Result<(), Box> { + println!("cargo:rustc-env=SLINT_ENABLE_EXPERIMENTAL_FEATURES=1",); + + let tests_file_path = + std::path::Path::new(&std::env::var_os("OUT_DIR").unwrap()).join("test_functions.rs"); + + let mut tests_file = std::fs::File::create(&tests_file_path)?; + + for testcase in test_driver_lib::collect_test_cases("cases")?.into_iter().filter(|testcase| { + // Style testing not supported yet + testcase.requested_style.is_none() + }) { + println!("cargo:rerun-if-changed={}", testcase.absolute_path.display()); + let test_function_name = testcase.identifier(); + let ignored = testcase.is_ignored("pyi"); + + write!( + tests_file, + r##" + #[test] + {ignore} + fn test_python_{function_name}() {{ + python::test(&test_driver_lib::TestCase{{ + absolute_path: std::path::PathBuf::from(r#"{absolute_path}"#), + relative_path: std::path::PathBuf::from(r#"{relative_path}"#), + requested_style: None, + }}).unwrap(); + }} + "##, + ignore = if ignored { "#[ignore]" } else { "" }, + function_name = test_function_name, + absolute_path = testcase.absolute_path.to_string_lossy(), + relative_path = testcase.relative_path.to_string_lossy(), + )?; + } + + println!("cargo:rustc-env=TEST_FUNCTIONS={}", tests_file_path.to_string_lossy()); + + Ok(()) +} diff --git a/tests/driver/python/main.rs b/tests/driver/python/main.rs new file mode 100644 index 0000000000..926b9a367e --- /dev/null +++ b/tests/driver/python/main.rs @@ -0,0 +1,12 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +#[cfg(test)] +mod python; + +#[cfg(test)] +include!(env!("TEST_FUNCTIONS")); + +fn main() { + println!("Nothing to see here, please run me through cargo test :)"); +} diff --git a/tests/driver/python/python.rs b/tests/driver/python/python.rs new file mode 100644 index 0000000000..2d4c12b5b3 --- /dev/null +++ b/tests/driver/python/python.rs @@ -0,0 +1,203 @@ +// Copyright © SixtyFPS GmbH +// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0 + +use i_slint_compiler::{diagnostics::BuildDiagnostics, *}; +use std::error::Error; +use std::io::Write; +use std::path::PathBuf; +use std::sync::LazyLock; + +pub fn test(testcase: &test_driver_lib::TestCase) -> Result<(), Box> { + let source = std::fs::read_to_string(&testcase.absolute_path)?; + + let include_paths = test_driver_lib::extract_include_paths(&source) + .map(std::path::PathBuf::from) + .collect::>(); + let library_paths = test_driver_lib::extract_library_paths(&source) + .map(|(k, v)| (k.to_string(), std::path::PathBuf::from(v))) + .collect::>(); + + let mut diag = BuildDiagnostics::default(); + let syntax_node = parser::parse(source.clone(), Some(&testcase.absolute_path), &mut diag); + + let mut compiler_config = CompilerConfiguration::new(generator::OutputFormat::Python); + compiler_config.include_paths = include_paths; + compiler_config.library_paths = library_paths; + compiler_config.style = testcase.requested_style.map(str::to_string); + compiler_config.debug_info = true; + if source.contains("//bundle-translations") { + compiler_config.translation_path_bundle = + Some(testcase.absolute_path.parent().unwrap().to_path_buf()); + compiler_config.translation_domain = + Some(testcase.absolute_path.file_stem().unwrap().to_str().unwrap().to_string()); + } + let (root_component, diag, loader) = + spin_on::spin_on(compile_syntax_node(syntax_node, diag, compiler_config)); + + if diag.has_errors() { + let vec = diag.to_string_vec(); + return Err(vec.join("\n").into()); + } + + let mut generated_python_interface: Vec = Vec::new(); + let mut python_file = tempfile::Builder::new().suffix(".py").tempfile()?; + + generator::generate( + generator::OutputFormat::Python, + &mut generated_python_interface, + Some(python_file.path()), + &root_component, + &loader.compiler_config, + )?; + + assert!(!PYTHON_PATH.clone().as_os_str().is_empty()); + + python_file + .write(&generated_python_interface) + .map_err(|err| format!("Error writing generated code: {err}"))?; + python_file + .as_file() + .sync_all() + .map_err(|err| format!("Error flushing generated code to disk: {err}"))?; + let python_file = python_file.into_temp_path(); + + let o = std::process::Command::new("uv") + .arg("init") + .arg("--script") + .arg(&python_file) + .arg("--python") + .arg("3.12") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("Could not launch uv init to add script tags: {err}")) + .unwrap(); + check_output(o); + + let mut pyi_test_functions = + test_driver_lib::extract_test_functions(&source).filter(|x| x.language_id == "pyi"); + + if let Some(expected_pyi) = pyi_test_functions.next().map(|f| f.source.replace("\r\n", "\n")) { + assert!(pyi_test_functions.next().is_none()); + + let generated_python_interface = { + let code = String::from_utf8(generated_python_interface).unwrap(); + let mut lines = code.trim_end().lines().collect::>(); + + let mut pop_front_if = |pattern| { + if lines[0].starts_with(pattern) { + lines.remove(0); + } + }; + + pop_front_if("# This file is auto-generated"); + pop_front_if(""); + pop_front_if("import slint"); + pop_front_if("import typing"); + pop_front_if("import enum"); + pop_front_if("import os"); + pop_front_if(""); + lines.pop(); // Remove call into slint package to load file + lines.join("\n").trim_end().to_string() + }; + + assert_eq!( + expected_pyi, generated_python_interface, + "Generated API differed from expected.\nEXPECTED:\n{}\nACTUAL:\n{}\n", + expected_pyi, generated_python_interface + ); + + if diag.has_errors() { + let vec = diag.to_string_vec(); + return Err(vec.join("\n").into()); + } + }; + + let mut python_test_functions = + test_driver_lib::extract_test_functions(&source).filter(|x| x.language_id == "python"); + + if let Some(python_script) = python_test_functions.next().map(|f| f.source) { + assert!(python_test_functions.next().is_none()); + + // Append the python code to the bottom of the generated file and run it + + let mut f = std::fs::File::options().append(true).open(&python_file).unwrap(); + f.write(python_script.as_bytes()).unwrap(); + }; + + let o = std::process::Command::new("uvx") + .arg("--python") + .arg("3.12") + .arg("ty") + .arg("check") + .arg(&python_file) + .env("PYTHONPATH", PYTHON_PATH.clone()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("Could not launch uv ty check: {err}")) + .unwrap(); + check_output(o); + + let o = std::process::Command::new("uv") + .arg("run") + .arg("--no-cache") + .arg(&python_file) + .env("PYTHONPATH", PYTHON_PATH.clone()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("Could not launch uv run: {err}")) + .unwrap(); + check_output(o); + + Ok(()) +} + +#[track_caller] +fn check_output(o: std::process::Output) { + if !o.status.success() { + eprintln!( + "STDERR:\n{}\nSTDOUT:\n{}", + String::from_utf8_lossy(&o.stderr), + String::from_utf8_lossy(&o.stdout), + ); + panic!("Process Failed {:?}", o.status); + } +} + +static PYTHON_PATH: LazyLock = LazyLock::new(|| { + let python_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../api/python/slint"); + + // Init venv for maturin + check_output( + std::process::Command::new("uv") + .arg("venv") + .current_dir(python_dir.clone()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| { + format!("Could not launch uv init to set up environment for maturin: {err}") + }) + .unwrap(), + ); + + // builds the slint python package + let o = std::process::Command::new("uvx") + .arg("--python") + .arg("3.12") + .arg("maturin") + .arg("develop") + .arg("--uv") + .current_dir(python_dir.clone()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(|err| format!("Could not launch uv build to build wheel: {err}")) + .unwrap(); + + check_output(o); + + python_dir +}); diff --git a/tests/driver/rust/build.rs b/tests/driver/rust/build.rs index 470491cee8..bc627a4df1 100644 --- a/tests/driver/rust/build.rs +++ b/tests/driver/rust/build.rs @@ -189,6 +189,7 @@ fn generate_source( generator::generate( generator::OutputFormat::Rust, output, + None, &root_component, &loader.compiler_config, )?; diff --git a/tests/screenshots/build.rs b/tests/screenshots/build.rs index afd16fdaa8..4d47749083 100644 --- a/tests/screenshots/build.rs +++ b/tests/screenshots/build.rs @@ -207,6 +207,7 @@ fn generate_source( generator::generate( generator::OutputFormat::Rust, output, + None, &root_component, &loader.compiler_config, )?; diff --git a/tools/compiler/Cargo.toml b/tools/compiler/Cargo.toml index ee94941b6c..f637f2ba84 100644 --- a/tools/compiler/Cargo.toml +++ b/tools/compiler/Cargo.toml @@ -26,7 +26,7 @@ sdf-fonts = ["i-slint-compiler/sdf-fonts"] default = ["software-renderer", "jemalloc"] [dependencies] -i-slint-compiler = { workspace = true, features = ["default", "display-diagnostics", "bundle-translations", "cpp", "rust"] } +i-slint-compiler = { workspace = true, features = ["default", "display-diagnostics", "bundle-translations", "cpp", "rust", "python"] } clap = { workspace = true } proc-macro2 = "1.0.11" diff --git a/tools/compiler/main.rs b/tools/compiler/main.rs index 67245b266e..1008239b8a 100644 --- a/tools/compiler/main.rs +++ b/tools/compiler/main.rs @@ -124,6 +124,7 @@ fn main() -> std::io::Result<()> { let mut format = args.format.clone().unwrap_or_else(|| { match std::path::Path::new(&args.output).extension().and_then(|ext| ext.to_str()) { Some("rs") => generator::OutputFormat::Rust, + Some("py") => generator::OutputFormat::Python, _ => generator::OutputFormat::Cpp(Default::default()), } }); @@ -196,10 +197,16 @@ fn main() -> std::io::Result<()> { let diag = diag.check_and_exit_on_error(); if args.output == std::path::Path::new("-") { - generator::generate(format, &mut std::io::stdout(), &doc, &loader.compiler_config)?; + generator::generate(format, &mut std::io::stdout(), None, &doc, &loader.compiler_config)?; } else { let mut file_writer = BufWriter::new(std::fs::File::create(&args.output)?); - generator::generate(format, &mut file_writer, &doc, &loader.compiler_config)?; + generator::generate( + format, + &mut file_writer, + Some(&args.output), + &doc, + &loader.compiler_config, + )?; file_writer.flush()?; }