22Logic to build installers using Briefcase.
33"""
44
5+ import functools
56import logging
7+ import os
68import re
79import shutil
810import sys
911import sysconfig
12+ import tarfile
1013import tempfile
1114from dataclasses import dataclass
1215from pathlib import Path
1922 tomli_w = None # This file is only intended for Windows use
2023
2124from . import preconda
25+ from .jinja import render_template
2226from .utils import DEFAULT_REVERSE_DOMAIN_ID , copy_conda_exe , filename_dist
2327
2428BRIEFCASE_DIR = Path (__file__ ).parent / "briefcase"
@@ -219,37 +223,6 @@ def create_install_options_list(info: dict) -> list[dict]:
219223 return options
220224
221225
222- # Create a Briefcase configuration file. Using a full TOML writer rather than a Jinja
223- # template allows us to avoid escaping strings everywhere.
224- def write_pyproject_toml (tmp_dir , info ):
225- name , version = get_name_version (info )
226- bundle , app_name = get_bundle_app_name (info , name )
227-
228- config = {
229- "project_name" : name ,
230- "bundle" : bundle ,
231- "version" : version ,
232- "license" : get_license (info ),
233- "app" : {
234- app_name : {
235- "formal_name" : f"{ info ['name' ]} { info ['version' ]} " ,
236- "description" : "" , # Required, but not used in the installer.
237- "external_package_path" : EXTERNAL_PACKAGE_PATH ,
238- "use_full_install_path" : False ,
239- "install_launcher" : False ,
240- "post_install_script" : str (BRIEFCASE_DIR / "run_installation.bat" ),
241- "install_option" : create_install_options_list (info ),
242- }
243- },
244- }
245-
246- if "company" in info :
247- config ["author" ] = info ["company" ]
248-
249- (tmp_dir / "pyproject.toml" ).write_text (tomli_w .dumps ({"tool" : {"briefcase" : config }}))
250- logger .debug (f"Created TOML file at: { tmp_dir } " )
251-
252-
253226@dataclass (frozen = True )
254227class PayloadLayout :
255228 """A data class with purpose to contain the payload layout."""
@@ -267,29 +240,135 @@ class Payload:
267240 """
268241
269242 info : dict
270- root : Path | None = None
243+ archive_name : str = "payload.tar.gz"
244+ conda_exe_name : str = "_conda.exe"
245+
246+ # Enable additional log output during pre/post uninstall/install.
247+ add_debug_logging : bool = False
248+
249+ @functools .cached_property
250+ def root (self ) -> Path :
251+ """Create root upon first access and cache it."""
252+ return Path (tempfile .mkdtemp (prefix = "payload-" ))
253+
254+ def remove (self , * , ignore_errors : bool = True ) -> None :
255+ """Remove the root of the payload.
256+
257+ This function requires some extra care due to the root being a cached property.
258+ """
259+ root = getattr (self , "root" , None )
260+ if root is None :
261+ return
262+ shutil .rmtree (root , ignore_errors = ignore_errors )
263+ # Now we drop the cached value so next access will recreate if desired
264+ try :
265+ delattr (self , "root" )
266+ except Exception :
267+ # delattr on a cached_property may raise on some versions / edge cases
268+ pass
271269
272270 def prepare (self ) -> PayloadLayout :
273- root = self . _ensure_root ()
274- self ._write_pyproject ( root )
271+ """Prepares the payload."""
272+ root = self .root
275273 layout = self ._create_layout (root )
274+ # Render the template files and add them to the necessary config field
275+ self .render_templates ()
276+ self .write_pyproject_toml (layout )
276277
277278 preconda .write_files (self .info , layout .base )
278279 preconda .copy_extra_files (self .info .get ("extra_files" , []), layout .external )
279280 self ._stage_dists (layout )
280281 self ._stage_conda (layout )
282+
283+ archive_path = self .make_archive (layout .base , layout .external )
284+ if not archive_path .exists ():
285+ raise RuntimeError (f"Unexpected error, failed to create archive: { archive_path } " )
281286 return layout
282287
283- def remove (self ) -> None :
284- shutil .rmtree (self .root )
288+ def make_archive (self , src : Path , dst : Path ) -> Path :
289+ """Create an archive of the directory 'src'.
290+ The input 'src' must be an existing directory.
291+ If 'dst' does not exist, this function will create it.
292+ The directory specified via 'src' is removed after successful creation.
293+ Returns the path to the archive.
294+
295+ Example:
296+ payload = Payload(...)
297+ foo = Path('foo')
298+ bar = Path('bar')
299+ targz = payload.make_archive(foo, bar)
300+ This will create the file bar\\ <payload.archive_name> containing 'foo' and all its contents.
301+
302+ """
303+ if not src .is_dir ():
304+ raise NotADirectoryError (src )
305+ dst .mkdir (parents = True , exist_ok = True )
306+
307+ archive_path = dst / self .archive_name
308+
309+ archive_type = archive_path .suffix [1 :] # since suffix starts with '.'
310+ with tarfile .open (archive_path , mode = f"w:{ archive_type } " , compresslevel = 1 ) as tar :
311+ tar .add (src , arcname = src .name )
312+
313+ shutil .rmtree (src )
314+ return archive_path
315+
316+ def render_templates (self ) -> list [Path ]:
317+ """Render the configured templates under the payload root,
318+ returns a list of Paths to the rendered templates.
319+ """
320+ templates = {
321+ Path (BRIEFCASE_DIR / "run_installation.bat" ): Path (self .root / "run_installation.bat" ),
322+ Path (BRIEFCASE_DIR / "pre_uninstall.bat" ): Path (self .root / "pre_uninstall.bat" ),
323+ }
324+
325+ context : dict [str , str ] = {
326+ "archive_name" : self .archive_name ,
327+ "conda_exe_name" : self .conda_exe_name ,
328+ "add_debug" : self .add_debug_logging ,
329+ "register_envs" : str (self .info .get ("register_envs" , True )).lower (),
330+ }
331+
332+ # Render the templates now using jinja and the defined context
333+ for src , dst in templates .items ():
334+ if not src .exists ():
335+ raise FileNotFoundError (src )
336+ rendered = render_template (src .read_text (encoding = "utf-8" ), ** context )
337+ dst .parent .mkdir (parents = True , exist_ok = True )
338+ dst .write_text (rendered , encoding = "utf-8" , newline = "\r \n " )
339+
340+ return list (templates .values ())
341+
342+ def write_pyproject_toml (self , layout : PayloadLayout ) -> None :
343+ name , version = get_name_version (self .info )
344+ bundle , app_name = get_bundle_app_name (self .info , name )
345+
346+ config = {
347+ "project_name" : name ,
348+ "bundle" : bundle ,
349+ "version" : version ,
350+ "license" : get_license (self .info ),
351+ "app" : {
352+ app_name : {
353+ "formal_name" : f"{ self .info ['name' ]} { self .info ['version' ]} " ,
354+ "description" : "" , # Required, but not used in the installer.
355+ "external_package_path" : str (layout .external ),
356+ "use_full_install_path" : False ,
357+ "install_launcher" : False ,
358+ "install_option" : create_install_options_list (self .info ),
359+ "post_install_script" : str (layout .root / "run_installation.bat" ),
360+ "pre_uninstall_script" : str (layout .root / "pre_uninstall.bat" ),
361+ }
362+ },
363+ }
285364
286- def _write_pyproject (self , root : Path ) -> None :
287- write_pyproject_toml (root , self .info )
365+ # Add optional content
366+ if "company" in self .info :
367+ config ["author" ] = self .info ["company" ]
288368
289- def _ensure_root (self ) -> Path :
290- if self .root is None :
291- self .root = Path (tempfile .mkdtemp ())
292- return self .root
369+ # Finalize
370+ (layout .root / "pyproject.toml" ).write_text (tomli_w .dumps ({"tool" : {"briefcase" : config }}))
371+ logger .debug (f"Created TOML file at: { layout .root } " )
293372
294373 def _create_layout (self , root : Path ) -> PayloadLayout :
295374 """The layout is created as:
@@ -321,7 +400,7 @@ def _stage_dists(self, layout: PayloadLayout) -> None:
321400 shutil .copy (download_dir / filename_dist (dist ), layout .pkgs )
322401
323402 def _stage_conda (self , layout : PayloadLayout ) -> None :
324- copy_conda_exe (layout .external , "_conda.exe" , self .info ["_conda_exe" ])
403+ copy_conda_exe (layout .external , self . conda_exe_name , self .info ["_conda_exe" ])
325404
326405
327406def create (info , verbose = False ):
0 commit comments