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()?; }