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:
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.:
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:
- Visual Studio 2022 with Python capability. I use professional edition (not sure if community addition would work, but most likely - yes).
- 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#).
- 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:
CommonPython
DotPyMatPlot
HistogramPlot
SinusoidPyMatPlot
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):
In one of the projects, e.g., DotPyMatPlot
, left click on the main environment and choose Install from requirements.txt option:
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:
The Python application containing the corresponding plot should popup:
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:
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.
Also, you'll have to slightly modify the code of each project - specifically lines:
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:
In the opened dialog, click View in Python environments window and then click Create button:
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:
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:
NP.Avalonia.Gidon
- the main dependency NP.Grpc.RelayClient
- implementation of the RelayServer
client NP.Grpc.RelayServer
- implementation of the RelayServer
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:
<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:
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:
public class App : Application
{
private static IDependencyInjectionContainer<Enum> IoCContainer { get; }
private static IRelayServer TheRelayServer { get; }
private static IRelayClient TheRelayClient { get; }
public static WindowHandleMatcher TheWindowHandleMatcher { get; }
static App()
{
IContainerBuilderWithMultiCells<Enum> containerBuilder =
new ContainerBuilder<Enum>();
containerBuilder.RegisterMultiCell(typeof(Enum), IoCKeys.Topics);
containerBuilder.RegisterType<IGrpcConfig, GrpcConfig>();
containerBuilder.RegisterAttributedStaticFactoryMethodsFromClass
(typeof(MessagesTopicsGetter));
containerBuilder.RegisterPluginsFromSubFolders("Plugins/Services");
IoCContainer = containerBuilder.Build();
TheRelayServer = IoCContainer.Resolve<IRelayServer>();
TheRelayClient = IoCContainer.Resolve<IRelayClient>();
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:
<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:
<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:
imports ...
class ApplicationWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self._main = QtWidgets.QWidget()
self.setCentralWidget(self._main)
layout = QtWidgets.QVBoxLayout(self._main)
layout.setContentsMargins(0,0,0,0)
canvas = FigureCanvas()
layout.addWidget(canvas)
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
plt = canvas.figure.subplots()
plt.scatter('a', 'b', c='c', s='d', data=data)
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
qapp = QtWidgets.QApplication.instance()
if not qapp:
qapp = QtWidgets.QApplication(sys.argv)
app = ApplicationWindow()
app.show()
app.activateWindow()
winhandle = int(app.winId())
print(winhandle);
if len(argv) > 0:
app.unique_window_host_id = argv[0];
broadcastingClient = BroadcastingRelayClient("localhost", 5051)
broadcastingClient.connect_if_needed()
winInfo = messages.WindowInfo(WindowHandle=winhandle,
UniqueWindowHostId=app.unique_window_host_id)
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:
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