From b079719a18ffd54534ef12f5f6392f11db1c16bc Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:00:04 +0000 Subject: [PATCH 1/7] Fix stale Python installs, C++ deployments beside a Python one, and setup friction - Install Python files on every build: the install ran after fprime_py linked, so an edit to a .py file alone was never installed. - Build fprime_py only for a deployment whose topology is annotated: a C++ deployment registered first claimed the module and the Python deployment was skipped, leaving an fprime_py that failed to import. A second Python deployment is now skipped with a warning. - Add cmake/FprimePython.cmake, one include replacing the pybind11 and target registration block each project copied. - Add the component cookiecutter, previously in a personal repository. - README: module is fprime_py, document custom bindings, running, and CMake setup. Co-authored-by: M Starch --- README.md | 106 ++++++++++++++---- cmake/FprimePython.cmake | 36 ++++++ cmake/autocoder/fprime_python.cmake | 4 + cmake/target/fprime_python.cmake | 58 ++++++++-- cookiecutters/component/README.md | 11 ++ cookiecutters/component/cookiecutter.json | 16 +++ .../component/hooks/pre_gen_project.py | 7 ++ .../CMakeLists.txt | 22 ++++ .../docs/sdd.md | 66 +++++++++++ .../{{cookiecutter.component_name}}.fpp | 49 ++++++++ .../{{cookiecutter.component_name}}.py | 30 +++++ pyproject.toml | 1 + .../autocode/topology_instance_generator.py | 5 + tests/fixtures/Config.fpp | 7 +- tests/test_generator.py | 33 ++++++ 15 files changed, 420 insertions(+), 31 deletions(-) create mode 100644 cmake/FprimePython.cmake create mode 100644 cookiecutters/component/README.md create mode 100644 cookiecutters/component/cookiecutter.json create mode 100644 cookiecutters/component/hooks/pre_gen_project.py create mode 100644 cookiecutters/component/{{cookiecutter.component_name}}/CMakeLists.txt create mode 100644 cookiecutters/component/{{cookiecutter.component_name}}/docs/sdd.md create mode 100644 cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.fpp create mode 100644 cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.py diff --git a/README.md b/README.md index 07b6363..247529d 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,22 @@ autocoder and its dependencies by running `pip install .` in the `fprime-python` or newer. The autocoder reads the FPP model directly, so `FPRIME_ENABLE_JSON_MODEL_GENERATION` and `fprime-python-model` are no longer needed. -Next, add the path to the download in the `library_locations` list set in settings.ini for a deployment. +Next, add the path to the download in the `library_locations` list set in the project's `settings.ini`. ```ini library_locations: ./lib/fprime-python ``` +Then include `FprimePython` from the project's top-level `CMakeLists.txt`, after `FPrime.cmake` and before +`fprime_setup_included_code()`. It locates the pip-installed pybind11, compiles the project as position +independent code, and registers the `fprime_python` build target that generates and builds the bindings. + +```cmake +include("${CMAKE_CURRENT_LIST_DIR}/lib/fprime/cmake/FPrime.cmake") +include(FprimePython) +fprime_setup_included_code() +``` + When running the code, the same version of Python must be used as the python used to build it. ## Setting a Component Up With Python Bindings @@ -69,6 +79,28 @@ Once finished, the python bindings will be autocoded and included in the next bu as shown below). This will also produce a `.template.py` file in the component folder as a basic template for implementing components in python. +> [!CAUTION] +> The `.template.py` is updated on every build unlike F Prime implementation templates. + +To include the component in the built python package, add the final Python file to the `SOURCES` list. A component +implemented in Python has no `.cpp` or `.hpp` implementation files. + +```cmake +register_fprime_module( + AUTOCODER_INPUTS + "${CMAKE_CURRENT_LIST_DIR}/MyComponent.fpp" # Has annotated component + SOURCES + "${CMAKE_CURRENT_LIST_DIR}/MyComponent.py" # Has Python implementation +) +``` + +A new component with all of the above in place can be stamped out with the cookiecutter in this repository. Run it +from the directory that should hold the component: + +```bash +fprime-util new --from-source /cookiecutters/component +``` + Every port, command, event, telemetry channel, parameter and internal port of the component is bound, as is the overflow hook of each member with `hook` queue-full behavior (see below). Three component features cannot be bound yet, because F Prime declares a handler for each that has no Python equivalent: @@ -133,29 +165,67 @@ deployment topology MyDeployment { An annotated topology must be a `deployment topology`: the binding calls the topology's setup and teardown, and F Prime only generates those for a deployment. -> [!CAUTION] -> The `.template.py` is updated on every build unlike F Prime implementation templates. +The build produces one Python extension module, `fprime_py`, for the annotated deployment, and installs it with +the Python files of every module the deployment uses into `build-artifacts/python`. Python files are reinstalled +by every build, so an edit to one takes effect without rebuilding any C++. -To include the component in the built python package, add the final Python file to the `SOURCES` list. +Deployments whose topology is not annotated run from C++ as usual. Any number of them may be built in the same +project as a Python deployment, in any order. A build supports one Python deployment: a second annotated deployment +is skipped with a warning, because both would install to `build-artifacts/python`. -```cmake -register_fprime_module( - AUTOCODER_INPUTS - "${CMAKE_CURRENT_LIST_DIR}/MyComponent.fpp" # Has annotated component - SOURCES - "${CMAKE_CURRENT_LIST_DIR}/MyComponent.py" # Has Python implementation -) +### Custom Bindings + +`fprime_py` binds what the FPP model defines. A deployment's hand-written types are not in the model, so the +project binds them itself by defining `setup_user_deployment`, declared in `FprimePython/FprimePython.hpp`. Every +Python deployment must define it, and at least bind the `TopologyState` type passed to `setup` and `teardown`: + +```c++ +#include "FprimePython/FprimePython.hpp" +#include "MyDeployment/Top/MyDeploymentTopologyDefs.hpp" + +void setup_user_deployment(pybind11::module_& m) { + pybind11::class_(m, "TopologyState") + .def(pybind11::init<>()) + .def_readwrite("hostname", &MyDeployment::TopologyState::hostname) + .def_readwrite("port", &MyDeployment::TopologyState::port); +} ``` +Add the file to the `SOURCES` of the module that defines the topology. A member bound with `def_readwrite` must own +its value: declare a string member as `std::string`. A `const char*` member is left pointing into a Python string +that is freed once the assignment returns. + +### Running a Deployment + +The deployment's entry point is a Python file named `fsw_main.py` defining a function `fsw_main()`, listed in the +`SOURCES` of the deployment so that it is installed with the rest. It constructs the `TopologyState`, calls +`fprime_py.Os.init()` and the bound `setup`, runs until told to stop, and calls `teardown`. Run it with the Python +that built the project, either directly or through the runner, which finds `build-artifacts/python` from the +working directory or any directory above it: + +```bash +python3 build-artifacts/python/fsw_main.py +fprime-python-runner +``` + +Python instances are reached as attributes of the bound topology's `Instances`, e.g. +`fprime_py.MyDeployment.Instances.myComponent`. Only instances of `@ fprime-python` components are listed. +`FprimePython.PythonRateGroupDriver` is one such component: instantiate it in place of a C++ timer to drive the +rate groups from `fsw_main()` with its blocking `driveRateGroup` call. + +A complete walk-through is in the F´ how-to +[Develop Python Components](https://fprime.jpl.nasa.gov/latest/docs/how-to/develop/python-development/), and a +working project in [fprime-python-reference](https://github.com/fprime-community/fprime-python-reference). + ## F Prime Data Types -Modeled types are available to Python. These types are under the namespace module `fprime_python` and then under their -respective F Prime namespaces. For example, `import fprime_python.Fw.Time.Time` will import the `Fw::Time` type that -lives under `#include "Fw/Time/Time.hpp`. +Modeled types are available to Python. These types are under the module `fprime_py` and then under their +respective F Prime namespaces. For example, `fprime_py.Fw.Time` is the `Fw::Time` type that lives under +`#include "Fw/Time/Time.hpp"`. ```python -from fprime_python.Fw.Time import Time -fw_time_object = Time() +import fprime_py +fw_time_object = fprime_py.Fw.Time() ``` Struct members are available as properties and as the autocoded `get_()`/`set_()` functions. Members @@ -205,7 +275,3 @@ python -m venv .venv && .venv/bin/pip install -e '.[dev]' .venv/bin/python -m mypy .venv/bin/python -m pytest tests ``` - -## TODO: custom bindings - -## TODO: Deployments \ No newline at end of file diff --git a/cmake/FprimePython.cmake b/cmake/FprimePython.cmake new file mode 100644 index 0000000..d107bc8 --- /dev/null +++ b/cmake/FprimePython.cmake @@ -0,0 +1,36 @@ +#### +# FprimePython.cmake: +# +# Sets a project up to build F´ Python bindings. Include it from the project's top-level CMakeLists.txt, after +# including FPrime.cmake and before calling `fprime_setup_included_code()`: +# +# include("${CMAKE_CURRENT_LIST_DIR}/lib/fprime/cmake/FPrime.cmake") +# include(FprimePython) +# fprime_setup_included_code() +# +# It must run in the project's top-level directory scope and before any F´ module is added: the bindings of +# every module are linked into one Python extension, so every module must be compiled as position independent +# code and must be able to include pybind11. +#### +include_guard() + +# The F´ sub-builds only gather model information, and compile nothing that could need pybind11 +if (DEFINED FPRIME_SUB_BUILD_TARGETS) + return() +endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(PYBIND11_FINDPYTHON ON) + +# pybind11 is installed with the fprime-python package, so the interpreter knows where its CMake package is +execute_process(COMMAND python3 -m pybind11 --cmakedir + OUTPUT_VARIABLE FPRIME_PYTHON_PYBIND11_CMAKEDIR + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE FPRIME_PYTHON_PYBIND11_RESULT + ERROR_QUIET) +if (NOT FPRIME_PYTHON_PYBIND11_RESULT EQUAL 0) + message(FATAL_ERROR "[fprime_python] 'python3 -m pybind11 --cmakedir' failed. Install fprime-python into the " + "Python environment used to build, e.g. 'pip install ./lib/fprime-python'.") +endif() +find_package(pybind11 CONFIG REQUIRED PATHS "${FPRIME_PYTHON_PYBIND11_CMAKEDIR}") +link_libraries("pybind11::module" "pybind11::headers") +register_fprime_target(target/fprime_python) diff --git a/cmake/autocoder/fprime_python.cmake b/cmake/autocoder/fprime_python.cmake index 0867721..6f2dc24 100644 --- a/cmake/autocoder/fprime_python.cmake +++ b/cmake/autocoder/fprime_python.cmake @@ -86,11 +86,15 @@ function(fprime_python_setup_autocode MODULE_NAME AC_INPUT_FILES) list(FILTER GENERATED_HPP_FILES INCLUDE REGEX "^.*\\.hpp$") list(FILTER GENERATED_JSON_FILES INCLUDE REGEX "^.*\\.json$") list(FILTER GENERATED_PY_FILES INCLUDE REGEX "^.*\\.py$") + # A bound topology leaves a snippet of its own kind, marking the deployments that run from Python + set(GENERATED_TOPOLOGY_FILES "${GENERATED_JSON_FILES}") + list(FILTER GENERATED_TOPOLOGY_FILES INCLUDE REGEX "^.*\\.topology\\.json$") # Append to target properties for tracking append_list_property("${GENERATED_HPP_FILES}" TARGET "${MODULE_NAME}" PROPERTY FPRIME_PYTHON_GENERATED_HPP_FILES) append_list_property("${GENERATED_JSON_FILES}" TARGET "${MODULE_NAME}" PROPERTY FPRIME_PYTHON_GENERATED_JSON_FILES) append_list_property("${GENERATED_PY_FILES}" TARGET "${MODULE_NAME}" PROPERTY FPRIME_PYTHON_GENERATED_PY_FILES) + append_list_property("${GENERATED_TOPOLOGY_FILES}" TARGET "${MODULE_NAME}" PROPERTY FPRIME_PYTHON_BOUND_TOPOLOGY_FILES) # Regenerate when anything in the model changes, not just this module's own translation units: a # bound component's ports, commands and types may all be defined elsewhere. diff --git a/cmake/target/fprime_python.cmake b/cmake/target/fprime_python.cmake index 758e569..a0886e4 100644 --- a/cmake/target/fprime_python.cmake +++ b/cmake/target/fprime_python.cmake @@ -53,37 +53,75 @@ endfunction(fprime_python_add_module_target) # Function `fprime_python_add_deployment_target`: # -# Generates the pybind11 module for the specified deployment target. This will create a shared library that can be -# imported into Python containing the F´ deployment. +# Generates the pybind11 module for a Python deployment: one whose topology is annotated `@ fprime-python`. This +# creates a shared library, importable from Python as `fprime_py`, containing the F´ deployment. Deployments +# that run from C++ are left alone, so they may be built alongside a Python deployment in any order. # #### function(fprime_python_add_deployment_target MODULE TARGET SOURCES DEPENDENCIES FULL_DEPENDENCIES) fprime_python_add_module_target("${MODULE}" "${TARGET}" "${SOURCES}" "${DEPENDENCIES}") - # Only a single fprime_py module may exist per build: skip additional - # deployments (e.g. other cFS apps within the same cFS mission build) + # A deployment without a bound topology has no setup or teardown for Python to call and defines no + # `setup_user_deployment`, so a module built for it could not be imported + _fprime_python_get_bound_topologies("${MODULE}" "${FULL_DEPENDENCIES}") + if (NOT FPRIME_PYTHON_BOUND_TOPOLOGIES) + message(STATUS "[fprime_python] Skipping deployment '${MODULE}': no '@ fprime-python' topology") + return() + endif() + # Only a single fprime_py module may exist per build, as every Python deployment would install its module + # and Python files to the same place if (TARGET "fprime_py") - message(STATUS "[fprime_python] Skipping deployment '${MODULE}': fprime_py already exists") + get_target_property(FIRST_DEPLOYMENT "fprime_py" FPRIME_PYTHON_DEPLOYMENT) + message(WARNING "[fprime_python] Skipping Python deployment '${MODULE}': fprime_py is already built for " + "'${FIRST_DEPLOYMENT}' and a build supports one Python deployment.") return() endif() message(STATUS "[fprime_python] Deployment '${MODULE}' dependencies: ${FULL_DEPENDENCIES}") - # Generate the Python module initialization file and add it to a pybind11 module called "fprime_python". This sets + # Generate the Python module initialization file and add it to a pybind11 module called "fprime_py". This sets # forth the top level module that users will import to access the deployment. _fprime_python_generate_init_file("${MODULE}" "${FULL_DEPENDENCIES}") pybind11_add_module("fprime_py" "${FPRIME_PYTHON_DEPLOYMENT_INIT_FILE}") + set_target_properties("fprime_py" PROPERTIES FPRIME_PYTHON_DEPLOYMENT "${MODULE}") # Link in all deployment modules and dependencies list(REMOVE_ITEM FULL_DEPENDENCIES "${MODULE}") target_link_libraries("fprime_py" PUBLIC ${FPRIME_PYTHON_DEPLOYMENT_MODULES} ${FULL_DEPENDENCIES} Fw_Types FprimePython) - - # Install the fprime_python target and any associated python files with a post-build install command + + # Install the fprime_py module and any associated python files on every build. Editing a Python file does + # not relink fprime_py, so an install that ran after its link would leave the old Python file installed. + # The install itself only copies what changed. _fprime_python_install_helper("${FULL_DEPENDENCIES}") - add_custom_command(TARGET "fprime_py" POST_BUILD COMMAND "${CMAKE_COMMAND}" + add_custom_target("fprime_py_install" ALL + COMMAND "${CMAKE_COMMAND}" -DCMAKE_INSTALL_COMPONENT=fprime-python -DFPRIME_INSTALL_DEST=${FPRIME_INSTALL_DEST} -DFPRIME_BUILD_DIR=${CMAKE_BINARY_DIR} - -P ${FPRIME_FRAMEWORK_PATH}/cmake/target/fprime_install.cmake) + -P ${FPRIME_FRAMEWORK_PATH}/cmake/target/fprime_install.cmake + COMMENT "Installing fprime_py and Python files" + VERBATIM) + add_dependencies("fprime_py_install" "fprime_py") +endfunction() + +#### +# Function `_fprime_python_get_bound_topologies`: +# +# Finds the topologies bound to Python among a deployment and its dependencies. The autocoder records each as a +# property of the module that defines it. +# +# Sets FPRIME_PYTHON_BOUND_TOPOLOGIES in the caller's scope, empty when the deployment runs from C++. +#### +function(_fprime_python_get_bound_topologies MODULE FULL_DEPENDENCIES) + set(BOUND_TOPOLOGIES) + foreach(DEPENDENCY IN LISTS FULL_DEPENDENCIES MODULE) + if (TARGET "${DEPENDENCY}") + get_target_property(TOPOLOGY_FILES "${DEPENDENCY}" FPRIME_PYTHON_BOUND_TOPOLOGY_FILES) + if (TOPOLOGY_FILES) + list(APPEND BOUND_TOPOLOGIES ${TOPOLOGY_FILES}) + endif() + endif() + endforeach() + set(FPRIME_PYTHON_BOUND_TOPOLOGIES "${BOUND_TOPOLOGIES}" PARENT_SCOPE) endfunction() #### diff --git a/cookiecutters/component/README.md b/cookiecutters/component/README.md new file mode 100644 index 0000000..e6c688d --- /dev/null +++ b/cookiecutters/component/README.md @@ -0,0 +1,11 @@ +# F Prime Python Component Cookiecutter + +Stamps out a component implemented in Python: an FPP model annotated `@ fprime-python`, the Python implementation, +and the `CMakeLists.txt` that registers both. Run it from the directory that should hold the new component, giving +the path to this directory: + +```bash +fprime-util new --from-source /cookiecutters/component +``` + +The new component has one command, one event and one telemetry channel as place-holders. diff --git a/cookiecutters/component/cookiecutter.json b/cookiecutters/component/cookiecutter.json new file mode 100644 index 0000000..2f9aff4 --- /dev/null +++ b/cookiecutters/component/cookiecutter.json @@ -0,0 +1,16 @@ +{ + "component_name": "MyPythonComponent", + "component_short_description": "A component implemented in Python", + "component_namespace": "Components", + "component_kind": [ + "active", + "passive", + "queued" + ], + "__prompts__": { + "component_name": "Component name", + "component_short_description": "Component short description", + "component_namespace": "Component namespace", + "component_kind": "Select component kind" + } +} diff --git a/cookiecutters/component/hooks/pre_gen_project.py b/cookiecutters/component/hooks/pre_gen_project.py new file mode 100644 index 0000000..70931e7 --- /dev/null +++ b/cookiecutters/component/hooks/pre_gen_project.py @@ -0,0 +1,7 @@ +from fprime.util.cookiecutter_wrapper import is_valid_name + +# Check if the component name is valid +if is_valid_name("{{ cookiecutter.component_name }}") != "valid": + raise ValueError( + "Unacceptable component name. Do not use spaces or special characters" + ) diff --git a/cookiecutters/component/{{cookiecutter.component_name}}/CMakeLists.txt b/cookiecutters/component/{{cookiecutter.component_name}}/CMakeLists.txt new file mode 100644 index 0000000..d39683d --- /dev/null +++ b/cookiecutters/component/{{cookiecutter.component_name}}/CMakeLists.txt @@ -0,0 +1,22 @@ +#### +# F Prime CMakeLists.txt: +# +# SOURCES: list of source files (to be compiled) +# AUTOCODER_INPUTS: list of files to be passed to the autocoders +# DEPENDS: list of libraries that this module depends on +# +# More information in the F´ CMake API documentation: +# https://fprime.jpl.nasa.gov/latest/docs/reference/api/cmake/API/ +# +#### + +# Module names are derived from the path from the nearest project/library/framework +# root when not specifically overridden by the developer. i.e. The module defined by +# `Ref/SignalGen/CMakeLists.txt` will be named `Ref_SignalGen`. + +register_fprime_library( + AUTOCODER_INPUTS + "${CMAKE_CURRENT_LIST_DIR}/{{cookiecutter.component_name}}.fpp" + SOURCES + "${CMAKE_CURRENT_LIST_DIR}/{{cookiecutter.component_name}}.py" +) diff --git a/cookiecutters/component/{{cookiecutter.component_name}}/docs/sdd.md b/cookiecutters/component/{{cookiecutter.component_name}}/docs/sdd.md new file mode 100644 index 0000000..b603476 --- /dev/null +++ b/cookiecutters/component/{{cookiecutter.component_name}}/docs/sdd.md @@ -0,0 +1,66 @@ +# {{cookiecutter.component_namespace}}::{{cookiecutter.component_name}} + +{{cookiecutter.component_short_description}} + +## Usage Examples +Add usage examples here + +### Diagrams +Add diagrams here + +### Typical Usage +And the typical usage of the component here + +## Class Diagram +Add a class diagram here + +## Port Descriptions +| Name | Description | +|---|---| +|---|---| + +## Component States +Add component states in the chart below +| Name | Description | +|---|---| +|---|---| + +## Sequence Diagrams +Add sequence diagrams here + +## Parameters +| Name | Description | +|---|---| +|---|---| + +## Commands +| Name | Description | +|---|---| +|---|---| + +## Events +| Name | Description | +|---|---| +|---|---| + +## Telemetry +| Name | Description | +|---|---| +|---|---| + +## Unit Tests +Add unit test descriptions in the chart below +| Name | Description | Output | Coverage | +|---|---|---|---| +|---|---|---|---| + +## Requirements +Add requirements in the chart below +| Name | Description | Validation | +|---|---|---| +|---|---|---| + +## Change Log +| Date | Description | +|---|---| +|---| Initial Draft | \ No newline at end of file diff --git a/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.fpp b/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.fpp new file mode 100644 index 0000000..ff4b2b2 --- /dev/null +++ b/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.fpp @@ -0,0 +1,49 @@ +module {{cookiecutter.component_namespace}} { + @ {{cookiecutter.component_short_description}} + @ fprime-python + {{cookiecutter.component_kind}} component {{cookiecutter.component_name}} { + @ Place-holder command: reports the greeting it is sent as a Hello event + {% if cookiecutter.component_kind == "passive" %}sync{% else %}async{% endif %} command HELLO( + greeting: string size 40 @< Greeting to report + ) + + @ Place-holder event: the greeting received by the HELLO command + event Hello( + greeting: string size 40 @< Greeting received + ) severity activity high format "Hello: {}" + + @ Place-holder telemetry channel: count of HELLO commands received + telemetry HelloCount: U32 + + ############################################################################### + # Standard AC Ports: Required for Channels, Events, Commands, and Parameters # + ############################################################################### + @ Port for requesting the current time + time get port timeCaller + + @ Port for sending command registrations + command reg port cmdRegOut + + @ Port for receiving commands + command recv port cmdIn + + @ Port for sending command responses + command resp port cmdResponseOut + + @ Port for sending textual representation of events + text event port logTextOut + + @ Port for sending events to downlink + event port logOut + + @ Port for sending telemetry channels to downlink + telemetry port tlmOut + + @ Port to return the value of a parameter + param get port prmGetOut + + @ Port to set the value of a parameter + param set port prmSetOut + + } +} diff --git a/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.py b/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.py new file mode 100644 index 0000000..41990b9 --- /dev/null +++ b/cookiecutters/component/{{cookiecutter.component_name}}/{{cookiecutter.component_name}}.py @@ -0,0 +1,30 @@ +""" {{cookiecutter.component_name}} Python component implementation + +This is the Python implementation for the {{cookiecutter.component_name}} component. This class extends the auto-coded python base +class {{cookiecutter.component_name}}Base that provides the necessary plumbing to connect to the C++ stub connected to the rest of +the F Prime topology. + +Each build writes {{cookiecutter.component_name}}.template.py next to this file with a stub for every handler in the model. Copy +the stubs of newly modeled handlers from there into this file. +""" +import fprime_py +from {{cookiecutter.component_name}}BaseAc import {{cookiecutter.component_name}}Base + + +class {{cookiecutter.component_name}}({{cookiecutter.component_name}}Base): + """ Python implementation for the {{cookiecutter.component_name}} component """ + def __init__(self): + """ Construct the component + + The component is constructed during the "init" phase when the component is originally called. During this phase + the F Prime topology is not yet fully constructed, so only basic initialization should be done here. + """ + super().__init__() + self.hello_count = 0 + + def HELLO_cmdHandler(self, opCode, cmdSeq, greeting): + """ Handle the HELLO command """ + self.hello_count += 1 + self.log_ACTIVITY_HI_Hello(greeting) + self.tlmWrite_HelloCount(self.hello_count) + self.cmdResponse_out(opCode, cmdSeq, fprime_py.Fw.CmdResponse(fprime_py.Fw.CmdResponse.T.OK)) diff --git a/pyproject.toml b/pyproject.toml index 5752875..4f12e87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ [project.optional-dependencies] dev = [ "mypy>=1.11", + "cookiecutter>=2.1.1", "pytest>=8", ] diff --git a/src/fprime_python/autocode/topology_instance_generator.py b/src/fprime_python/autocode/topology_instance_generator.py index c89dc98..abae190 100644 --- a/src/fprime_python/autocode/topology_instance_generator.py +++ b/src/fprime_python/autocode/topology_instance_generator.py @@ -95,6 +95,11 @@ class TopologyBindingGenerator(BindingGenerator): Python, and exposes the topology's own setup and teardown. """ + #: A bound topology is what makes a deployment a Python deployment. The build system has to know that + #: when it configures, before any snippet exists to be read, so it is told by the snippet's file name. + #: The extra extension cannot collide with another definition's snippet: FPP names hold no dots. + INVOCATION_SUFFIX = "Binding.topology" + def __init__( self, include_manager: IncludeManager, symbol: fpp.Symbol.Variant, topology: fpp.Topology ) -> None: diff --git a/tests/fixtures/Config.fpp b/tests/fixtures/Config.fpp index 0828040..3bbf5ed 100644 --- a/tests/fixtures/Config.fpp +++ b/tests/fixtures/Config.fpp @@ -7,15 +7,18 @@ type FwSizeType = U64 type FwEventIdType = U32 type FwChanIdType = U32 type FwOpcodeType = U32 +type FwPrmIdType = U32 -@ Stand-ins for the framework ports that commands, events and telemetry need +@ Stand-ins for the framework ports that commands, events, telemetry and parameters need module Fw { type Time type LogBuffer type TlmBuffer type CmdArgBuffer + type ParamBuffer enum CmdResponse { OK } enum LogSeverity { ACTIVITY_LO } + enum ParamValid { VALID } port Log($id: FwEventIdType, ref timeTag: Fw.Time, $severity: LogSeverity, ref args: LogBuffer) port LogText($id: FwEventIdType, ref timeTag: Fw.Time, $severity: LogSeverity, ref $text: string size 256) port Time(ref $time: Fw.Time) @@ -23,4 +26,6 @@ module Fw { port CmdReg(opCode: FwOpcodeType) port CmdResponse(opCode: FwOpcodeType, cmdSeq: U32, response: CmdResponse) port Tlm($id: FwChanIdType, ref timeTag: Fw.Time, ref val: TlmBuffer) + port PrmGet($id: FwPrmIdType, ref val: ParamBuffer) -> ParamValid + port PrmSet($id: FwPrmIdType, ref val: ParamBuffer) } diff --git a/tests/test_generator.py b/tests/test_generator.py index 67cae6f..2b70250 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -12,6 +12,7 @@ from fprime_python.autocode.visitor import AnnotatedDefinitionVisitor FIXTURES = Path(__file__).parent / "fixtures" +COMPONENT_COOKIECUTTER = Path(__file__).parent.parent / "cookiecutters" / "component" def generate(fixture: str, output: Path) -> Dict[str, str]: @@ -65,6 +66,38 @@ def test_topologies_needing_no_annotation_are_accepted(files: Dict[str, str]) -> assert "AnnotatedBindingAc.cpp" in files +def test_only_a_bound_topology_marks_its_deployment(files: Dict[str, str]) -> None: + # The build system builds the Python module for a deployment when, and only when, this file is generated + assert [name for name in files if name.endswith(".topology.json")] == ["AnnotatedBinding.topology.json"] + + +@pytest.mark.parametrize("kind", ["active", "passive", "queued"]) +def test_component_cookiecutter_is_bound(kind: str, tmp_path: Path) -> None: + cookiecutter = pytest.importorskip("cookiecutter.main").cookiecutter + # The name check in the template's hook needs fprime-tools, which the autocoder does not + component = Path( + cookiecutter( + str(COMPONENT_COOKIECUTTER), + no_input=True, + accept_hooks=False, + output_dir=str(tmp_path), + extra_context={"component_name": "Stamped", "component_kind": kind}, + ) + ) + model = load_model([component / "Stamped.fpp"], [FIXTURES / "Config.fpp"]) + visitor = AnnotatedDefinitionVisitor(tmp_path, model.analysis, IncludeManager([tmp_path])) + files = {path.name: contents for path, contents in visitor.generate(model).items()} + # Every handler the model asks for is implemented, under the name the generated base class calls + stubs = [line for line in files["Stamped.template.py"].splitlines() if line.startswith(" def ")] + implementation = (component / "Stamped.py").read_text(encoding="utf-8") + assert stubs + for stub in stubs: + assert stub in implementation + assert "self.log_ACTIVITY_HI_Hello(greeting)" in implementation + assert '"log_ACTIVITY_HI_Hello"' in files["StampedBindingAc.cpp"] + assert '"tlmWrite_HelloCount"' in files["StampedBindingAc.cpp"] + + def test_unannotated_deployment_of_python_components_is_rejected( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: From 13bc3f3736b3518517e6140af71ca97c495bce16 Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:02:03 +0000 Subject: [PATCH 2/7] Find pybind11 with the interpreter F Prime found Co-authored-by: M Starch --- cmake/FprimePython.cmake | 5 +++-- src/fprime_python/autocode/topology_instance_generator.py | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmake/FprimePython.cmake b/cmake/FprimePython.cmake index d107bc8..b733ffc 100644 --- a/cmake/FprimePython.cmake +++ b/cmake/FprimePython.cmake @@ -22,13 +22,14 @@ set(CMAKE_POSITION_INDEPENDENT_CODE ON) set(PYBIND11_FINDPYTHON ON) # pybind11 is installed with the fprime-python package, so the interpreter knows where its CMake package is -execute_process(COMMAND python3 -m pybind11 --cmakedir +find_program(PYTHON NAMES python3 python) +execute_process(COMMAND "${PYTHON}" -m pybind11 --cmakedir OUTPUT_VARIABLE FPRIME_PYTHON_PYBIND11_CMAKEDIR OUTPUT_STRIP_TRAILING_WHITESPACE RESULT_VARIABLE FPRIME_PYTHON_PYBIND11_RESULT ERROR_QUIET) if (NOT FPRIME_PYTHON_PYBIND11_RESULT EQUAL 0) - message(FATAL_ERROR "[fprime_python] 'python3 -m pybind11 --cmakedir' failed. Install fprime-python into the " + message(FATAL_ERROR "[fprime_python] '${PYTHON} -m pybind11 --cmakedir' failed. Install fprime-python into the " "Python environment used to build, e.g. 'pip install ./lib/fprime-python'.") endif() find_package(pybind11 CONFIG REQUIRED PATHS "${FPRIME_PYTHON_PYBIND11_CMAKEDIR}") diff --git a/src/fprime_python/autocode/topology_instance_generator.py b/src/fprime_python/autocode/topology_instance_generator.py index abae190..f3c2b03 100644 --- a/src/fprime_python/autocode/topology_instance_generator.py +++ b/src/fprime_python/autocode/topology_instance_generator.py @@ -95,9 +95,8 @@ class TopologyBindingGenerator(BindingGenerator): Python, and exposes the topology's own setup and teardown. """ - #: A bound topology is what makes a deployment a Python deployment. The build system has to know that - #: when it configures, before any snippet exists to be read, so it is told by the snippet's file name. - #: The extra extension cannot collide with another definition's snippet: FPP names hold no dots. + #: Names the snippet so the build can tell, at configure time, that this deployment runs from Python. + #: FPP names hold no dots, so the extra extension cannot collide with another definition's snippet. INVOCATION_SUFFIX = "Binding.topology" def __init__( From 44aad3f52e7655a27da45dc65b65883d46af45aa Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:27:14 +0000 Subject: [PATCH 3/7] Link the Python deployment executable to pybind11::embed and test the build target - The executable of a Python deployment links modules that call into Python, so it failed to link unless each project added pybind11::embed itself. - Add tests of target/fprime_python.cmake against a stand-in project: deployment order, C++-only builds, a second Python deployment, Python reinstall without relinking, and template copy-back. Co-authored-by: M Starch --- .github/workflows/check.yml | 2 +- README.md | 7 ++ cmake/target/fprime_python.cmake | 12 +-- tests/cmake/CMakeLists.txt | 65 +++++++++++++ tests/cmake/Component.py | 1 + tests/cmake/empty.c | 2 + tests/cmake/main.c | 3 + tests/cmake/stubs/autocoder/autocoder.cmake | 1 + .../stubs/cmake/target/fprime_install.cmake | 4 + tests/test_cmake.py | 92 +++++++++++++++++++ 10 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 tests/cmake/CMakeLists.txt create mode 100644 tests/cmake/Component.py create mode 100644 tests/cmake/empty.c create mode 100644 tests/cmake/main.c create mode 100644 tests/cmake/stubs/autocoder/autocoder.cmake create mode 100644 tests/cmake/stubs/cmake/target/fprime_install.cmake create mode 100644 tests/test_cmake.py diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 824f4f9..5bedd10 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -50,5 +50,5 @@ jobs: - name: Run mypy run: python -m mypy - - name: Run generator tests + - name: Run tests run: python -m pytest tests diff --git a/README.md b/README.md index 247529d..0ff909c 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ The build produces one Python extension module, `fprime_py`, for the annotated d the Python files of every module the deployment uses into `build-artifacts/python`. Python files are reinstalled by every build, so an edit to one takes effect without rebuilding any C++. +The executable of an annotated deployment is linked against `pybind11::embed` by the build, because the components +it links call into Python. That executable is not what runs the deployment (see +[Running a Deployment](#running-a-deployment)), so its `Main.cpp` may be an empty `main`. + Deployments whose topology is not annotated run from C++ as usual. Any number of them may be built in the same project as a Python deployment, in any order. A build supports one Python deployment: a second annotated deployment is skipped with a warning, because both would install to `build-artifacts/python`. @@ -213,6 +217,9 @@ Python instances are reached as attributes of the bound topology's `Instances`, `FprimePython.PythonRateGroupDriver` is one such component: instantiate it in place of a C++ timer to drive the rate groups from `fsw_main()` with its blocking `driveRateGroup` call. +A deployment may list `FprimePython_PythonConsole` under `CHOOSES_IMPLEMENTATIONS` to send F Prime console output +through Python's `print`. + A complete walk-through is in the F´ how-to [Develop Python Components](https://fprime.jpl.nasa.gov/latest/docs/how-to/develop/python-development/), and a working project in [fprime-python-reference](https://github.com/fprime-community/fprime-python-reference). diff --git a/cmake/target/fprime_python.cmake b/cmake/target/fprime_python.cmake index a0886e4..568f7e7 100644 --- a/cmake/target/fprime_python.cmake +++ b/cmake/target/fprime_python.cmake @@ -60,15 +60,13 @@ endfunction(fprime_python_add_module_target) #### function(fprime_python_add_deployment_target MODULE TARGET SOURCES DEPENDENCIES FULL_DEPENDENCIES) fprime_python_add_module_target("${MODULE}" "${TARGET}" "${SOURCES}" "${DEPENDENCIES}") - # A deployment without a bound topology has no setup or teardown for Python to call and defines no - # `setup_user_deployment`, so a module built for it could not be imported + # A deployment without a bound topology runs from C++: it has nothing for Python to import _fprime_python_get_bound_topologies("${MODULE}" "${FULL_DEPENDENCIES}") if (NOT FPRIME_PYTHON_BOUND_TOPOLOGIES) message(STATUS "[fprime_python] Skipping deployment '${MODULE}': no '@ fprime-python' topology") return() endif() - # Only a single fprime_py module may exist per build, as every Python deployment would install its module - # and Python files to the same place + # Every Python deployment would install its fprime_py and Python files to the same place if (TARGET "fprime_py") get_target_property(FIRST_DEPLOYMENT "fprime_py" FPRIME_PYTHON_DEPLOYMENT) message(WARNING "[fprime_python] Skipping Python deployment '${MODULE}': fprime_py is already built for " @@ -76,6 +74,8 @@ function(fprime_python_add_deployment_target MODULE TARGET SOURCES DEPENDENCIES return() endif() message(STATUS "[fprime_python] Deployment '${MODULE}' dependencies: ${FULL_DEPENDENCIES}") + # The deployment executable links the bindings of its modules, so it needs the Python library + target_link_libraries("${MODULE}" PRIVATE pybind11::embed) # Generate the Python module initialization file and add it to a pybind11 module called "fprime_py". This sets # forth the top level module that users will import to access the deployment. @@ -88,9 +88,7 @@ function(fprime_python_add_deployment_target MODULE TARGET SOURCES DEPENDENCIES target_link_libraries("fprime_py" PUBLIC ${FPRIME_PYTHON_DEPLOYMENT_MODULES} ${FULL_DEPENDENCIES} Fw_Types FprimePython) - # Install the fprime_py module and any associated python files on every build. Editing a Python file does - # not relink fprime_py, so an install that ran after its link would leave the old Python file installed. - # The install itself only copies what changed. + # Install on every build, not after the link of fprime_py: editing a Python file does not relink it _fprime_python_install_helper("${FULL_DEPENDENCIES}") add_custom_target("fprime_py_install" ALL COMMAND "${CMAKE_COMMAND}" diff --git a/tests/cmake/CMakeLists.txt b/tests/cmake/CMakeLists.txt new file mode 100644 index 0000000..f485aff --- /dev/null +++ b/tests/cmake/CMakeLists.txt @@ -0,0 +1,65 @@ +#### +# Stand-in project for tests/test_cmake.py: it calls the functions of target/fprime_python.cmake the way F Prime +# does, with the F Prime and pybind11 functions those rely on replaced by the minimum needed to configure and +# build. The autocoder is not run. +# +# TEST_DEPLOYMENTS lists the deployments to register, in order: "Cpp" has no bound topology; any other does. +#### +cmake_minimum_required(VERSION 3.16) +project(FprimePythonTargetTest C) + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/stubs" "${FPRIME_PYTHON_CMAKE_DIR}") +set(FPRIME_FRAMEWORK_PATH "${CMAKE_CURRENT_LIST_DIR}/stubs") +set(FPRIME_INSTALL_DEST "${CMAKE_BINARY_DIR}/install") +set(EMPTY_SOURCE "${CMAKE_CURRENT_LIST_DIR}/empty.c") + +add_library(pybind11::embed INTERFACE IMPORTED) +add_library(Fw_Types INTERFACE) +add_library(FprimePython INTERFACE) + +function(pybind11_add_module NAME) + add_library("${NAME}" MODULE ${ARGN}) +endfunction() + +# Reports the "generated" sources a test assigned to the module, as the autocoder would +macro(run_ac_set MODULE) + set(AUTOCODER_GENERATED_BUILD_SOURCES ${TEST_GENERATED_SOURCES_${MODULE}}) + set(AUTOCODER_DEPENDENCIES) +endmacro() + +include(target/fprime_python) + +# Replaces the function that runs fprime-python-ac +function(_fprime_python_generate_init_file MODULE FULL_DEPENDENCIES) + set(FPRIME_PYTHON_DEPLOYMENT_MODULES "" PARENT_SCOPE) + set(FPRIME_PYTHON_DEPLOYMENT_INIT_FILE "${EMPTY_SOURCE}" PARENT_SCOPE) +endfunction() + +# A component module with a generated template and a hand-written Python implementation +set(TEMPLATE_FILE "${CMAKE_BINARY_DIR}/Component.template.py") +file(WRITE "${TEMPLATE_FILE}" "# template\n") +add_library(Component STATIC "${EMPTY_SOURCE}") +set(TEST_GENERATED_SOURCES_Component "${EMPTY_SOURCE}") +set_target_properties(Component PROPERTIES FPRIME_PYTHON_GENERATED_PY_FILES "${TEMPLATE_FILE}") +fprime_python_add_module_target(Component "fprime_python" "${CMAKE_CURRENT_LIST_DIR}/Component.py" "") + +foreach(DEPLOYMENT IN LISTS TEST_DEPLOYMENTS) + add_library("${DEPLOYMENT}_Top" STATIC "${EMPTY_SOURCE}") + if (NOT DEPLOYMENT STREQUAL "Cpp") + set_target_properties("${DEPLOYMENT}_Top" PROPERTIES + FPRIME_PYTHON_BOUND_TOPOLOGY_FILES "${DEPLOYMENT}Binding.topology.json") + endif() + add_executable("${DEPLOYMENT}" "${CMAKE_CURRENT_LIST_DIR}/main.c") + fprime_python_add_deployment_target("${DEPLOYMENT}" "fprime_python" "${CMAKE_CURRENT_LIST_DIR}/main.c" + "${DEPLOYMENT}_Top" "Component;${DEPLOYMENT}_Top;${DEPLOYMENT}") + + get_target_property(LINKED "${DEPLOYMENT}" LINK_LIBRARIES) + message(STATUS "RESULT ${DEPLOYMENT} links ${LINKED}") +endforeach() + +if (TARGET fprime_py) + get_target_property(OWNER fprime_py FPRIME_PYTHON_DEPLOYMENT) + message(STATUS "RESULT fprime_py built for ${OWNER}") +else() + message(STATUS "RESULT fprime_py not built") +endif() diff --git a/tests/cmake/Component.py b/tests/cmake/Component.py new file mode 100644 index 0000000..b71dad9 --- /dev/null +++ b/tests/cmake/Component.py @@ -0,0 +1 @@ +VERSION = 1 diff --git a/tests/cmake/empty.c b/tests/cmake/empty.c new file mode 100644 index 0000000..d213880 --- /dev/null +++ b/tests/cmake/empty.c @@ -0,0 +1,2 @@ +/* Stands in for generated and hand-written sources */ +typedef int fprime_python_test_unused; diff --git a/tests/cmake/main.c b/tests/cmake/main.c new file mode 100644 index 0000000..9b6bdc2 --- /dev/null +++ b/tests/cmake/main.c @@ -0,0 +1,3 @@ +int main(void) { + return 0; +} diff --git a/tests/cmake/stubs/autocoder/autocoder.cmake b/tests/cmake/stubs/autocoder/autocoder.cmake new file mode 100644 index 0000000..53275cf --- /dev/null +++ b/tests/cmake/stubs/autocoder/autocoder.cmake @@ -0,0 +1 @@ +# Stand-in for F Prime's autocoder/autocoder.cmake: the test project defines run_ac_set itself diff --git a/tests/cmake/stubs/cmake/target/fprime_install.cmake b/tests/cmake/stubs/cmake/target/fprime_install.cmake new file mode 100644 index 0000000..92e1f29 --- /dev/null +++ b/tests/cmake/stubs/cmake/target/fprime_install.cmake @@ -0,0 +1,4 @@ +# Stand-in for F Prime's cmake/target/fprime_install.cmake: installs one component to FPRIME_INSTALL_DEST +set(ENV{DESTDIR} "${FPRIME_INSTALL_DEST}") +set(CMAKE_INSTALL_PREFIX "/") +include("${FPRIME_BUILD_DIR}/cmake_install.cmake") diff --git a/tests/test_cmake.py b/tests/test_cmake.py new file mode 100644 index 0000000..373b951 --- /dev/null +++ b/tests/test_cmake.py @@ -0,0 +1,92 @@ +""" Tests of target/fprime_python.cmake, run against a stand-in project: see tests/cmake/CMakeLists.txt """ +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path +from typing import List + +import pytest + +CMAKE_DIR = Path(__file__).parent.parent / "cmake" +PROJECT = Path(__file__).parent / "cmake" + +pytestmark = pytest.mark.skipif(shutil.which("cmake") is None, reason="cmake is not installed") + + +class Project: + """ A copy of the stand-in project, since a build writes templates back into its source directory """ + + def __init__(self, root: Path) -> None: + self.source = root / "source" + self.build_dir = root / "build" + self.installed = self.build_dir / "install" / "python" + shutil.copytree(PROJECT, self.source) + + def configure(self, deployments: List[str]) -> str: + """ Configure with the deployments registered in the order given + + Returns the output of CMake with whitespace collapsed, as CMake wraps the text of its warnings. + """ + result = subprocess.run( + ["cmake", "-S", str(self.source), "-B", str(self.build_dir), + f"-DFPRIME_PYTHON_CMAKE_DIR={CMAKE_DIR}", f"-DTEST_DEPLOYMENTS={';'.join(deployments)}"], + capture_output=True, text=True, check=True, + ) + return " ".join((result.stdout + result.stderr).split()) + + def build(self) -> None: + subprocess.run(["cmake", "--build", str(self.build_dir)], capture_output=True, text=True, check=True) + + +@pytest.fixture +def project(tmp_path: Path) -> Project: + return Project(tmp_path) + + +@pytest.mark.parametrize("deployments", [["Cpp", "Py"], ["Py", "Cpp"]]) +def test_fprime_py_is_built_for_the_python_deployment_in_any_order(project: Project, deployments: List[str]) -> None: + output = project.configure(deployments) + assert "RESULT fprime_py built for Py" in output + assert "Skipping deployment 'Cpp': no '@ fprime-python' topology" in output + + +def test_only_the_python_deployment_links_the_python_library(project: Project) -> None: + output = project.configure(["Cpp", "Py"]) + assert "RESULT Py links pybind11::embed" in output + assert "RESULT Cpp links LINKED-NOTFOUND" in output + + +def test_cpp_only_project_builds_no_python_module(project: Project) -> None: + output = project.configure(["Cpp"]) + assert "RESULT fprime_py not built" in output + project.build() + assert not project.installed.exists() + + +def test_second_python_deployment_is_skipped_with_a_warning(project: Project) -> None: + output = project.configure(["Py", "Py2"]) + assert "RESULT fprime_py built for Py" in output + assert "Skipping Python deployment 'Py2': fprime_py is already built for 'Py'" in output + assert "RESULT Py2 links LINKED-NOTFOUND" in output + + +def test_python_edit_is_installed_without_relinking(project: Project) -> None: + project.configure(["Py"]) + project.build() + installed = project.installed / "Component.py" + module = next(project.installed.glob("*fprime_py*")) + assert installed.read_text() == "VERSION = 1\n" + linked = module.stat().st_mtime_ns + + (project.source / "Component.py").write_text("VERSION = 2\n") + project.build() + assert installed.read_text() == "VERSION = 2\n" + assert module.stat().st_mtime_ns == linked + + +def test_template_is_copied_to_the_source_directory_but_not_installed(project: Project) -> None: + project.configure(["Py"]) + project.build() + assert (project.source / "Component.template.py").read_text() == "# template\n" + assert not (project.installed / "Component.template.py").exists() From d663e49cad52b1bcd2b05dc5287a88fd830b1b70 Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:51:43 +0000 Subject: [PATCH 4/7] Test the FprimePython CMake hook Covers target registration, the missing-pybind11 error, and the sub-build early return. Co-authored-by: M Starch --- tests/cmake_hook/CMakeLists.txt | 15 +++++++++++++++ tests/test_cmake.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/cmake_hook/CMakeLists.txt diff --git a/tests/cmake_hook/CMakeLists.txt b/tests/cmake_hook/CMakeLists.txt new file mode 100644 index 0000000..a4d226c --- /dev/null +++ b/tests/cmake_hook/CMakeLists.txt @@ -0,0 +1,15 @@ +#### +# Stand-in project for tests/test_cmake.py: includes FprimePython.cmake the way a project does, with the one +# F Prime function it calls replaced by a report of its argument. +#### +cmake_minimum_required(VERSION 3.16) +project(FprimePythonHookTest C) + +list(APPEND CMAKE_MODULE_PATH "${FPRIME_PYTHON_CMAKE_DIR}") + +function(register_fprime_target TARGET_FILE) + message(STATUS "RESULT registered ${TARGET_FILE}") +endfunction() + +include(FprimePython) +message(STATUS "RESULT position independent ${CMAKE_POSITION_INDEPENDENT_CODE}") diff --git a/tests/test_cmake.py b/tests/test_cmake.py index 373b951..36351aa 100644 --- a/tests/test_cmake.py +++ b/tests/test_cmake.py @@ -3,6 +3,7 @@ import shutil import subprocess +import sys from pathlib import Path from typing import List @@ -10,6 +11,7 @@ CMAKE_DIR = Path(__file__).parent.parent / "cmake" PROJECT = Path(__file__).parent / "cmake" +HOOK_PROJECT = Path(__file__).parent / "cmake_hook" pytestmark = pytest.mark.skipif(shutil.which("cmake") is None, reason="cmake is not installed") @@ -90,3 +92,33 @@ def test_template_is_copied_to_the_source_directory_but_not_installed(project: P project.build() assert (project.source / "Component.template.py").read_text() == "# template\n" assert not (project.installed / "Component.template.py").exists() + + +def configure_hook(tmp_path: Path, python: str, *options: str) -> subprocess.CompletedProcess: + """ Configure a project that includes FprimePython.cmake, finding pybind11 with the interpreter given """ + return subprocess.run( + ["cmake", "-S", str(HOOK_PROJECT), "-B", str(tmp_path / "build"), f"-DFPRIME_PYTHON_CMAKE_DIR={CMAKE_DIR}", + f"-DPYTHON={python}", *options], + capture_output=True, text=True, check=False, + ) + + +def test_hook_registers_the_target_and_position_independent_code(tmp_path: Path) -> None: + pytest.importorskip("pybind11") + result = configure_hook(tmp_path, sys.executable) + assert result.returncode == 0, result.stderr + assert "RESULT registered target/fprime_python" in result.stdout + assert "RESULT position independent ON" in result.stdout + + +def test_hook_names_the_fix_when_pybind11_is_not_installed(tmp_path: Path) -> None: + # An "interpreter" that fails as one without pybind11 does + result = configure_hook(tmp_path, shutil.which("false") or "false") + assert result.returncode != 0 + assert "Install fprime-python into the Python environment used to build" in " ".join(result.stderr.split()) + + +def test_hook_does_nothing_in_a_sub_build(tmp_path: Path) -> None: + result = configure_hook(tmp_path, shutil.which("false") or "false", "-DFPRIME_SUB_BUILD_TARGETS=target/sub") + assert result.returncode == 0, result.stderr + assert "RESULT registered" not in result.stdout From 765cbf599dbb20c9134839cae5214f0df9c0b57a Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:52:14 +0000 Subject: [PATCH 5/7] Name the how-to by its title Co-authored-by: M Starch --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0ff909c..ede1c17 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ A deployment may list `FprimePython_PythonConsole` under `CHOOSES_IMPLEMENTATION through Python's `print`. A complete walk-through is in the F´ how-to -[Develop Python Components](https://fprime.jpl.nasa.gov/latest/docs/how-to/develop/python-development/), and a +[Develop Components in Python](https://fprime.jpl.nasa.gov/latest/docs/how-to/develop/python-development/), and a working project in [fprime-python-reference](https://github.com/fprime-community/fprime-python-reference). ## F Prime Data Types From 44a2118a0fd951caac8cc54db4643c7dcd3b7179 Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:56:32 +0000 Subject: [PATCH 6/7] Install Python files when only the deployment is built fprime-util build from the deployment directory builds the deployment target, which did not depend on fprime_py or its install, leaving build-artifacts/python stale. Co-authored-by: M Starch --- cmake/target/fprime_python.cmake | 2 ++ tests/test_cmake.py | 13 +++++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/cmake/target/fprime_python.cmake b/cmake/target/fprime_python.cmake index 568f7e7..cdc4e91 100644 --- a/cmake/target/fprime_python.cmake +++ b/cmake/target/fprime_python.cmake @@ -99,6 +99,8 @@ function(fprime_python_add_deployment_target MODULE TARGET SOURCES DEPENDENCIES COMMENT "Installing fprime_py and Python files" VERBATIM) add_dependencies("fprime_py_install" "fprime_py") + # Building only the deployment, as `fprime-util build` does from its directory, installs as well + add_dependencies("${MODULE}" "fprime_py_install") endfunction() #### diff --git a/tests/test_cmake.py b/tests/test_cmake.py index 36351aa..bd1b2fb 100644 --- a/tests/test_cmake.py +++ b/tests/test_cmake.py @@ -37,8 +37,10 @@ def configure(self, deployments: List[str]) -> str: ) return " ".join((result.stdout + result.stderr).split()) - def build(self) -> None: - subprocess.run(["cmake", "--build", str(self.build_dir)], capture_output=True, text=True, check=True) + def build(self, target: str = "all") -> None: + subprocess.run( + ["cmake", "--build", str(self.build_dir), "--target", target], capture_output=True, text=True, check=True + ) @pytest.fixture @@ -73,6 +75,13 @@ def test_second_python_deployment_is_skipped_with_a_warning(project: Project) -> assert "RESULT Py2 links LINKED-NOTFOUND" in output +def test_building_only_the_deployment_installs(project: Project) -> None: + project.configure(["Cpp", "Py"]) + project.build("Py") + assert (project.installed / "Component.py").read_text() == "VERSION = 1\n" + assert list(project.installed.glob("*fprime_py*")) + + def test_python_edit_is_installed_without_relinking(project: Project) -> None: project.configure(["Py"]) project.build() From 2f882a563100fe3e342e86ae230511c2625accbb Mon Sep 17 00:00:00 2001 From: lestarch-autobot Date: Tue, 10 Nov 2026 00:58:59 +0000 Subject: [PATCH 7/7] Say which builds install the Python files Co-authored-by: M Starch --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ede1c17..19b836e 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,8 @@ F Prime only generates those for a deployment. The build produces one Python extension module, `fprime_py`, for the annotated deployment, and installs it with the Python files of every module the deployment uses into `build-artifacts/python`. Python files are reinstalled -by every build, so an edit to one takes effect without rebuilding any C++. +by every build of the project or of the deployment, so an edit to one takes effect without rebuilding any C++. +Building a single component's directory installs nothing. The executable of an annotated deployment is linked against `pybind11::embed` by the build, because the components it links call into Python. That executable is not what runs the deployment (see