5

I was freezing a gettext localized (English and French, but probably more in the future) Python script with pyinstaller --onefile palc.py and it compiles perfectly, but when I try to run it it attempts to use the locales stored in the locales directory (meaning it can't find them if I don't distribute the package with the locales directory). As you can imagine, this is a major drawback and pretty much ruins the point of PyInstaller — in order to distribute it, I have to give a directory along with the package in order for it to work — though, as I'm going to show you, it doesn't work even with that.

Here is the main question:

Is it possible (preferably not too difficult or something that would require heavy rewriting) to make PyInstaller compile the Python script WITH the gettext locales?

EDIT: I tried editing my palc.spec, here is the new version:

# -*- mode: python ; coding: utf-8 -*-

block_cipher = None


a = Analysis(['palc.py'],
             pathex=['~/python-text-calculator'],
             binaries=[],
             datas=[('~/python-text-calculator/locales/*', 'locales')],
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          [],
          exclude_binaries=True,
          name='palc',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          console=True )
coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=True,
               upx_exclude=[],
               name='palc')

And here is the output of the compiled package:

>>> ./palc
--------------------------------------------------------------------------
                          Language Selection
--------------------------------------------------------------------------
1 - English // Anglais
2 - Francais // French
Type: 1
Traceback (most recent call last):
  File "/Users/computer/python-text-calculator/palc.py", line 30, in <module>
    l_translations = gettext.translation('base', localedir='locales', languages=["en"])
  File "/Library/Frameworks/Python.framework/Versions/3.5/lib/python3.5/gettext.py", line 514, in translation
    raise OSError(ENOENT, 'No translation file found for domain', domain)
FileNotFoundError: [Errno 2] No translation file found for domain: 'base'
[19393] Failed to execute script palc

This is the exact same output as it was without editing the palc.spec. Plus, it made the compiled package a directory (I ran ./palc inside the palc directory in dist), so I would still have to distribute a directory. What I need is a SINGLE FILE like the ones found here.

Can anyone help? Thanks! :D

TheTechRobo the Nerd
  • 1,249
  • 15
  • 28
  • I don't know if this really what you need but try editing the ```spec``` and run again pyinstaller with ```pyinstaller --onefile sciptname.spec```. Here's the [reference](https://pyinstaller.readthedocs.io/en/stable/spec-files.html) of adding bundles/files to the package. – Klien Menard May 13 '20 at 16:35
  • @KlienMenard I tried that but couldn't find out how to add a `.mo` file (aka gettext compiled translation). – TheTechRobo the Nerd May 13 '20 at 19:15
  • include the file inside ```datas = []``` in the ```spec``` file. Here's an example: ```a = Analysis(... datas=[ ('filename.mo', 'foldername where you want to put it') ], ... )``` – Klien Menard May 14 '20 at 05:06
  • @KlienMenard Unfortunately it didn't work, see my revised question for more details, but thanks for the help :) – TheTechRobo the Nerd May 14 '20 at 12:53
  • I think you should specify the directory in of your locales in the datas. – Klien Menard May 25 '20 at 10:23
  • Use this as an example: ```datas = [( 'C:/Users/klien/AppData/Local/Programs/Python/Python38-32/Lib/site-packages/octave_kernel/*', 'octave_kernel')], ``` – Klien Menard May 25 '20 at 10:24
  • after the directory, don't forget the ```*``` to include all the files in that folder – Klien Menard May 25 '20 at 10:26
  • @KlienMenard Check my updated answer. – TheTechRobo the Nerd May 25 '20 at 14:08
  • And @KlienMenard , There are directories inside the locales directory (`en` and `fr`, and inside of *those* there is `LC_MESSAGES`), does that change anything? – TheTechRobo the Nerd May 25 '20 at 14:13

2 Answers2

3

well the thing is:
your new .spec file is correct, you are stating what files you want in your bundle and where you want to put them inside the bundle.

it is the way you access them is what causing you the pain.
the line l_translations = gettext.translation('base', localedir='locales', languages=["en"]) from your error code suggest that you list the dir where the files are locales which actually make sense since you stated they should be there... BUT the way PyInstaller works is a little different. since you bundle it as onefile, it actually gets opened somewhere else by the bootloader.
how to fix it:
instead of specifying the dir locales change it to:

from os import path

bundle_dir = getattr(sys, '_MEIPASS', path.abspath(path.dirname(__file__))) # get the bundle dir if bundled or simply the __file__ dir if not bundled
locales_dir = path.abspath(path.join(bundle_dir, 'locales'))

now locales_dir points to the directory that you bundled with PyInstaller

P.S.
if that causes error (it shouldn't), edit your Analasis datas section to datas=[('~/python-text-calculator/locales', 'locales')],

Hagai Kalinhoff
  • 606
  • 3
  • 10
2

First, once the spec file has been generated, provide your spec file to pysintaller instead of a Python file: run pyinstaller palc.spec instead of pyinstaller palc.py. Otherwise, pyinstaller will reset the spec file each time.

Then, in order to generate a correct spec file for a onefile application, use pyi-makespec --onefile palc.py. It generates a spec file with no COLLECT step, and a different EXE step.

Then you can use a custom python function in your spec file to build datas for your locales (remember that a spec file is just a Python file with a custom file extension):

def get_locales_data():
    locales_data = []
    for locale in os.listdir(os.path.join('./locales')):
        locales_data.append((
            os.path.join('./locales', locale, 'LC_MESSAGES/*.mo'),
            os.path.join('locales', locale, 'LC_MESSAGES')
        ))
    return locales_data

Then use the return value of this function as the value of the datas parameter in the Analysis step:

a = Analysis(['palc.py'],
             ...
             datas=get_locales_data(),
             ...)

Then you will have to adapt your code to look for the locales files at the correct place (according to the runtime envrionment: packaged or not), but I have no more time to develop this part of the answer so here is a thread discussing this. ;)


For convenience, below is an example of correct specfile generated with pyi-makespec and altered to include locales:

# -*- mode: python ; coding: utf-8 -*-
import os

block_cipher = None


def get_locales_data():
    locales_data = []
    for locale in os.listdir(os.path.join('./locales')):
        locales_data.append((
            os.path.join('./locales', locale, 'LC_MESSAGES/*.mo'),
            os.path.join('locales', locale, 'LC_MESSAGES')
        ))
    return locales_data


a = Analysis(['palc.py'],
             pathex=['.'],
             binaries=[],
             datas=get_locales_data(),
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)
pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)
exe = EXE(pyz,
          a.scripts,
          a.binaries,
          a.zipfiles,
          a.datas,
          [],
          name='palc',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=True,
          upx_exclude=[],
          runtime_tmpdir=None,
          console=True )

Tryph
  • 5,946
  • 28
  • 49
  • Well, it worked - thanks for that! But, instead of a single file like i specified with `--onefile`,... it came out a directory. https://ibb.co/RQWpG2d Any way to convert from the directory to a single file, or is there a --onefile that'll actually work? – TheTechRobo the Nerd Jan 05 '21 at 14:19
  • your screenshot shows logs about a COLLECT step in your packaging process. The "onefile" bundle should NOT involve a COLLECT step. You probably missed a step in generating the specfile with `pyi-makespec` or running `pyinstaller` providing it the spec file as described in my answer. I added the spec file I obtained and tested. – Tryph Jan 05 '21 at 14:28
  • Thank you very much - it works. :) Not sure what I did wrong; I used your specfile (copy pasted into my spec file) but after generating one myself and making your modifications, it worked. – TheTechRobo the Nerd Jan 05 '21 at 14:33