From 11b25c5088f1a4b4079e71391428d27898574460 Mon Sep 17 00:00:00 2001 From: Jacob Bryan Date: Thu, 25 Jul 2024 14:09:00 -0600 Subject: [PATCH] better utilities for finding workbench installation --- package/inno_package.iss | 68 +++++++---- package/setup.py | 7 ++ package/ui/utils.py | 254 ++++++++++++++++++++++++++++++--------- 3 files changed, 248 insertions(+), 81 deletions(-) diff --git a/package/inno_package.iss b/package/inno_package.iss index bc73c800..5fcb5300 100644 --- a/package/inno_package.iss +++ b/package/inno_package.iss @@ -33,7 +33,8 @@ WizardStyle=modern Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] -Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}" +Name: "workbenchinstall"; Description: "Install NEAMS Workbench-5.4.1"; GroupDescription: "Optional Components" [Files] Source: "force_install\heron.exe"; DestDir: "{app}"; Flags: ignoreversion @@ -62,8 +63,8 @@ Root: HKCU; Subkey: "Software\Classes\FORCE.heron"; ValueType: string; ValueData Root: HKCU; Subkey: "Software\Classes\FORCE.heron\DefaultIcon"; ValueType: string; ValueData: "{app}\heron.exe,0" ; The open command will be set dynamically in the [Code] section -[Run] -Filename: "{app}\Workbench-5.4.1.exe"; Description: "Install NEAMS Workbench-5.4.1"; Flags: nowait postinstall skipifsilent +;[Run] +;Filename: "{app}\Workbench-5.4.1.exe"; Description: "Install NEAMS Workbench-5.4.1"; Flags: nowait postinstall skipifsilent [Code] var @@ -77,18 +78,24 @@ var begin Result := ''; Paths := [ - ExpandConstant('{sd}'), + ExpandConstant('{%USERPROFILE}'), ExpandConstant('{userpf}'), + ExpandConstant('{userprograms}'), ExpandConstant('{commonpf}'), ExpandConstant('{commonpf64}'), + ExpandConstant('{commonpf32}'), + ExpandConstant('{commonprograms}'), + ExpandConstant('{sd}'), ExpandConstant('{app}') ]; for I := 0 to GetArrayLength(Paths) - 1 do begin Path := Paths[I]; - if DirExists(Path + '\Workbench-5.4.1\bin\Workbench.exe') then + // MsgBox('Checking for Workbench at path ' + Path + '\Workbench-5.4.1\bin\Workbench.exe', mbInformation, MB_OK); + if FileExists(Path + '\Workbench-5.4.1\bin\Workbench.exe') then begin - Result := Path + '\Workbench-5.4.1\bin\Workbench.exe'; + Result := Path + '\Workbench-5.4.1\'; + // MsgBox('Found workbench at path ' + Result + '!', mbInformation, MB_OK); break; end; end; @@ -98,24 +105,36 @@ procedure CurStepChanged(CurStep: TSetupStep); var DefaultAppsFilePath: string; DefaultAppsContent: string; - WorkbenchConfigPath: string; ResultCode: Integer; begin - if (CurStep = ssPostInstall) and (WorkbenchPath <> '') then + // Install Workbench if the user selected the option + if CurStep = ssInstall then begin - // Run the "{app}\Workbench-5.4.1.exe" installer - Exec(ExpandConstant('{app}\Workbench-5.4.1.exe'), '', '', SW_SHOW, ewWaitUntilTerminated, ResultCode); - - // Find the path to the Workbench executable - WorkbenchPath := FindWorkbenchInstallPath() + if WizardIsTaskSelected('workbenchinstall') then + begin + // Run the Workbench installer + Exec(ExpandConstant('{app}\Workbench-5.4.1.exe'), '', '', SW_SHOW, ewWaitUntilTerminated, ResultCode); + // Find the path to the Workbench executable + WorkbenchPath := FindWorkbenchInstallPath(); + // MsgBox('Workbench installed at ' + WorkbenchPath, mbInformation, MB_OK); + //end + //else + //begin + // MsgBox('Workbench component not selected', mbInformation, MB_OK); + end; + end; + // If Workbench has been installed, associate .heron files with the Workbench executable + if (CurStep = ssPostInstall) and (WorkbenchPath <> '') then + begin // Associate .heron files with the Workbench executable - RegWriteStringValue(HKEY_CURRENT_USER, 'Software\Classes\FORCE.heron\shell\open\command', '', '"' + WorkbenchPath + '" "%1"'); + RegWriteStringValue(HKEY_CURRENT_USER, 'Software\Classes\FORCE.heron\shell\open\command', WorkbenchPath + 'bin\Workbench.exe', 'NEAMS Workbench 5.4.1'); - DefaultAppsFilePath := ExtractFilePath(WorkbenchPath) + 'default.apps.son'; + // default.apps.son file tells Workbench where to find HERON + DefaultAppsFilePath := WorkbenchPath + 'default.apps.son'; DefaultAppsContent := 'applications {' + #13#10 + - ' HERON {' + #13#10 + + ' HERON {' + #13#10 + ' configurations {' + #13#10 + ' default {' + #13#10 + ' options {' + #13#10 + @@ -125,19 +144,26 @@ begin ' }' + #13#10 + ' }' + #13#10 + ' }' + #13#10 + - ' }' + #13#10 + - ' }'; + ' }' + #13#10 + + '}'; // Save the default.apps.son file in the Workbench base directory if not SaveStringToFile(DefaultAppsFilePath, DefaultAppsContent, False) then begin - MsgBox('Failed to create default.apps.son in the Workbench directory.', mbError, MB_OK); + MsgBox('Failed to create default.apps.son in the Workbench directory. Attempted to write to ' + DefaultAppsFilePath, mbError, MB_OK); end; // Save the path to the Workbench executable in a file at {app}/.workbench. - if not SaveStringToFile(ExpandConstant('{app}') + '\.workbench', WorkbenchPath, False) then + if not SaveStringToFile(ExpandConstant('{app}') + '\.workbench', 'WORKBENCHDIR=' + WorkbenchPath, False) then + begin + MsgBox('Failed to save the path to the Workbench executable. Attempted to write to ' + ExpandConstant('{app}') + '\.workbench', mbError, MB_OK); + end; + end + else + begin + if CurStep = ssPostInstall then begin - MsgBox('Failed to save the path to the Workbench executable.', mbError, MB_OK); + MsgBox('Workbench not installed. Not creating Workbench defaults. WorkbenchPath = ' + WorkbenchPath, mbInformation, MB_OK); end; end; end; diff --git a/package/setup.py b/package/setup.py index 32e3d922..5932543b 100644 --- a/package/setup.py +++ b/package/setup.py @@ -1,6 +1,7 @@ import sys import platform import os +import pathlib from cx_Freeze import setup, Executable, build_exe import HERON.templates.write_inner @@ -12,6 +13,12 @@ "include_files": [(HERON.templates.write_inner.__file__,"lib/HERON/templates/write_inner.py")], } +# Add all of the HERON template XML files to the build +write_inner_path = pathlib.Path(HERON.templates.write_inner.__file__) +for xml_file in os.listdir(write_inner_path.parent): + if xml_file.endswith(".xml"): + build_exe_options["include_files"].append((write_inner_path.parent / xml_file, f"lib/HERON/templates/{xml_file}")) + # Some files must be included manually for the Windows build if platform.system().lower() == "windows": # netCDF4 .dll files get missed by cx_Freeze diff --git a/package/ui/utils.py b/package/ui/utils.py index 8f516c81..86f91909 100644 --- a/package/ui/utils.py +++ b/package/ui/utils.py @@ -1,3 +1,4 @@ +import sys import os import pathlib import subprocess @@ -7,30 +8,144 @@ from tkinter import messagebox, filedialog -def find_workbench() -> pathlib.Path: +def is_frozen_app() -> bool: + """ + Infers whether the application being run is a frozen executable or not. This is done by checking the + location and file extension of the HERON executable. + + @return, bool, True if the application is a frozen executable, False otherwise + """ + current_file_dir = pathlib.Path(__file__).parent + # If there's a "heron.py" file one directory up from this file, then we're likely running from the + # source code. Frozen executables will have "heron" or "heron.exe" as the executable name. + # FIXME: This is likely to be a bit fragile! Is there a better way to determine if we're running from + # a frozen executable? + if (current_file_dir.parent / "heron.py").exists(): + return False + else: + return True + + +def get_workbench_exe_path(workbench_dir: pathlib.Path) -> pathlib.Path: + """ + Returns the path to the Workbench executable, dependent on the operating system. + + @ In, workbench_dir, pathlib.Path, the path to the Workbench installation directory + @ Out, workbench_exe_path, pathlib.Path, the path to the Workbench executable + """ + if platform.system() == "Windows": + workbench_exe_path = workbench_dir / "bin" / "Workbench.exe" + elif platform.system() == "Darwin": + workbench_exe_path = workbench_dir / "Contents" / "MacOS" / "Workbench" + elif platform.system() == "Linux": + workbench_exe_path = workbench_dir / "bin" / "Workbench" + else: + raise ValueError(f"Platform {platform.system()} is not supported.") + + return workbench_exe_path + + +def get_workbench_dir_from_exe_path(workbench_exe_path: pathlib.Path) -> pathlib.Path: + """ + Returns the path to the Workbench installation directory from the path to the Workbench executable. + + @ In, workbench_exe_path, pathlib.Path, the path to the Workbench executable + @ Out, workbench_dir, pathlib.Path, the path to the Workbench installation directory + """ + if platform.system() == "Darwin": + workbench_dir = workbench_exe_path.parent.parent.parent + else: + workbench_dir = workbench_exe_path.parent.parent + return workbench_dir + + +def verify_workbench_dir(workbench_dir: pathlib.Path) -> bool: + """ + Verifies that the given path is a valid NEAMS Workbench installation directory. This is done by checking for the Workbench executable, + dependent on the operating system. + + @ In, workbench_dir, pathlib.Path, the path to the Workbench installation directory + @ Out, valid, bool, True if the directory is a valid Workbench installation, False otherwise + """ + workbench_exe_path = get_workbench_exe_path(workbench_dir) + valid = workbench_exe_path.exists() + return valid + + +def get_dirs(dirname: pathlib.Path, pattern: str = "*") -> list[pathlib.Path]: + """ + Finds all directories in dirname that match the given pattern. + + @ In, dirname, pathlib.Path, the directory to search + @ In, pattern, str, optional, the pattern to match directories against + @ Out, dirs, list[pathlib.Path], the list of directories that match the pattern + """ + dirs = [p for p in dirname.iterdir() if p.is_dir() and p.match(pattern)] + return dirs + + +def check_workbench_file_for_dir(workbench_file: pathlib.Path) -> pathlib.Path | None: + """ + Checks the given .workbench file for the installation directory of Workbench. If file does not exist, None is returned. If + the file does exist but if the Workbench executable cannot be found there, the WORKBENCHDIR key is deleted and None is returned. + Finally, if the file exists and the Workbench executable is found, the path to the Workbench installation directory is returned. + + @ In, workbench_file, pathlib.Path, the path to the .workbench file + @ Out, workbench_dir, pathlib.Path | None, the path to the Workbench installation directory, or None if the file does not exist or the + Workbench executable cannot be found + """ + if not workbench_file.exists(): # .workbench file not found at given path + return None + + # Parse the .workbench file to get the installation directory of Workbench. + # Info is stored in the format "KEY=VALUE" on each line. + workbench_config = {} + with open(workbench_file, 'r') as f: + for line in f: + key, value = line.strip().split("=") + workbench_config[key] = value + + workbench_dir = workbench_config.get("WORKBENCHDIR", None) + + if workbench_dir is not None and not verify_workbench_dir(pathlib.Path(workbench_dir)): + workbench_dir = None + + if workbench_dir is None: # either wasn't provided or was invalid + # If the path in the .workbench file is invalid, delete the WORKBENCHDIR key so we don't keep trying + # to use it. If no other keys are present, delete the file. + workbench_config.pop("WORKBENCHDIR", None) # Remove the key if it exists + if len(workbench_config) == 0: # If no other keys are present, delete the file + workbench_file.unlink() + else: # Otherwise, write the updated config back to the file + with open(workbench_file, 'w') as f: + for key, value in workbench_config.items(): + f.write(f"{key}={value}\n") + + return workbench_dir + + +def find_workbench() -> pathlib.Path | None: """ Finds the NEAMS Workbench executable. A ".workbench" file in the FORCE app's main directory tracks the location of the Workbench executable. If that file doesn't exist, we look in common directories to find Workbench ourselves. If we still can't find it, we ask the user to locate it manually, if desired. + + @ In, None + @ Out, workbench_exe_path, pathlib.Path | None, the path to the NEAMS Workbench executable, or None if it could not be found """ workbench_path = None # Check if a ".workbench" file exists in the main build directory (same directory as the heron and # raven_framework executables). That should be 2 directories up from the directory of this file. current_file_dir = pathlib.Path(__file__).parent - workbench_file = current_file_dir.parent.parent / ".workbench" - workbench_file_exists = workbench_file.exists() - if workbench_file_exists: - with open(workbench_file, 'r') as f: - workbench_path = f.read().strip() - if not os.path.exists(workbench_path): - # If the path in the .workbench file is invalid, delete the file so we don't keep trying - # to use it. - workbench_path = None - os.remove(workbench_file) - else: - return workbench_path + # Is this being run from a frozen executable or via the source code? Changes where the package's + # base directory is located, changing where to look for the .workbench file. + if is_frozen_app(): # Frozen executable + workbench_file = current_file_dir.parent.parent / ".workbench" + else: # Source code + workbench_file = current_file_dir.parent / ".workbench" + workbench_path = check_workbench_file_for_dir(workbench_file) # Returns None if file doesn't exist or is invalid # If that .workbench file doesn't exist, we can look around for the Workbench executable if platform.system() == "Windows": @@ -40,22 +155,22 @@ def find_workbench() -> pathlib.Path: workbench_path = wb_path else: # Manually search through a few common directories for the Workbench installation - for path in ["$HOMEDRIVE\\", "$PROGRAMFILES", "$HOME", "$APPDATA", "$LOCALAPPDATA"]: - path = os.path.expandvars(path) - if not os.path.exists(path): + for path in ["$HOMEDRIVE", "$PROGRAMFILES", "$HOME", "$APPDATA", "$LOCALAPPDATA"]: + path = pathlib.Path(os.path.expandvars(path)) + if not path.exists(): continue - for file in os.listdir(path): - if file.startswith("Workbench"): - wb_path = os.path.join(path, file, "bin", "Workbench.exe") - if os.path.exists(wb_path): - workbench_path = wb_path - break + for loc in get_dirs(path, "Workbench*"): + if verify_workbench_dir(loc): + workbench_path = loc + break elif platform.system() == "Darwin": - # Look in the /Applications directory for a directory starting with "Workbench" - for app in os.listdir("/Applications"): - if app.startswith("Workbench") and os.path: - workbench_path = os.path.join("/Applications", app, "Contents/MacOS/Workbench") + # The only place Workbench should be installed on Mac is in the Applications directory. + for app in get_dirs(pathlib.Path("/Applications"), "Workbench*"): + if verify_workbench_dir(app): + workbench_path = app break + # NOTE: Workbench install on Linux is only with a source install which has no standard location. We'll rely on the user to connect + # the two tools. # If we still haven't found Workbench, let the user help us out. Throw up a tkinter warning dialog to # ask the user to locate the Workbench executable. @@ -64,55 +179,72 @@ def find_workbench() -> pathlib.Path: root.withdraw() response = messagebox.askyesno( - "NEAMS Workbench could not be found!", - "The NEAMS Workbench executable could not be found. Would you like to locate it manually?" + title="NEAMS Workbench could not be found!", + message="The NEAMS Workbench executable could not be found. Would you like to manually find the Workbench installation directory?" ) if response: - workbench_path = filedialog.askopenfilename( - title="Locate NEAMS Workbench", - filetypes=[("Workbench Executable", "*.exe")] - ) + dialog_title = "Select NEAMS Workbench Application" if platform.system() == "Darwin" else "Select NEAMS Workbench Directory" + workbench_path = filedialog.askdirectory(title=dialog_title) if workbench_path: - with open(workbench_file, 'w') as f: - f.write(workbench_path) + is_valid = verify_workbench_dir(pathlib.Path(workbench_path)) + if not is_valid: + messagebox.showerror( + title="Invalid Workbench Directory", + message="The NEAMS Workbench executable was not found in the selected directory!" + ) + workbench_path = None + else: + with open(workbench_file, 'w') as f: + f.write("WORKBENCHDIR=" + workbench_path) + + if workbench_path is None: # If we still don't have a valid path, just give up I guess + return None if isinstance(workbench_path, str): workbench_path = pathlib.Path(workbench_path) - return workbench_path + workbench_exe_path = get_workbench_exe_path(workbench_path) + return workbench_exe_path -def create_workbench_heron_default(workbench_path: pathlib.Path): + +def create_workbench_heron_default(workbench_dir: pathlib.Path): """ Creates a configuration file for Workbench so it knows where HERON is located. - @ In, workbench_path, pathlib.Path, the path to the NEAMS Workbench executable + + @ In, workbench_dir, pathlib.Path, the path to the NEAMS Workbench installation directory + @ Out, NOne """ # First, we need to find the HERON executable. This will be "heron.exe" on Windows # and just "heron" on MacOS and Linux. It should be located 2 directories up from the # directory of this file. current_file_dir = pathlib.Path(__file__).parent - heron_path = current_file_dir.parent.parent / "heron" - if platform.system() == "Windows": - heron_path = heron_path.with_suffix(".exe") + + # Is this a frozen executable or source code? Changes where the package's base directory is located. + if (current_file_dir.parent / "heron.py").exists(): # Source code + heron_path = current_file_dir.parent.parent / "heron.py" + else: # Frozen executable + heron_path = current_file_dir.parent.parent / "heron" + # Windows executables have a ".exe" extension + if platform.system() == "Windows": + heron_path = heron_path.with_suffix(".exe") + # If the HERON executable doesn't exist, we can't create the Workbench configuration file if not heron_path.exists(): print(f"ERROR: Could not find the HERON executable in the directory {heron_path.parent}.") return # Create the configuration file for Workbench - workbench_root_dir = workbench_path.parent.parent - workbench_config_file = workbench_root_dir / "default.apps.son" + workbench_config_file = workbench_dir / "default.apps.son" if workbench_config_file.exists(): - # File already exists, but does a HERON entry already exist? See if HERON mentioned in the - # file. This isn't as robust as actually parsing the file, but it should work for now. - with open(workbench_config_file, 'r') as f: - for line in f: - if "heron" in line.lower(): - return - # If the file doesn't exist or doesn't mention HERON, we need to add it + # If the default app config file already exists, don't overwrite it. + print("Workbench default.apps.son file already exists! No edits made.") + return + + # If the file doesn't exist, create it and add a configuration for HERON print("Adding HERON configuration to NEAMS Workbench", workbench_config_file) - with open(workbench_config_file, 'a') as f: - f.write("\napplications {\n" + with open(workbench_config_file, "w") as f: + f.write("applications {\n" " HERON {\n" " configurations {\n" " default {\n" @@ -136,11 +268,12 @@ def run_in_workbench(file: str | None = None): workbench_path = find_workbench() if workbench_path is None: print("ERROR: Could not find the NEAMS Workbench executable. Please set the " - "WORKBENCH_PATH environment variable or add it to the system path.") - raise RuntimeError + "WORKBENCH_PATH environment variable, add it to the system path, or specify it manually " + "with the WORKBENCHDIR key in the \".workbench\" file in the main FORCE directory.") + return - # Point Workbench to the HERON executable if it's not already configured - create_workbench_heron_default(workbench_path) + # Create Workbench default configuration for HERON if a default configurations file does not exist + create_workbench_heron_default(get_workbench_dir_from_exe_path(workbench_path)) # Open the file in Workbench # Currently, we're only able to open the MacOS version of Workbench by opening the app itself. @@ -150,10 +283,11 @@ def run_in_workbench(file: str | None = None): if file is not None and platform.system() == "Windows": command += ' ' + file + print("Opening Workbench...", file=sys.__stdout__) + print("***If this is the first time you are running Workbench, this may take a few minutes!***\n", file=sys.__stdout__) if platform.system() == "Windows": - os.system(command) - elif platform.system() == "Darwin": - subprocess.call(["/usr/bin/open", "-n", "-a", workbench_path]) + # os.system(command) + subprocess.run(command) else: - print("ERROR: Automatic connection of FORCE tools to the NEAMS Workbench is only supported " - "on Windows and MacOS. Please open Workbench manually and open the desired file.") + # NOTE: untested on Linux as of 2024-07-22 + subprocess.call(["/usr/bin/open", "-n", "-a", workbench_path])