DEV Community

Raül Martínez i Peris
Raül Martínez i Peris

Posted on

Python. Project Structure (VIII)

Índice:


Para crear la aplicación .exe para Windows, vamos a necesitar lanzar PyInstaller a través de GitHub Actions.

¿Porqué GitHub? GitHub nos permite lanzar gratuitamente hasta 2.000 minutos de cómputo, incluyendo también una FreeTier de Windows.

¿Porqué no se hizo todo con GitHub? La mejor forma de aprender cómo integrar diferentes plataforma es haciéndolo, por lo que aprovechamos que ambas plataformas tienen planes gratuitos para mostrar como integrarlas.

Preparar el repositorio de GitHub

Vamos a GitHub y creamos un nuevo repositorio para lanzar el pipeline.

Una vez creado el repositorio, en el icono de usuario (arriba a la derecha) pulsa Settings en el desplegable.

Nos aparecerá a la izquierda, al final de todos los settings (abajo), el apartado Developer setting, tras abrir Personal access tokens pulsamos sobre Tokens (clasic) y nos creamos un nuevo token haciendo click en:

  • Generate new token > Generate new token (classic), y rellenamos:
    • Note: ponemos un alias identificativo.
    • Expiration: seleccionamos cuanto tiempo queremos que dure.
    • Select scopes, seleccionamos los siguientes:
    • repo (todos)
    • workflow
  • Pulsamos en Generate token.

Atención: Nos mostrará únicamente una vez el token, nunca volverás a verlo, guárdalo en tu aplicación de almacenar contraseñas.

Gitlab

Ahora en Gitlab necesitaremos introducir los datos de nuestro repo de GitHub, pero primero nos preparamos los archivos. Para ello creamos el archivo para GitHub, añadiendo a nuestro repo los siguientes archivos:

  • Actualizamos requirements-extra-windows.txt añadiendo:
PyInstaller
Enter fullscreen mode Exit fullscreen mode
  • Creamos el 'github-build' que necesitaremos para GitHub:
# ci/github-build.yml
name: Build EXE

on:
  push:
    branches:
      - __BUILD_BRANCH__

permissions:
  contents: write

jobs:
  build:
    runs-on: windows-latest
    steps:
      # 1. Clonar el repositorio
      - name: Checkout project
        uses: actions/checkout@v4

      # 2. **Verificación entorno**
      - name: Check for .env.production file
        id: check_env
        shell: pwsh
        run: |
          if (-not (Test-Path ".env.production")) {
            Write-Error "Error: No existe el archivo '.env.production'. Recuerde que la aplicación se genera con el entorno de producción."
            exit 1
          }

      # 3. Instalar Python 3.12
      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      # 4. Crear y activar entorno virtual
      - name: Create virtual environment
        shell: pwsh
        run: |
          python -m venv venv
          .\venv\Scripts\Activate.ps1
          python -m pip install --upgrade pip wheel setuptools

      # 5. Instalar dependencias dentro del venv
      - name: Install dependencies
        shell: pwsh
        run: |
          .\venv\Scripts\Activate.ps1
          # Instalación de requisitos explícitos
          pip install PyQt6 PyQt6-WebEngine
          pip install nicegui
          pip install pydantic-settings
          pip install pydantic
          pip install pywebview
          # Resto de requisitos
          pip install -r requirements.txt -r requirements-extra-windows.txt
          # PyInstaller al final
          pip install pyinstaller
          pip install --upgrade pyinstaller pyinstaller-hooks-contrib

      # 6. Verificar paquetes instalados
      - name: Verify key packages
        shell: pwsh
        run: |
          .\venv\Scripts\Activate.ps1
          python -c "import PyQt6; print('PyQt6 OK')"
          python -c "import PyQt6.QtWebEngineWidgets; print('WebEngineWidgets OK')"
          python -c "import nicegui; print('NiceGUI OK')"

      # 7. Comprobar DLLs de Qt6
      - name: Verify Qt6 DLLs
        shell: pwsh
        run: |
          .\venv\Scripts\Activate.ps1
          $pyqt6_path = (python -c "import PyQt6; print(PyQt6.__path__[0])")
          $qt6_base = Join-Path $pyqt6_path "Qt6"
          if (Test-Path "$qt6_base\bin") { echo "Qt6 bin exists"; dir "$qt6_base\bin" } else { echo "Qt6 bin not found" }
          if (Test-Path "$qt6_base\plugins") { echo "Qt6 plugins exist"; dir "$qt6_base\plugins" } else { echo "Qt6 plugins not found" }

      # 8. Instalar WebView2 runtime (para pywebview)
      - name: Install WebView2 runtime
        shell: pwsh
        run: |
          Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/p/?LinkId=2124703" -OutFile "MicrosoftEdgeWebView2Setup.exe"
          write-host "Lanzando MicrosoftEdgeWebView2Setup.exe en modo desatentido (silent)..."
          Start-Process .\MicrosoftEdgeWebView2Setup.exe -ArgumentList "/silent","/install" -Wait

      # 9. Compilar con PyInstaller
      - name: Compile app
        shell: pwsh
        run: |
          .\venv\Scripts\Activate.ps1
          pyinstaller app/main.spec --noconfirm
          echo "Build finished. Checking dist/"
          dir dist

      # 10. Copiar DLLs y plugins de Qt6 al dist
      - name: Copy Qt6 runtime dependencies
        shell: pwsh
        run: |
          .\venv\Scripts\Activate.ps1
          $pyqt6_path = (python -c "import PyQt6; print(PyQt6.__path__[0])")
          $QT6_PATH = Join-Path $pyqt6_path "Qt6"
          $DEST = "dist\la_fragua"
          Write-Host "Copying Qt6 from $QT6_PATH to $DEST"
          Copy-Item "$QT6_PATH\bin\*.dll" -Destination "$DEST\PyQt6\Qt6\bin" -Recurse -Force -ErrorAction SilentlyContinue
          Copy-Item "$QT6_PATH\plugins" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force -ErrorAction SilentlyContinue
          Copy-Item "$QT6_PATH\resources" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force -ErrorAction SilentlyContinue
          if (Test-Path "$QT6_PATH\translations") {
            Copy-Item "$QT6_PATH\translations" -Destination "$DEST\PyQt6\Qt6" -Recurse -Force
          }

      # 11. Publicar artefacto en release
      - name: Release build artifact
        uses: softprops/action-gh-release@v1
        with:
          tag_name: "__BUILD_BRANCH__-${{ github.run_number }}"
          generate_release_notes: true
          files: dist/*.exe
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Enter fullscreen mode Exit fullscreen mode
  • Ahora necesitamos crear el archivo spec para la configuración de creación del .exe:
# app/main.spec
# main.spec para compilación en windows
# -*- mode: python ; coding: utf-8 -*-

import os
import sys
import PyQt6
import PyQt6.QtWebEngineWidgets
import nicegui
from PyInstaller.utils.hooks import collect_all, collect_data_files, collect_submodules

# ---- Carpeta base del proyecto ----
project_dir = os.getcwd()
app_dir = os.path.join(project_dir, "app")
hookspath = [os.path.join(project_dir, 'extra-hooks')]

# ---- Recolectar archivos del proyecto ----
def collect_all_data(src_folder):
    datas = []
    for root, dirs, files in os.walk(src_folder):
        for file in files:
            if file.endswith('.pyc') or file.endswith('.env.production'):
                continue
            full_path = os.path.join(root, file)
            rel_path = os.path.relpath(root, src_folder)
            target_path = os.path.join(rel_path)
            datas.append((full_path, target_path))
    return datas

datas = collect_all_data(app_dir)

# ---- Qt6 DLLs y plugins ----
site_packages = sys.base_prefix + r"\Lib\site-packages"
qt6_base = os.path.join(site_packages, "PyQt6", "Qt6")

# DLLs (bin)
qt_bin = os.path.join(qt6_base, "bin")
if os.path.exists(qt_bin):
    datas += [(os.path.join(qt_bin, f), "PyQt6/Qt6/bin") for f in os.listdir(qt_bin) if f.endswith(".dll")]

# Plugins (platforms, imageformats, etc.)
qt_plugins = os.path.join(qt6_base, "plugins")
for root, dirs, files in os.walk(qt_plugins):
    for file in files:
        full_path = os.path.join(root, file)
        rel_path = os.path.relpath(root, qt6_base)
        datas.append((full_path, f"PyQt6/Qt6/{rel_path}"))

# ---- NiceGUI recursos estáticos ----
datas += collect_data_files("nicegui", subdir="static")
datas += collect_data_files("nicegui", subdir="templates")

# Aquí cambiamos a .env.production
datas += [(os.path.join(project_dir, '.env.production'), '.')]

# ---- Icono opcional ----
icon_path = os.path.join(app_dir, 'assets', 'icon.ico')
if not os.path.exists(icon_path):
    icon_path = None

# ---- Recoger todos los submódulos para pydantic y pydantic_settings ----
hidden_imports = collect_submodules('pydantic') + collect_submodules('pydantic_settings') + ["PyQt6.QtWebEngineWidgets"]

# ---- Análisis ----
a = Analysis(
    [os.path.join(app_dir, "main.py")],
    pathex=[project_dir],
    binaries=[],
    datas=datas,
    hiddenimports=[
        "PyQt6.QtWebEngineWidgets",
        "PyQt6.QtWebEngineCore",
        "PyQt6.QtWebChannel",
        "PyQt6.QtPositioning",
    ] + hidden_imports,
    hookspath=hookspath,
    runtime_hooks=[],
    excludes=[],
    win_no_prefer_redirects=False,
    win_private_assemblies=False,
    cipher=None,
    noarchive=False,
)

pyz = PYZ(a.pure, a.zipped_data, cipher=None)

exe = EXE(
    pyz,
    a.scripts,
    a.binaries,
    a.zipfiles,
    a.datas,
    name="la_fragua.exe",
    debug=False,
    strip=False,
    upx=True,
    console=True,
    icon=icon_path,
)

coll = COLLECT(
    exe,
    a.binaries,
    a.zipfiles,
    a.datas,
    strip=False,
    upx=True,
    name="la_fragua",
)
Enter fullscreen mode Exit fullscreen mode
  • También necesitamos el archivo 'hook-pydantic':
# extra-hooks/hook-pydantic.py
from PyInstaller.utils.hooks import collect_submodules

hiddenimports = collect_submodules('pydantic') + collect_submodules('pydantic_settings')
Enter fullscreen mode Exit fullscreen mode
  • Y finalmente, añadimos a 'gitlab-ci' el stage necesario (línea 3-4):
  - build
Enter fullscreen mode Exit fullscreen mode

a continuación del stage 'test' le agregamos una nueva tarea:

launch-build-exe:
  stage: build
  image: alpine:latest
  before_script:
    - apk add --no-cache git zip unzip curl bash
    - curl --silent "https://gitlab.com/gitlab-org/incubation-engineering/mobile-devops/download-secure-files/-/raw/main/installer" | bash
    # descarga de los archivos protegidos
    - download-secure-files
    - cp .secure_files/.env .
    - cp .secure_files/.env.production .
  script:
    # empaquetar el código excluyendo .git
    - zip -r project.zip . -x ".git/*" -x "tests/*" -x "KANBAN.md" -x "README.md" -x ".gitlab-ci.yml" -x ".gitignore"

    # configurar git
    - git config --global user.email "$GITLAB_CI_EMAIL"
    - git config --global user.name "$GITLAB_CI_USER"

    # clonar el repo de GitHub
    - git clone https://oauth2:${GITHUB_TOKEN}@github.com/${GITHUB_USER}/${GITHUB_REPO}.git
    - cd ${GITHUB_REPO}
    - git checkout -B $BUILD_BRANCH
    - rm -rf *

    # mover proyecto
    - mv ../project.zip .
    - unzip project.zip
    - rm project.zip

    # añadir workflow con datos dinámicos
    - mkdir -p .github/workflows
    - sed -e "s|__BUILD_BRANCH__|$BUILD_BRANCH|g" -e "s|__CI_PROJECT_NAME__|$CI_PROJECT_NAME|g" ./ci/github-build.yml > .github/workflows/build.yml
    - mv ./ci/README.github.md README.md
    - rm -r ./ci

    # commit & push
    - git add .
    - git commit -m "Trigger build from GitLab CI ${BUILD_BRANCH}"
    - git push origin $BUILD_BRANCH --force
  rules:
    - if: '$CI_COMMIT_BRANCH == "main" && $CI_COMMIT_MESSAGE =~ /^\[build-exe\]/'
      when: always
    - when: manual
  allow_failure: true
Enter fullscreen mode Exit fullscreen mode

Configuración del entorno de Gitlab

Una vez actualizados los ficheros preparamos el entorno de Gitlab. Para ello, desde Gitlab:

  • Settings > CI/CD Settings > CI/CD Variables, añadimos la variables:
    • BUILD_BRANCH (contenido: un nombre descriptivo para tu rama en GitHub)
    • GITHUB_REPO (contenido: el nombre de tu repositorio en GitHub)
    • GITHUB_USER (contenido: tu usuario de GitHub)
    • GITLAB_CI_EMAIL (contenido: tu correo electronico)
    • GITLAB_CI_USER (contenido: un alias descriptivo)
    • GITHUB_TOKEN (contenido: tu token de GitHub)
  • Actualizamos/añadimos los archivos protegidos:
    • Settings > CI/CD Settings > Secure files:
    • .env
    • .env.production

Una vez que has configurado totalmente las variables y archivos protegidos, ya puedes subir la rama a Gitlab:

git add .
git commit -m 'Virtualización'
git push -u origin 'release/000-virtualization'
Enter fullscreen mode Exit fullscreen mode

Volvemos a Gitlab realizamos el Merge Request y lo fusionamos.

Una vez terminado solo nos queda actualizar nuestra rama local de 'main':

git checkout main
git pull
Enter fullscreen mode Exit fullscreen mode

Comprobación

Si todo ha funcionado correctamente, en tu cuenta de GitHub se habrá lanzado una Action sobre tu repositorio, que te habrá generado un artefacto y un tag a dicho artefacto.

Para ver el artefacto debes pinchar sobre "Tag" y una vez dentro verás que también tienes "Releases", dentro de las cuales están los artefactos generados.

Apuntes

  • En BUILD_BRANCH puedes poner, por ejemplo: 'el_repositorio-build_artifact'.
  • En GITLAB_CI_EMAIL, puedes poner tu email o puedes utilizar un email que te indica GitHub. Lo tienes en los Settings de usuario, en el apartado Emails > Keep my email addresses private, donde tras activarlo leerás en la explicación "We'll remove your public profile email and use [...@users.noreply.github.com]".

Enlaces

Repositorio del proyecto:

Top comments (0)