diff --git a/.github/workflows/build-portable.yml b/.github/workflows/build-portable.yml new file mode 100644 index 0000000..cb39bc1 --- /dev/null +++ b/.github/workflows/build-portable.yml @@ -0,0 +1,56 @@ +name: Build portable (Windows) + +# Costruisce la distribuzione portabile ZIP di Songpress++ con cx_Freeze, +# riusando lo script sanzionato installer/Build-Portable.ps1. +# +# Trigger: +# - manuale (workflow_dispatch) +# - push di un tag di versione v* -> allega lo ZIP alla Release + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write # necessario per creare/aggiornare la Release sui tag + +jobs: + build-portable: + runs-on: windows-latest + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: '3.13' + + - name: Build portable ZIP + shell: pwsh + run: .\installer\Build-Portable.ps1 + + - name: Locate artifact + id: artifact + shell: pwsh + run: | + $zip = Get-ChildItem -Path dist -Filter '*-portable.zip' | Select-Object -First 1 + if (-not $zip) { throw "Nessuno ZIP portabile trovato in dist\" } + "path=$($zip.FullName)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + "name=$($zip.Name)" | Out-File -FilePath $env:GITHUB_OUTPUT -Append + + - name: Upload build artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.artifact.outputs.name }} + path: ${{ steps.artifact.outputs.path }} + if-no-files-found: error + + - name: Attach to release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v3 + with: + files: dist/*-portable.zip diff --git a/.gitignore b/.gitignore index 02ecff2..dd7d70b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,8 +8,8 @@ installer/~$ spiego passo.docx /.idea/ /.vscode/ -# Egg info (setuptools) -/*.egg-info/ +# Egg info (setuptools / cx_Freeze) — a qualsiasi profondità (es. src/) +*.egg-info/ # Build e distribuzione # Esclude le sottocartelle di build, non i file diff --git a/installer/Build-Portable.ps1 b/installer/Build-Portable.ps1 index f21c8cd..26c64e6 100644 --- a/installer/Build-Portable.ps1 +++ b/installer/Build-Portable.ps1 @@ -1,4 +1,4 @@ -############################################################### +############################################################### # Build-Portable.ps1 # Crea una distribuzione portabile ZIP di Songpress++ # usando cx_Freeze in un venv dedicato. @@ -55,17 +55,25 @@ $Python = Join-Path $VenvDir 'Scripts\python.exe' Write-Host "[2/6] Installazione dipendenze (può richiedere alcuni minuti)..." -ForegroundColor Yellow +# Le specifiche di versione vanno tenute come elementi di un array e passate +# con lo splatting (@Deps): la continuazione con backtick di stringhe +# contenenti '<' e ',' non viene analizzata correttamente da Windows +# PowerShell 5.1 ("The '<' operator is reserved for future use"). +$Deps = @( + 'cx_Freeze' + 'wxPython>=4.2.4,<5.0.0' + 'requests>=2.32.4,<3.0.0' + 'python-pptx>=1.0.2,<2.0.0' + 'pyshortcuts>=1.9.5,<2.0.0' + 'reportlab>=4.0.0,<5.0.0' + 'pypdf>=6.0.0,<7.0.0' + 'markdown>=3.4,<4.0.0' + 'mistune>=3.0.0,<4.0.0' + 'pywin32>=308' +) + & $Pip install --upgrade pip --quiet -& $Pip install --quiet ` - cx_Freeze ` - "wxPython>=4.2.4,<5.0.0" ` - "requests>=2.32.4,<3.0.0" ` - "python-pptx>=1.0.2,<2.0.0" ` - "pyshortcuts>=1.9.5,<2.0.0" ` - "reportlab>=4.0.0,<5.0.0" ` - "pypdf>=6.0.0,<7.0.0" ` - "markdown>=3.4,<4.0.0" ` - "mistune>=3.0.0,<4.0.0" +& $Pip install --quiet @Deps if ($LASTEXITCODE -ne 0) { throw "pip install fallito (codice $LASTEXITCODE)" } Write-Host " Dipendenze installate." -ForegroundColor Green @@ -74,12 +82,20 @@ Write-Host " Dipendenze installate." -ForegroundColor Green Write-Host "[3/6] Esecuzione cx_Freeze build_exe..." -ForegroundColor Yellow +# cx_Freeze deve poter importare il package 'songpressPlusPlus' (elencato in +# [tool.cxfreeze.build_exe] packages): va reso individuabile aggiungendo src/ +# a PYTHONPATH, altrimenti il finder fallisce con "No module named ...". +$SrcDir = Join-Path $ProjectRoot 'src' +$OldPyPath = $env:PYTHONPATH +$env:PYTHONPATH = if ($OldPyPath) { "$SrcDir;$OldPyPath" } else { $SrcDir } + Push-Location $ProjectRoot try { & $Python -m cx_Freeze build_exe if ($LASTEXITCODE -ne 0) { throw "cx_Freeze fallito (codice $LASTEXITCODE)" } } finally { Pop-Location + $env:PYTHONPATH = $OldPyPath } Write-Host " Build completata." -ForegroundColor Green @@ -94,7 +110,7 @@ Write-Host " Cartella build: $($BuildOutput.FullName)" Write-Host "[4/6] Verifica cartella templates\fonts..." -ForegroundColor Yellow -$SrcFonts = Join-Path $ProjectRoot 'src\songpress\templates\fonts' +$SrcFonts = Join-Path $ProjectRoot 'src\songpressPlusPlus\templates\fonts' $DestFonts = Join-Path $BuildOutput.FullName 'templates\fonts' if (Test-Path $SrcFonts) { diff --git a/pyproject.toml b/pyproject.toml index 7754328..c02e0aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,13 +63,19 @@ artifacts = [ # python -m cx_Freeze build_exe [[tool.cxfreeze.executables]] -script = "src/songpressplusplus/main.py" +# Non congelare main.py direttamente: usa import relativi di pacchetto e +# come script __main__ fallirebbe ("attempted relative import with no known +# parent package"). Il launcher importa il package e ne chiama main(). +script = "src/spp_frozen_main.py" target_name = "SongpressPlusPlus.exe" base = "gui" icon = "installer/songpressplusplus.ico" [tool.cxfreeze.build_exe] packages = [ + # Case identico alla cartella reale src/songpressPlusPlus: gli import + # Python sono case-sensitive anche su Windows. + "songpressPlusPlus", "multiprocessing", "idna.idnadata", "ssl", @@ -92,4 +98,10 @@ include_files = [ ["src/songpressplusplus/templates", "templates"], ["src/songpressplusplus/xrc", "xrc"], ["pyproject.toml", "pyproject.toml"], + # File markdown della guida rapida (Help): caricati a runtime da + # SongpressFrame via glb.path, quindi devono stare accanto all'exe. + ["src/songpressplusplus/guida.md", "guida.md"], + ["src/songpressplusplus/guida_en.md", "guida_en.md"], + ["src/songpressplusplus/guida_comandi_songpress.md", "guida_comandi_songpress.md"], + ["src/songpressplusplus/guida_comandi_songpress_en.md", "guida_comandi_songpress_en.md"], ] diff --git a/src/songpressPlusPlus/Editor.py b/src/songpressPlusPlus/Editor.py index c3e70c3..827979c 100644 --- a/src/songpressPlusPlus/Editor.py +++ b/src/songpressPlusPlus/Editor.py @@ -572,7 +572,10 @@ def OnContextMenu(self, evt): menu = wx.Menu() import os - _img_dir = os.path.join(os.path.dirname(__file__), 'img') + from .Globals import glb + # Usa glb (frozen-aware) invece di __file__: nella build congelata + # img/ è nella cartella dell'exe, non dentro lib/songpressPlusPlus/. + _img_dir = glb.AddPath('img') img = lambda name: os.path.join(_img_dir, name) # Recupera le preferenze di visibilità (default True se non presenti) diff --git a/src/songpressPlusPlus/Globals.py b/src/songpressPlusPlus/Globals.py index 347fc66..f4e9a5e 100644 --- a/src/songpressPlusPlus/Globals.py +++ b/src/songpressPlusPlus/Globals.py @@ -71,8 +71,16 @@ class Globals(object): def __init__(self): object.__init__(self) - current_file = os.path.abspath(__file__) - self.path = os.path.dirname(current_file) + if getattr(sys, 'frozen', False): + # Build congelata (cx_Freeze): le risorse (xrc/, img/, templates/, + # locale/) vengono copiate da include_files nella cartella + # dell'eseguibile, non dentro lib/songpressPlusPlus/. Usare la dir + # dell'exe come radice risorse; __file__ punterebbe invece dentro + # lib/ dove xrc/ ecc. non esistono → wxDir::Open fallisce. + self.path = os.path.dirname(os.path.abspath(sys.executable)) + else: + current_file = os.path.abspath(__file__) + self.path = os.path.dirname(current_file) self.data_path = None def InitDataPath(self): diff --git a/src/songpressPlusPlus/PreferencesDialog.py b/src/songpressPlusPlus/PreferencesDialog.py index 9e41cb3..300a448 100644 --- a/src/songpressPlusPlus/PreferencesDialog.py +++ b/src/songpressPlusPlus/PreferencesDialog.py @@ -133,7 +133,9 @@ def __init__(self, parent, pref=None): self.themeDeleteBtn = wx.Button(self.general, wx.ID_ANY, _(u"Delete"), wx.DefaultPosition, wx.Size(75, -1)) # Icone pulsanti tema import os as _os - _icons_dir = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), 'img') + from .Globals import glb + # Frozen-aware: nella build congelata img/ è nella cartella dell'exe. + _icons_dir = glb.AddPath('img') def _load_btn_icon(filename): path = _os.path.join(_icons_dir, filename) if _os.path.isfile(path): diff --git a/src/songpressPlusPlus/SongpressFrame.py b/src/songpressPlusPlus/SongpressFrame.py index 562d73c..b653b06 100644 --- a/src/songpressPlusPlus/SongpressFrame.py +++ b/src/songpressPlusPlus/SongpressFrame.py @@ -6522,7 +6522,9 @@ def OnGuideMarkdown(self, evt): if canonical: lang_code = canonical.split('_')[0].lower() # → "it", "en", "fr" - base_dir = os.path.dirname(__file__) + # Frozen-aware: nella build congelata guida_*.md e img/GUIDE sono + # nella cartella dell'exe (glb.path), non dentro lib/songpressPlusPlus/. + base_dir = glb.path _img_guide_url = 'file:///' + os.path.abspath(os.path.join(base_dir, 'img', 'GUIDE')).replace('\\', '/').lstrip('/') + '/' # Priorità: guida_.md → guida.md → errore @@ -7374,7 +7376,9 @@ def OnGuideCommandsMarkdown(self, evt): if canonical: lang_code = canonical.split('_')[0].lower() - base_dir = os.path.dirname(__file__) + # Frozen-aware: nella build congelata guida_*.md e img/GUIDE sono + # nella cartella dell'exe (glb.path), non dentro lib/songpressPlusPlus/. + base_dir = glb.path _img_guide_url = 'file:///' + os.path.abspath(os.path.join(base_dir, 'img', 'GUIDE')).replace('\\', '/').lstrip('/') + '/' # Priorità: guida_comandi_songpress_.md → guida_comandi_songpress.md → errore diff --git a/src/songpressPlusPlus/songimpress.py b/src/songpressPlusPlus/songimpress.py index a818b42..adbc343 100644 --- a/src/songpressPlusPlus/songimpress.py +++ b/src/songpressPlusPlus/songimpress.py @@ -50,6 +50,12 @@ def close(self): self._add_slide(self.prev, '') self.pres.save(self.out_filename) + def __enter__(self): + # Il blocco `with SongPresentation(...) as c` richiede __enter__: + # senza, Python solleva TypeError ("does not support the context + # manager protocol") prima ancora di eseguire il corpo. + return self + def __exit__(self, exc_type, exc_val, exc_tb): if exc_type is not None: # Exception in progress: save what we have but signal it to the caller diff --git a/src/spp_frozen_main.py b/src/spp_frozen_main.py new file mode 100644 index 0000000..fad2763 --- /dev/null +++ b/src/spp_frozen_main.py @@ -0,0 +1,29 @@ +############################################################### +# spp_frozen_main.py +# Entry point per la build congelata (cx_Freeze). +# +# main.py usa import relativi di pacchetto (`from .Globals import glb`), +# quindi NON può essere congelato come script __main__ di primo livello: +# in quel contesto __package__ è vuoto e ogni import relativo fallisce con +# "attempted relative import with no known parent package". +# +# Questo launcher importa il package `songpressplusplus` (cx_Freeze aggiunge +# la cartella di questo script — src/ — al percorso di ricerca dei moduli, +# rendendo il package individuabile) e ne invoca main() con il corretto +# contesto di pacchetto. +############################################################### + +import multiprocessing + +# Nota: il nome del package rispecchia la cartella reale su disco +# (src/songpressPlusPlus). Gli import interni sono relativi, quindi il +# nome di primo livello è quello della directory. Python impone la +# corrispondenza esatta del case anche su Windows: usare la stessa +# capitalizzazione della cartella è indispensabile. +from songpressPlusPlus.main import main + +if __name__ == "__main__": + # Necessario nelle build congelate su Windows: evita che i processi + # figli di multiprocessing rilancino l'intera GUI. + multiprocessing.freeze_support() + main()