13 February 2023
Nuitka Package Configuration Part 2
This is the second part of a post series under the tag package_config that explains the Nuitka Package Configuration in more detail. To recap, Nuitka package configuration is the way Nuitka learns about hidden dependencies, needed DLLs, data files, and just generally avoids bloat in the compilation. The details are here on a dedicate page on the web site in Nuitka Package Configuration but reading on will be just fine.
Problem Package
Each post will feature one package that caused a particular problem. In
this case, we are talking about the package customtkinter
.
Problems are typically encountered in standalone mode only. Missing data
files are typical issue there, and in this case, we already had a
solution, but turns out, only working on Windows for a weird reason. We
are going to look at that in some detail, and will see a workaround
applied with the anti-bloat
engine doing code modification on the
fly.
Initial Symptom
The initial symptom reported was like this, on the Nuitka Discord server and much to my surprise, against the then current 1.4.4 release of Nuitka.
Traceback (most recent call last):
File "/tmp/onefile_32802_1675869955_205649/customtkinter/windows/widgets/theme/__init__.py", line 5, in <module customtkinter.windows.widgets.theme>
File "/tmp/onefile_32802_1675869955_205649/customtkinter/windows/widgets/theme/theme_manager.py", line 18, in load_theme
FileNotFoundError: [Errno 2] No such file or directory: '/tmp/onefile_32802_1675869955_205649/customtkinter/windows/widgets/theme/../../../assets/themes/blue.json'
One thing to say here is that if you have this kind of issues, using onefile is definitely too early. The recommendation is to use onefile only after standalone works, because it is easier to debug.
Step 1 - Analysis
So first thing, I did was to normalize the path, so I can see if something funny is going on, with that.
>>> import os
>>> os.path.normpath("/tmp/onefile_32802_1675869955_205649/customtkinter/windows/widgets/theme/../../../assets/themes/blue.json")
'/tmp/onefile_32802_1675869955_205649/customtkinter/assets/themes/blue.json'
So this matches existing data files. Originally we had worked off this on Windows,
python3.10 -m nuitka --list-package-data=customtkinter
Nuitka-Tools:INFO: Checking package directory 'C:\Python310_64\lib\site-packages\customtkinter' ..
C:\Python310_64\lib\site-packages\customtkinter
C:\Python310_64\lib\site-packages\customtkinter\assets\fonts\CustomTkinter_shapes_font.otf
C:\Python310_64\lib\site-packages\customtkinter\assets\fonts\Roboto\Roboto-Medium.ttf
C:\Python310_64\lib\site-packages\customtkinter\assets\fonts\Roboto\Roboto-Regular.ttf
C:\Python310_64\lib\site-packages\customtkinter\assets\icons\CustomTkinter_icon_Windows.ico
C:\Python310_64\lib\site-packages\customtkinter\assets\themes\blue.json
C:\Python310_64\lib\site-packages\customtkinter\assets\themes\dark-blue.json
C:\Python310_64\lib\site-packages\customtkinter\assets\themes\green.json
Which confirms the data file, and had lead to adding configuration to include these data files.
- module-name: 'customtkinter'
data-files:
dirs:
- 'assets'
This is the most simple for including data files. Because there are no needs to limit by file type, and not even a wish to know what file types might get added in the future. We add the folder and are good.
Since the original report was on Windows, it was tested there and assumed to be good on all platforms.
Step 2 - Debugging
So, the data files are indeed included, which was confirmed by asking to compile in standalone and to provide the listing. However, at run time they were not found.
This is where the platform differences come in, somewhat to my surprise,
on Windows the
customtkinter/windows/widgets/theme/../../../assets/themes/blue.json
part of the path works, even though there is no windows
directory.
Since compiled code is contained in the executable, packages in
standalone may not have a directory associated.
So while __file__
is set to that virtual directory, it does not
exist. And usually that is all good, and directories get created of
course when package data gets added. That is e.g. why customtkinter
and then of course customtkinter/assets
exist. But
customtkinter/windows
does not.
The platform difference, that I was not aware of that only on Windows, will a path being opened get normalized before being used.
>>> os.name
'nt'
>>> os.path.exists("README.rst")
True
>>> os.path.exists("doesnotexist")
False
>>> os.path.exists("doesnotexist\\..\\README.rst")
But neither on Linux nor macOS this is going to be possible. So that was the problem. Checkout their code, this is what is used:
with open(os.path.join(script_directory, "../../../assets", "themes", f"{theme_name_or_path}.json"), "r") as f:
cls.theme = json.load(f)
This could have used pkg_resources
or importlib.resources
or
many of the methods that Nuitka supports out of the box. But actually
open
and being __file__
relative is supposed to be fine.
So this does not work.
Step 2 - Devising a solution
So, there are actually a couple of ways to resolve this. One would be to just provide the empty directories that non-Windows is checking to exist. These would then have to be deployed. There is actually support for that in Nuitka Package Configuration.
And we have examples of that in our existing configuration, e.g. for
Crypto.Util._raw_api
.
- module-name: 'customtkinter.windows.widgets.theme.theme_manager'
data-files:
empty_dirs:
- '.'
This is for code that insists on the package directory, but honestly, while it is easy enough, I don’t quite like this solution. The empty directory requires a dummy file, and ultimately it that is all that is used for is rather ugly.
So, what else we can do? Well, we can modify the code of course too. We
have the anti-bloat
engine and it is capable of achieving reduction
of bloat. What if the we used it to modify the code to do a
os.path.normpath
and that is actually what we ended up doing. The
most simple form of anti-bloat does plain string replacements. Here it
is used.
- module-name: 'customtkinter.windows.widgets.theme.theme_manager'
anti-bloat:
- description: 'workaround for file path on Linux'
replacements_plain:
'script_directory, "../../../assets"': 'os.path.normpath(os.path.join(script_directory, "../../../assets"))'
# Not necessary on Windows
when: 'not win32'
The effect can be easily seen with the --show-source-changes option
which outputs for modules the changes applied. Notice that we do not do
it on Windows, because there it is not needed. The when
clause
allows us to specify such conditions.
customtkinter.windows.widgets.theme.theme_manager:
--- original
+++ modified
@@ -15,7 +15,7 @@
script_directory = os.path.dirname(os.path.abspath(__file__))
if theme_name_or_path in cls._built_in_themes:
- with open(os.path.join(script_directory, "../../../assets", "themes", f"{theme_name_or_path}.json"), "r") as f:
+ with open(os.path.join(os.path.normpath(os.path.join(script_directory, "../../../assets")), "themes", f"{theme_name_or_path}.json"), "r") as f:
The normpath
is limited to where it is needed, to keep the replaced
expression minimal. And in this way, the problem is solved for in a
pretty non-invasive way.
Source modification is of course always something that can break if the code changes, and should normally be avoided. But it appears this should be a very robust one.
Final remarks
I am hoping you will find this very helpful information and will join
the effort to make packaging for Python work out of the box. Adding
support for customtkinter
seems trivial at first, and for Windows in
fact is was.
Lessons learned. Data files working need to be confirmed on either Linux or macOS too, esp. in case of these relative paths.
We will have to have a repository of test cases for this kind of small programs, where you can help by checking for new version to still produce the same compilation report. Then it would be fairly easy to get the coverage on all platforms and in case of updated packages on PyPI.