Search code examples
pythonpython-3.xlocalizationpyinstallergettext

Compiling gettext locales with PyInstaller in Python 3.x


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


Solution

  • 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 )