Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Python

Embedding Python Applications within Gidon C# Plugin Framework

5.00/5 (11 votes)
27 Feb 2023MIT11 min read 10.9K  
How to display Python plots within a C# program
Python is a great language for individual scientists. For developing large applications, C# or other strongly typed languages are better. The article demonstrates how to implant Python plots within a C# plugin framework.

Introduction

Advantages of Python

Recently, I've been working a lot with Python. My opinion is that Python is a great language for individual scientists and developers. It has probably more built-in scientific and plotting libraries than any other language or package and most of the widely used Python libraries are free.

Python is considered very simple and the scientists love this, since they want to concentrate on their area of expertise and not spend a lot of time learning a software language.

Another great advantage of Python is that it is multiplatform and works exactly the same on Windows, Linux and MacOS.

Python has great interactive environments allowing to experiment with software and data while building and tuning Python programs.

Python is an interpreted language and does not require compilation.

Shortcomings of Python

General Python is an interpreted language and because of that is pretty slow. There are some versions of Python that are compiled and run much faster but not many are using them. I assume that the compilation time for those versions is becoming bothersome as the performance of the compiled code improves.

Because of Python's lack of strong typing, its intellisense is akin to JavaScript and much worse than in strongly typed languages like C# or Java. Because of that, Python libraries can be more difficult to learn.

As was mentioned above, Python is a great language for individual development, though it does not have great capabilities for team development, plugins and separation of concerns.

Using C# Avalonia Gidon Plugin Framework for Hosting Python Applications

Gidon MVVM Plugin framework has been described in Gidon - Avalonia based MVVM Plugin IoC Container. In that article, I present a way to create dynamically loaded C# plugins.

Recently, I've added an ability for Gidon to also host Python windows run as separate Python processes.

The Python Windows are implanted into C# by using multiplatform Avalonia UniDock window docking framework which is part of Gidon. The Python windows, thus can be docked or tabbed together or pulled into separate floating windows.

Communications between various Python and C# processes is done via a RelayServer described in Publish/Subscribe gRPC Relay Server with Separation of Concerns and run by the C# process that hosts the main Avalonia window.

Here is the how Gidon sample with three implanted Python windows looks:

Image 1

The code for this sample will be described in detail below.

The Python code for the plots is taken from matplotlib tutorials with added PySide6 code to make standalone windows out of them.

One can use the headers of the Python plots to rearrange them, pull some of them out into stand-alone floating windows or tab them, e.g.:

Image 2

On the picture above, histogram is tabbed together with the Dot Plot while the Sinusoid is docked next to them.

Software Used in the Article

On the C# side, open source Gidon MVVM Plugin Package now incorporates everything else required for the implanting Python windows.

As part of Gidon, I use Avalonia - an open source C# multiplatform version of WPF to create the UI of the Shell into which the Python windows will be implanted.

Gidon window docking functionality is fuelled by Avalonia based UniDock framework.

For individual plots, I use Python matplotlib library combined with PySide6 Python UI library.

For communications between different processes, I use the Grpc based Relay Server (which became part of Gidon framework).

Sample Code Location

The sample code is located under Gidon/DockableAppsDemo folder of NP.Avalonia.Demos repository.

The Visual Studio solution file (that includes both the C# and the Python code) is located within Gidon/DockableAppsDemo/DockableAppImplantsDemo folder at DockableAppImplantsDemo.sln.

Running Sample Code on Windows

Prerequisites

In order to run the sample code on Windows, you need the following prerequisites:

  1. Visual Studio 2022 with Python capability. I use professional edition (not sure if community addition would work, but most likely - yes).
  2. You should install the latest Python3 and Pip3 versions on your Windows machine (Pip for Python is used to install Python packages - similar to nuget for C#).
  3. You should have a functional internet connection for installing C# and Python packages.

Sample Code Structure

The code of the sample consists of one main C# project - DockableAppImplantsDemo and four Python project under Apps solution folder:

  1. CommonPython
  2. DotPyMatPlot
  3. HistogramPlot
  4. SinusoidPyMatPlot

Image 3

CommonPython project is only needed if you want to use a Python Virtual environment as will be explained below.

The rest of the Python projects correspond to various maps within the sample one-to-one.

Running the Sample on your Windows Machine in your Main Python Environment (without Python Virtual Environment)

Running the sample using your main Python environment (instead of creating a special virtual environment) is easier, but it will add some Python packages to your main Python environment.

So if you care to preserve your main Python environment as it was, skip this sub-section and go to the next one explaining how to create a Virtual Python Environment and use it for the sample.

Since you are using the Main Python Environment, choose it for every Python project (if you have only one environment, the Visual Studio should have done it for you already):

Image 4

In one of the projects, e.g., DotPyMatPlot, left click on the main environment and choose Install from requirements.txt option:

Image 5

Since requirements.txt files are the same in every project and since you are updating the same globally available environment, it does not matter which one you choose.

Here is the content or any of the requirement.txt files listing all the packages needed for the Python samples to run:

cycler==0.11.0
grpcio==1.51.3
kiwisolver==1.4.4
matplotlib==3.7.0
NP.Grpc.PythonMessages==0.99.2
NP.Grpc.PythonRelayInterfaces==0.99.2
numpy==1.24.2
packaging==23.0
Pillow==9.4.0
pip==22.3.1
protobuf==4.22.0
pyparsing==3.0.9
PySide6==6.4.2
PySide6-Addons==6.4.2
PySide6-Essentials==6.4.2
python-dateutil==2.8.2
setuptools==65.5.0
shiboken6==6.4.2
six==1.16.0 

The key packages are:

  • matplotlib - for creating the plots
  • numpy - for calculating the data for the plots
  • PySide6 - for building the UI window and (if needed adding buttons and other common UI controls)
  • NP.Grpc.PythonRelayInterfaces - for generic communication mechanism with the RelayServer
  • NP.Grpc.PythonMessages - for a specific WindowInfo message carrying the Window Handle to the Relay Server

After you install Python packages, you can check that the individual Python projects run, by e.g., right mouse clicking on DotPyMatPlot and choosing Debug->Start New Instance:

Image 6

The Python application containing the corresponding plot should popup:

Image 7

The Python application detects that it is stand alone (due to a lack of command line argument) and does not try to call the Relay Server which is part of the C# project and is not running when only Python project is started.

Kill the Python program (if still running).

Right click on the main project DockableAppImplantsDemo and choose Rebuild.

Wait for the successful build and run the main project:

Image 8

Now you can play with the plots by dragging the out, docking and tabbing them in different order.

Using a Virtual Environment for Python Projects

If you do not want to modify your main Python environment, you can still create a Virtual environment within CommonPython project, call it env, activate it, install the packages from requirements.txt to it and all Python projects will pick it up by env name.

Note that you have to create a virtual environment specifically called env otherwise the predefined search paths will not work. If you already have a globally available env environment and do not want to change it, you can create a virtual environment of different names and then modify the Search Paths of all the Python projects, e.g.

Image 9

Also, you'll have to slightly modify the code of each project - specifically lines:

Python
sys.path.append(r'..\CommonPython\env\Lib\site-packages')  

will have to be modified to point to the correct environment path.

To create a new environment under CommonPython project, right click on its Python Environments and choose Add Environment:

Image 10

In the opened dialog, click View in Python environments window and then click Create button:

Image 11

You might need to wait several seconds in order for the environment to appear under Python Environments of the project. Right mouse click on it and choose Activate Environment:

Image 12

The new environment should be populated automatically from requirements.txt, expand it to check its package content and if it is not populated - right mouse click on it and choose Install from requirements.txt.

Now you can test the Python projects individually and build and start the main C# project DockableAppImplantsDemo as was described in the previous sub-section.

Explanations of the Code

Here, I explain both the C# and Python code that allows embedding Python apps into a C# shell.

Code for the Main C# Project DockableAppImplantsDemo

The main C# project of the sample depends on four nuget packages:

  1. NP.Avalonia.Gidon - the main dependency
  2. NP.Grpc.RelayClient - implementation of the RelayServer client
  3. NP.Grpc.RelayServer - implementation of the RelayServer
  4. XamlNameReferenceGenerator - Avalonia file needed for parsing XAML code

Out of the projects listed above, NP.Grpc.RelayClient and NP.Grpc.RelayServer are installed as plugins (as was described in Creating and Installing Plugins as Nuget Packages). This means that their implementation code is not available to the sample program, only their interfaces provided by NP.Grpc.CommonRelayInterfaces package as part of NP.Avalonia.Gidon package.

File App.axaml contains (as usual) the reference to common styles shared across the application:

XAML
<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             x:Class="DockableAppImplantsDemo.App">
    <Application.Styles>
		<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
		<StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
		<StyleInclude Source="avares://NP.Avalonia.Visuals/Themes/CustomWindowStyles.axaml"/>
		<StyleInclude Source="avares://NP.Avalonia.UniDock/Themes/DockStyles.axaml"/>
    </Application.Styles>
</Application>  

App.axaml.cs file is considerably more interesting as it defines a way to build RelayServer and RelayClient objects from IoC provided plugins. It also creates WindowHandleMatcher object that observes the objects of type:

C#
public class WindowInfo
{
   long WindowHandle { get; }
   string UniqueWindowHostId { get; }
}

arrived from the Python programs. A reference to the WindowHandleMatcher is provided within MultiPlatformProcessInitInfoWithMatcher objects that are used for inserting the Windows with the proper handle into their host objects (as will be explained when we look at MainWindow.axaml file).

Here is the documented interesting part of the App.axaml.cs file:

C#
public class App : Application
{
    // IoC Container
    private static IDependencyInjectionContainer<Enum> IoCContainer { get; }

    // IRelayServer (provided so that we could shut it down when the program shuts down)
    private static IRelayServer TheRelayServer { get; }

    // IRelayClient used for getting WindowHandles from the python windows
    // started in different processes
    private static IRelayClient TheRelayClient { get; }

    // Window handle matcher - matches the window handle with the unique
    // window host id
    public static WindowHandleMatcher TheWindowHandleMatcher { get; }

    static App()
    {
        // create the container builder
        IContainerBuilderWithMultiCells<Enum> containerBuilder = 
                                              new ContainerBuilder<Enum>();

        // register a multicell that can container several different Enum values
        // corresponding to the various topics of the RelayServer.
        // Here we use only one topic - WindowInfoTopic the allows to publish 
        // and subscribe object of WindowInfo type 
        // (contained within NP.Gidon.Message package) that 
        // provide the WindowHandle (of type long) and UniqueWindowHostId 
        // (or type string)
        containerBuilder.RegisterMultiCell(typeof(Enum), IoCKeys.Topics);

        // provides an object for determining Grpc server host and port 
        // (in our simple case they are hardcoded to "localhost" 
        // and 5051 correspondingly)
        containerBuilder.RegisterType<IGrpcConfig, GrpcConfig>();

        // provides the topics MultiCell (only one topic WindowInfoTopic - in our case)
        containerBuilder.RegisterAttributedStaticFactoryMethodsFromClass
                         (typeof(MessagesTopicsGetter));

        // picks up the RelayServer and RelayClient implementations 
        // as plugins from Plugins/Services folder
        containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");

        // builds the IoC Container
        IoCContainer = containerBuilder.Build();

        // gets a reference to the Relay Server from the container
        TheRelayServer = IoCContainer.Resolve<IRelayServer>();

        // gets a reference to the RelayClient from the container
        TheRelayClient = IoCContainer.Resolve<IRelayClient>();

        // gets the WindowHandleMatcher - an object that observes the 
        // RelayServer from WindowInfo objects
        // and fires events matching the UniqueWindowHostId and the 
        // WindowHandle every time 
        // such objects arrive
        TheWindowHandleMatcher = new WindowHandleMatcher(TheRelayClient);
    }
    
    ...
}  

The real meat, however, is located within MainWindow.axaml file. It defines a simple UniDock groups with three DockItems - one for each plot. Two dock items at the top and one at the bottom.

To learn more about UniDock framework, please read UniDock - A New Multiplatform UI Docking Framework. UniDock Power Features.

The contents of each one of the DockItems are very similar, only are used to invoke different Python programs so I shall only explain one of them:

XAML
<np:DockItem Header="Dot Plot" DockId="DockPlot">
    <np:DockItemImplantedWindowHost x:Name="TheWindowHostContainer1"
                                    Margin="2"
                                    HorizontalAlignment="Stretch"
                                    VerticalAlignment="Stretch">
        <np:DockItemImplantedWindowHost.ProcessInitInfo>
            <np:MultiPlatformProcessInitInfoWithMatcher UniqueWindowHostId="DotPlot" 
             TheWindowHandleMatcher="{x:Static local:App.TheWindowHandleMatcher}">
                <np:MultiPlatformProcessInitInfoWithMatcher.WindowsProcInitInfo>
                    <np:ProcessInitInfo ExePath="pythonw" 
                     WorkingDir="../../../../Apps/DotPyMatPlot/" InsertIdx="1">
                        <np:ProcessInitInfo.Args>
                            <x:String>DotPyMatPlot.py</x:String>
                        </np:ProcessInitInfo.Args>
                    </np:ProcessInitInfo>
                </np:MultiPlatformProcessInitInfoWithMatcher.WindowsProcInitInfo>
                <np:MultiPlatformProcessInitInfoWithMatcher.LinuxProcInitInfo>
                    <np:ProcessInitInfo ExePath="python3" 
                     WorkingDir="../../../../Apps/DotPyMatPlot/" InsertIdx="1">
                        <np:ProcessInitInfo.Args>
                            <x:String>DotPyMatPlot.py</x:String>
                        </np:ProcessInitInfo.Args>
                    </np:ProcessInitInfo>
                </np:MultiPlatformProcessInitInfoWithMatcher.LinuxProcInitInfo>
            </np:MultiPlatformProcessInitInfoWithMatcher>
        </np:DockItemImplantedWindowHost.ProcessInitInfo>
    </np:DockItemImplantedWindowHost>
</np:DockItem>

Each DockItem object contains an object of type DockItemImplantedWindowHost which takes care of communications with the Relay Client via its ProcessInitInfo property set to contain MultiPlatformProcessInitInfoWithMatcher object. The latter object contains the UniqueWindowHostId (which should be unique for the application) and a TheWindowHandleMatcher - containing a reference to the WindowHandleMatcher object.

It also has several properties defining how to start the process on the corresponding OS. For example, WindowProcInitInfo specifies how to start the process on Window, while LinuxProcInitInfo - on Linux.

For example, WindowProcInitInfo set to:

XAML
<np:ProcessInitInfo ExePath="pythonw" 
 WorkingDir="../../../../Apps/DotPyMatPlot/" InsertIdx="1">
    <np:ProcessInitInfo.Args>
        <x:String>DotPyMatPlot.py</x:String>
    </np:ProcessInitInfo.Args>
</np:ProcessInitInfo>  

means that the command that starts the python process on Windows is pythonw (this is the command that can start python window without console), the folder in the Python process is started is ../../../../Apps/DotPyMatPlot/, the Unique Window Host Id is inserted at index 1 and the Python program started is DotPyMapPlot.py.

So the total line starting Python process is:

pythonw ../../../../Apps/DotPyMatPlot/DotPyMatPlot.py DotPlot 

where DotPlot is the Unique Window Host Id of the current DockItem.

Remember that the WindowHandleMatcher whose static instance is connected to our MultiPlatformProcessInitInfoWithMatcher object will fire an event when a WindowInfo object arrives from the RelayServer (published to it by the Python process).

The MultiPlatformProcessInitInfoWithMatcher object will watch for WindowInfo objects with matching UniqueWindowHostId property and when such object arrives, it will use its WindowHandle property to insert the Python window into the current DockItem object.

Python Code

All three Python projects are very similar, so, here I shall describe only one of them - DotPyMatPlog.py.

Here is the documented code of the program:

Python
imports ...
class ApplicationWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        # create QWidget, its layout and canvas for the figure
        self._main = QtWidgets.QWidget()
        self.setCentralWidget(self._main)
        layout = QtWidgets.QVBoxLayout(self._main)
        layout.setContentsMargins(0,0,0,0)
        canvas = FigureCanvas()
        layout.addWidget(canvas)

        # generate data for the plot
        np.random.seed(19680801) 
        data = {'a': np.arange(50),
                'c': np.random.randint(0, 50, 50),
                'd': np.random.randn(50)}
        data['b'] = data['a'] + 10 * np.random.randn(50)
        data['d'] = np.abs(data['d']) * 100
        #end generate data for the plot

        # create plot
        plt = canvas.figure.subplots()
        #paint the dots 
        plt.scatter('a', 'b', c='c', s='d', data=data)

        #set the names of the axes of the plot
        plt.set_xlabel('entry a')
        plt.set_ylabel('entry b')

def main(argv):
    sys.path.append(r'..\CommonPython\env\Lib\site-packages')

    import Messages_pb2 as messages

    # Check whether there is already a running QApplication (e.g., if running
    # from an IDE).
    qapp = QtWidgets.QApplication.instance()
    if not qapp:
        qapp = QtWidgets.QApplication(sys.argv)

    #create and show the window
    app = ApplicationWindow()
    app.show()
    app.activateWindow()
    
    # get the handle of the window
    winhandle = int(app.winId())
    print(winhandle);

    # argument means that the Python program is started from the C# code
    if len(argv) > 0:
        app.unique_window_host_id = argv[0]; #unique window host id

        #create Relay Client
        broadcastingClient = BroadcastingRelayClient("localhost", 5051)

        # connect the relay client to the server
        broadcastingClient.connect_if_needed()

        # create the WindowInfo object containing the UniqueWindowHostId 
        # and the WindowHandle
        winInfo = messages.WindowInfo(WindowHandle=winhandle, 
                  UniqueWindowHostId=app.unique_window_host_id)

        #publish the WindowInfo object to the Relay Server
        broadcastingClient.broadcast_object(winInfo, "WindowInfoTopic", 1)

    app.raise_()
    qapp.exec()

if __name__ == "__main__":
    main(sys.argv[1:]) 

Essentially, we generate data and then display it as a (scatter) plot within the QT window.

If there is a command line argument to the program, we assume that it is the UniqueWindowHostId, connect to the server running at "localhost:5051" and publish back to the server WindowInfo consisting of the UniqueWindowHostId and WindowHandle which we determine by calling winhandle = int(app.winId()).

Running the Sample on Linux (Ubuntu - fluxbox)

Unfortunately Linux - Gnome environment screws us the window implanting. This is something I am trying to resolve between Avalonia and myself. So, at this point, I can only run implanted application on fluxbox.

First, you need to install dotnet 6.0, python3, pip3 and python3-tk (for PySide6) on Linux. Here are the commands to install them on Ubuntu:

sudo apt-get install -y dotnet-sdk-6.0
sudo apt install python3
sudo apt install python3-pip
sudo apt-get install python3-tk  

and provide the password if required.

Then install the Linux packages by typing:

pip3 install numpy
pip3 install matplotlib
pip3 install grpcio
pip3 install NP.Grpc.PythonRelayInterfaces
pip3 install NP.Grpc.PythonMessages
pip3 install PySide6
pip3 install --upgrade protobuf  

Do not know why but protobuf required upgrade in my case.

To run the sample on Linux, you need to compile it on Windows and then copy the whole folder structure of the solution onto linux.

cd to <RootDir>DockableAppsDemo/DockableAppImplantsDemo/bin/Debug/net6.0 and run:

dotnet DockableAppImplantsDemo.dll  

Here is what you'll see:

Image 13

The whole application shows on Linux in a fashion very similar to windows since all the components used for building the application (C# and Python) are multiplatforms.

Unfortunately, UniDock currently has some issues with moving dockable windows on Linux, so redocking individual plots will not work. I plan to resolve these problems soon, so the window docking should work properly on any OS.

History

  • 27th February, 2023: Initial version

License

This article, along with any associated source code and files, is licensed under The MIT License