diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index d08dd8530..ab2edf09a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -38,3 +38,52 @@ jobs: run: | anaconda -t ${{ secrets.CONDA_UPLOAD_TOKEN }} upload \ /usr/share/miniconda/envs/build/conda-bld/noarch/*.tar.bz2 + + + build: + runs-on: ${{ matrix.os }} + permissions: + contents: write + strategy: + fail-fast: false + matrix: + os: [ windows-latest, macos-13 ] + python-version: [ '3.10' ] + defaults: + run: + shell: bash -e {0} + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # install + - name: Install package + run: | + export GIT_DESCRIBE_TAG=`git describe --tags` + pip install . + - name: Install Build Dependencies + run: | + python -m pip install pyinstaller --user + - name: Build + id: build + run: | + if [ "$RUNNER_OS" == "Windows" ]; then + python scripts/createmsi.py + echo "build=$(ls *.msi)" >> $GITHUB_OUTPUT + elif [ "$RUNNER_OS" == "macOS" ]; then + export PATH=$PATH:/Users/runner/.local/bin + python scripts/create_macos_package.py + echo "build=$(ls *.pkg)" >> $GITHUB_OUTPUT + else + echo "$RUNNER_OS not supported" + exit 1 + fi + shell: bash + - name: Upload + uses: softprops/action-gh-release@v2 + with: + files: ${{ steps.build.outputs.build }} + + diff --git a/.gitignore b/.gitignore index 333a0ae4a..d743b2a66 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,5 @@ activity_browser/ABSettings.json .logs test/.logs .venv* + +pyinst-tmp \ No newline at end of file diff --git a/LICENSE.rtf b/LICENSE.rtf new file mode 100755 index 000000000..1485cef9d --- /dev/null +++ b/LICENSE.rtf @@ -0,0 +1,170 @@ +{\rtf1\ansi\deff0\nouicompat{\fonttbl{\f0\fnil\fcharset0 Courier New;}} +{\colortbl ;\red0\green0\blue255;} +{\*\generator Riched20 10.0.19041}\viewkind4\uc1 +\pard\f0\fs22\lang1033 GNU LESSER GENERAL PUBLIC LICENSE\par + Version 3, 29 June 2007\par +\par + Copyright (C) 2007 Free Software Foundation, Inc. <{{\field{\*\fldinst{HYPERLINK "https://fsf.org/"}}{\fldrslt{https://fsf.org/\ul0\cf0}}}}\f0\fs22 >\par + Everyone is permitted to copy and distribute verbatim copies\par + of this license document, but changing it is not allowed.\par +\par +\par + This version of the GNU Lesser General Public License incorporates\par +the terms and conditions of version 3 of the GNU General Public\par +License, supplemented by the additional permissions listed below.\par +\par + 0. Additional Definitions.\par +\par + As used herein, "this License" refers to version 3 of the GNU Lesser\par +General Public License, and the "GNU GPL" refers to version 3 of the GNU\par +General Public License.\par +\par + "The Library" refers to a covered work governed by this License,\par +other than an Application or a Combined Work as defined below.\par +\par + An "Application" is any work that makes use of an interface provided\par +by the Library, but which is not otherwise based on the Library.\par +Defining a subclass of a class defined by the Library is deemed a mode\par +of using an interface provided by the Library.\par +\par + A "Combined Work" is a work produced by combining or linking an\par +Application with the Library. The particular version of the Library\par +with which the Combined Work was made is also called the "Linked\par +Version".\par +\par + The "Minimal Corresponding Source" for a Combined Work means the\par +Corresponding Source for the Combined Work, excluding any source code\par +for portions of the Combined Work that, considered in isolation, are\par +based on the Application, and not on the Linked Version.\par +\par + The "Corresponding Application Code" for a Combined Work means the\par +object code and/or source code for the Application, including any data\par +and utility programs needed for reproducing the Combined Work from the\par +Application, but excluding the System Libraries of the Combined Work.\par +\par + 1. Exception to Section 3 of the GNU GPL.\par +\par + You may convey a covered work under sections 3 and 4 of this License\par +without being bound by section 3 of the GNU GPL.\par +\par + 2. Conveying Modified Versions.\par +\par + If you modify a copy of the Library, and, in your modifications, a\par +facility refers to a function or data to be supplied by an Application\par +that uses the facility (other than as an argument passed when the\par +facility is invoked), then you may convey a copy of the modified\par +version:\par +\par + a) under this License, provided that you make a good faith effort to\par + ensure that, in the event an Application does not supply the\par + function or data, the facility still operates, and performs\par + whatever part of its purpose remains meaningful, or\par +\par + b) under the GNU GPL, with none of the additional permissions of\par + this License applicable to that copy.\par +\par + 3. Object Code Incorporating Material from Library Header Files.\par +\par + The object code form of an Application may incorporate material from\par +a header file that is part of the Library. You may convey such object\par +code under terms of your choice, provided that, if the incorporated\par +material is not limited to numerical parameters, data structure\par +layouts and accessors, or small macros, inline functions and templates\par +(ten or fewer lines in length), you do both of the following:\par +\par + a) Give prominent notice with each copy of the object code that the\par + Library is used in it and that the Library and its use are\par + covered by this License.\par +\par + b) Accompany the object code with a copy of the GNU GPL and this license\par + document.\par +\par + 4. Combined Works.\par +\par + You may convey a Combined Work under terms of your choice that,\par +taken together, effectively do not restrict modification of the\par +portions of the Library contained in the Combined Work and reverse\par +engineering for debugging such modifications, if you also do each of\par +the following:\par +\par + a) Give prominent notice with each copy of the Combined Work that\par + the Library is used in it and that the Library and its use are\par + covered by this License.\par +\par + b) Accompany the Combined Work with a copy of the GNU GPL and this license\par + document.\par +\par + c) For a Combined Work that displays copyright notices during\par + execution, include the copyright notice for the Library among\par + these notices, as well as a reference directing the user to the\par + copies of the GNU GPL and this license document.\par +\par + d) Do one of the following:\par +\par + 0) Convey the Minimal Corresponding Source under the terms of this\par + License, and the Corresponding Application Code in a form\par + suitable for, and under terms that permit, the user to\par + recombine or relink the Application with a modified version of\par + the Linked Version to produce a modified Combined Work, in the\par + manner specified by section 6 of the GNU GPL for conveying\par + Corresponding Source.\par +\par + 1) Use a suitable shared library mechanism for linking with the\par + Library. A suitable mechanism is one that (a) uses at run time\par + a copy of the Library already present on the user's computer\par + system, and (b) will operate properly with a modified version\par + of the Library that is interface-compatible with the Linked\par + Version.\par +\par + e) Provide Installation Information, but only if you would otherwise\par + be required to provide such information under section 6 of the\par + GNU GPL, and only to the extent that such information is\par + necessary to install and execute a modified version of the\par + Combined Work produced by recombining or relinking the\par + Application with a modified version of the Linked Version. (If\par + you use option 4d0, the Installation Information must accompany\par + the Minimal Corresponding Source and Corresponding Application\par + Code. If you use option 4d1, you must provide the Installation\par + Information in the manner specified by section 6 of the GNU GPL\par + for conveying Corresponding Source.)\par +\par + 5. Combined Libraries.\par +\par + You may place library facilities that are a work based on the\par +Library side by side in a single library together with other library\par +facilities that are not Applications and are not covered by this\par +License, and convey such a combined library under terms of your\par +choice, if you do both of the following:\par +\par + a) Accompany the combined library with a copy of the same work based\par + on the Library, uncombined with any other library facilities,\par + conveyed under the terms of this License.\par +\par + b) Give prominent notice with the combined library that part of it\par + is a work based on the Library, and explaining where to find the\par + accompanying uncombined form of the same work.\par +\par + 6. Revised Versions of the GNU Lesser General Public License.\par +\par + The Free Software Foundation may publish revised and/or new versions\par +of the GNU Lesser General Public License from time to time. Such new\par +versions will be similar in spirit to the present version, but may\par +differ in detail to address new problems or concerns.\par +\par + Each version is given a distinguishing version number. If the\par +Library as you received it specifies that a certain numbered version\par +of the GNU Lesser General Public License "or any later version"\par +applies to it, you have the option of following the terms and\par +conditions either of that published version or of any later version\par +published by the Free Software Foundation. If the Library as you\par +received it does not specify a version number of the GNU Lesser\par +General Public License, you may choose any version of the GNU Lesser\par +General Public License ever published by the Free Software Foundation.\par +\par + If the Library as you received it specifies that a proxy can decide\par +whether future versions of the GNU Lesser General Public License shall\par +apply, that proxy's public statement of acceptance of any version is\par +permanent authorization for you to choose that version for the\par +Library.\par +} + \ No newline at end of file diff --git a/README.md b/README.md index d515f0cf9..4fb7c8f84 100644 --- a/README.md +++ b/README.md @@ -235,3 +235,12 @@ If you experience problems or are suffering from a specific bug, please [raise a # License You can find the license information for Activity Browser in the [license file](https://github.com/LCA-ActivityBrowser/activity-browser/blob/main/LICENSE.txt). + + +# Build + +```bash +pip install third-party-license-file-generator +pip freeze > requirements.txt +python -m third_party_license_file_generator -r requirements.txt -p .venv/bin/python -c -g +``` \ No newline at end of file diff --git a/activity-browser.spec b/activity-browser.spec new file mode 100644 index 000000000..5767faa0c --- /dev/null +++ b/activity-browser.spec @@ -0,0 +1,99 @@ +# -*- mode: python ; coding: utf-8 -*- +import argparse +import os +import platform + +parser = argparse.ArgumentParser() +parser.add_argument("--debug", action="store_true") +parser.add_argument("--verbose", action="store_true") +parser.add_argument("--console", action="store_true") +options = parser.parse_args() + +python_options = [ + ("W ignore", None, "OPTION"), +] +if options.verbose: + python_options.append(("v", None, "OPTION")) + +EXE_ICON = os.path.join("activity_browser", "static", "icons", "activity-browser.ico") if platform.system().lower() == 'windows' else None + +a = Analysis( + ["run-activity-browser.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=["activity_browser", "playhouse"], + hookspath=['scripts/hooks'], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +a.datas += Tree("activity_browser/static", prefix="activity_browser/static") +pyz = PYZ(a.pure) + +if options.debug: + exe = EXE( + pyz, + a.scripts, + python_options, + exclude_binaries=True, + name="activity-browser", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=options.console, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="activity-browser", + icon=EXE_ICON + ) + target = coll +else: + exe = EXE( + pyz, + a.scripts, + python_options, + a.binaries, + a.datas, + name="activity-browser", + console=options.console, + icon=EXE_ICON + ) + target = exe + +if platform.system() == "Darwin": + app = BUNDLE( + target, + name="Activity Browser.app", + icon="activity_browser/static/icons/activity-browser.icns", + bundle_identifier="com.github.lca_activity_browser.activity_browser", + version=os.environ.get("VERSION", "0.0.0"), + info_plist={ + "NSPrincipalClass": "NSApplication", + "NSAppleScriptEnabled": False, + "CFBundleDocumentTypes": [ + { + "CFBundleTypeName": "Icon", + "CFBundleTypeIconFile": "activity_browser/static/icons/activity-browser.icns", + "LSItemContentTypes": [ + "com.github.lca_activity_browser.activity_browser" + ], + "LSHandlerRank": "Owner", + } + ], + }, + ) diff --git a/activity_browser/__init__.py b/activity_browser/__init__.py index aceefc824..32ca49e08 100644 --- a/activity_browser/__init__.py +++ b/activity_browser/__init__.py @@ -11,6 +11,7 @@ from .plugin import Plugin from .controllers import * + def load_settings() -> None: if ab_settings.settings: bw2data.projects.switch_dir(ab_settings.current_bw_dir) @@ -19,7 +20,7 @@ def load_settings() -> None: log.info(f"Brightway2 current project: {bw2data.projects.current}") -def run_activity_browser(): +def run_activity_browser(splash=None): log.info(f"Activity Browser version: {version}") if log_file_location: log.info(f"The log file can be found at {log_file_location}") @@ -27,6 +28,8 @@ def run_activity_browser(): application.main_window = MainWindow(application) load_settings() application.show() + if splash: + splash.finish(application.main_window) sys.excepthook = exception_hook diff --git a/activity_browser/application.py b/activity_browser/application.py index b37b9799c..e539c9455 100644 --- a/activity_browser/application.py +++ b/activity_browser/application.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os +from PySide2 import QtWidgets from PySide2.QtCore import QCoreApplication, QObject, QSysInfo, Qt from PySide2.QtWidgets import QApplication @@ -51,6 +52,8 @@ def deleteLater(self): os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--no-sandbox" log.info("Info: QtWebEngine sandbox disabled") -QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) - -application = ABApplication() +if not QtWidgets.QApplication.instance(): + QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) + application = ABApplication() +else: + application: ABApplication = QtWidgets.QApplication.instance() diff --git a/activity_browser/layouts/panels/right.py b/activity_browser/layouts/panels/right.py index a1747c7fc..98cf0fc41 100644 --- a/activity_browser/layouts/panels/right.py +++ b/activity_browser/layouts/panels/right.py @@ -4,6 +4,7 @@ from activity_browser import log, signals from activity_browser.mod import bw2data as bd +from activity_browser.utils import STATIC_DIR from ...bwutils.commontasks import get_activity_name from ...ui.web import GraphNavigatorWidget, RestrictedWebViewWidget @@ -22,8 +23,7 @@ class RightPanel(ABTab): def __init__(self, *args): super(RightPanel, self).__init__(*args) - package_dir = Path(__file__).resolve().parents[2] - html_file = str(package_dir.joinpath("static", "startscreen", "welcome.html")) + html_file = str(STATIC_DIR.joinpath("startscreen", "welcome.html")) self.tabs = { "Welcome": RestrictedWebViewWidget(html_file=html_file), "Characterization Factors": CharacterizationFactorsTab(self), diff --git a/activity_browser/logger.py b/activity_browser/logger.py index de63d00c8..796efea7c 100644 --- a/activity_browser/logger.py +++ b/activity_browser/logger.py @@ -1,16 +1,19 @@ import inspect +import json import logging import os import sys import threading import time from io import StringIO +from logging.handlers import TimedRotatingFileHandler from traceback import extract_tb from types import TracebackType from typing import TextIO, Type import platformdirs +BUNDLED = getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') WHITELIST = ["activity_browser", "ab_plugin", "brightway2", "bw2data", "bw2io", "C/C++"] EXTENDED_CONSOLE = os.environ.get("AB_EXTENDED_CONSOLE", False) SIMPLE_CONSOLE = os.environ.get("AB_SIMPLE_CONSOLE", False) @@ -206,7 +209,11 @@ def __init__(self): self.filename = "ab_logs" + self.timestamp() + ".csv" # set dir and create it if it doesn't exist yet - dir_path = str(platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) + dir_path = str( + platformdirs.user_log_dir( + appname="ActivityBrowser", appauthor="ActivityBrowser" + ) + ) os.makedirs(dir_path, exist_ok=True) # create final filepath of the logfile of this session @@ -279,6 +286,76 @@ def timestamp(self) -> str: return f"-{stmp.tm_year}-{stmp.tm_mon}-{stmp.tm_mday}_{stmp.tm_hour}-{stmp.tm_min}-{stmp.tm_sec}" +class ABTimedFileHandler(TimedRotatingFileHandler): + + def __init__(self, suffix: str, *args, **kwargs): + # set dir and create it if it doesn't exist yet + dir_path = str(platformdirs.user_log_dir(appname="ActivityBrowser", appauthor="ActivityBrowser")) + os.makedirs(dir_path, exist_ok=True) + self.filepath = os.path.join(dir_path, "ab_logs.{}".format(suffix)) + + super().__init__(self.filepath, *args, **kwargs) + + def namer(self, default_name): + base_filename, ext, date = default_name.split(".") + return f"{base_filename}.{date}.{ext}" + + +class JSONFormatter(logging.Formatter): + """ + Formatter that outputs JSON strings after parsing the LogRecord. + + @param dict fmt_dict: Key: logging format attribute pairs. Defaults to {"message": "message"}. + @param str time_format: time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S" + @param str msec_format: Microsecond formatting. Appended at the end. Default: "%s.%03dZ" + """ + + def __init__(self, fmt_dict: dict = None, time_format: str = "%Y-%m-%dT%H:%M:%S", msec_format: str = "%s.%03dZ"): + self.fmt_dict = fmt_dict if fmt_dict is not None else {"message": "message"} + self.default_time_format = time_format + self.default_msec_format = msec_format + self.datefmt = None + + def usesTime(self) -> bool: + """ + Overwritten to look for the attribute in the format dict values instead of the fmt string. + """ + return "asctime" in self.fmt_dict.values() + + def formatMessage(self, record) -> dict: + """ + Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string. + KeyError is raised if an unknown attribute is provided in the fmt_dict. + """ + return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()} + + def format(self, record) -> str: + """ + Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON + instead of a string. + """ + record.message = record.getMessage() + + if self.usesTime(): + record.asctime = self.formatTime(record, self.datefmt) + + message_dict = self.formatMessage(record) + + if record.exc_info: + # Cache the traceback text to avoid converting it multiple times + # (it's constant anyway) + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + message_dict["exc_info"] = record.exc_text + + if record.stack_info: + message_dict["stack_info"] = self.formatStack(record.stack_info) + + return json.dumps(message_dict, default=str) + + class LoggingProxy: """ Official logging documentation states that loggers should be initiated per module using the __name__ attribute of @@ -340,7 +417,7 @@ def log(self, level: int, msg, *args, stack_level: int = 2, exc_info: tuple = No def exception_hook( - error: Type[BaseException], message: BaseException, traceback: TracebackType + error: Type[BaseException], message: BaseException, traceback: TracebackType ): """Exception hook to catch and log exceptions""" exc_info = (error, message, traceback) @@ -361,27 +438,47 @@ def basic_setup(): def advanced_setup(): - # replace the low and high level StdIO's - low_level_stdout = LowLevelStdIO(sys.stdout).start_capture("StdoutCapture") - # low_level_stderr = LowLevelStdIO(sys.stderr).start_capture("StderrCapture") - - sys.stdout = HighLevelStdIO() - # sys.stderr = HighLevelStdIO() - # setting up our own logger root = logging.getLogger() logging.addLevelName(25, "PRINT") - # setting up the console handler - console_handler = ABConsoleHandler(low_level_stdout) - console_handler.addFilter(log_filter) - console_handler.setLevel(LOG_LEVEL) - root.addHandler(console_handler) - - # setting up the file handler - file_handler = ABFileHandler() - file_handler.addFilter(log_filter) - root.addHandler(file_handler) + if not BUNDLED: + # replace the low and high level StdIO's + low_level_stdout = LowLevelStdIO(sys.stdout).start_capture("StdoutCapture") + # low_level_stderr = LowLevelStdIO(sys.stderr).start_capture("StderrCapture") + + sys.stdout = HighLevelStdIO() + # sys.stderr = HighLevelStdIO() + + # setting up the console handler + console_handler = ABConsoleHandler(low_level_stdout) + console_handler.addFilter(log_filter) + console_handler.setLevel(LOG_LEVEL) + root.addHandler(console_handler) + + # setting up the file handler + file_handler = ABFileHandler() + file_handler.addFilter(log_filter) + file_handler.setLevel(LOG_LEVEL) + root.addHandler(file_handler) + else: + file_handler = ABTimedFileHandler("json") + file_handler.addFilter(log_filter) + file_handler.setLevel(LOG_LEVEL) + json_formatter = JSONFormatter({ + "level": "levelname", + "message": "message", + "loggerName": "name", + "processName": "processName", + "processID": "process", + "threadName": "threadName", + "threadID": "thread", + "timestamp": "asctime", + "funcName": "funcName", + "lineno": "lineno" + }) + file_handler.setFormatter(json_formatter) + root.addHandler(file_handler) log = LoggingProxy() diff --git a/activity_browser/static/icons/activity-browser.icns b/activity_browser/static/icons/activity-browser.icns new file mode 100644 index 000000000..66b6030fb Binary files /dev/null and b/activity_browser/static/icons/activity-browser.icns differ diff --git a/activity_browser/static/icons/activity-browser.ico b/activity_browser/static/icons/activity-browser.ico new file mode 100644 index 000000000..86f157e38 Binary files /dev/null and b/activity_browser/static/icons/activity-browser.ico differ diff --git a/activity_browser/ui/icons.py b/activity_browser/ui/icons.py index fb8e3916e..08910385b 100644 --- a/activity_browser/ui/icons.py +++ b/activity_browser/ui/icons.py @@ -3,12 +3,12 @@ from PySide2.QtGui import QIcon -PACKAGE_DIR = Path(__file__).resolve().parents[1] +from activity_browser.utils import STATIC_DIR def create_path(folder: str, filename: str) -> str: """Builds a path to the image file.""" - return str(PACKAGE_DIR.joinpath("static", "icons", folder, filename)) + return str(STATIC_DIR.joinpath("icons", folder, filename)) # CURRENTLY UNUSED ICONS diff --git a/activity_browser/ui/web/base.py b/activity_browser/ui/web/base.py index 4de9746a3..ddbb1ce72 100644 --- a/activity_browser/ui/web/base.py +++ b/activity_browser/ui/web/base.py @@ -38,7 +38,7 @@ def __init__(self, parent=None, css_file: str = "", *args, **kwargs): self.view.loadFinished.connect(self.load_finished_handler) self.view.setContextMenuPolicy(Qt.PreventContextMenu) self.view.page().setWebChannel(self.channel) - self.url = QUrl.fromLocalFile(self.HTML_FILE) + self.url = QUrl.fromLocalFile(str(self.HTML_FILE)) self.css_file = css_file # Various Qt objects diff --git a/activity_browser/ui/web/navigator.py b/activity_browser/ui/web/navigator.py index 780b0fce4..13c68d4ea 100644 --- a/activity_browser/ui/web/navigator.py +++ b/activity_browser/ui/web/navigator.py @@ -10,6 +10,7 @@ from activity_browser import log, signals from activity_browser.mod.bw2data import Database, get_activity +from activity_browser.utils import STATIC_DIR from ...bwutils.commontasks import identify_activity_type from .base import BaseGraph, BaseNavigatorWidget @@ -50,9 +51,7 @@ class GraphNavigatorWidget(BaseNavigatorWidget): NAVIGATION MODE: Click on activities to jump to specific activities (instead of expanding the graph). """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/navigator.html" - ) + HTML_FILE = STATIC_DIR.joinpath("navigator.html") def __init__(self, parent=None, key=None): super().__init__(parent, css_file="navigator.css") diff --git a/activity_browser/ui/web/sankey_navigator.py b/activity_browser/ui/web/sankey_navigator.py index 85c3e0a1e..1694c49be 100644 --- a/activity_browser/ui/web/sankey_navigator.py +++ b/activity_browser/ui/web/sankey_navigator.py @@ -17,6 +17,7 @@ from activity_browser import log, signals from activity_browser.mod import bw2data as bd from activity_browser.mod.bw2data.backends import ActivityDataset +from activity_browser.utils import STATIC_DIR from ...bwutils.commontasks import identify_activity_type from ...bwutils.superstructure.graph_traversal_with_scenario import ( @@ -45,9 +46,7 @@ class SankeyNavigatorWidget(BaseNavigatorWidget): Green flows: Avoided impacts """ - HTML_FILE = os.path.join( - os.path.abspath(os.path.dirname(__file__)), "../../static/sankey_navigator.html" - ) + HTML_FILE = STATIC_DIR.joinpath("sankey_navigator.html") def __init__(self, cs_name, parent=None): super().__init__(parent, css_file="sankey_navigator.css") diff --git a/activity_browser/ui/web/webutils.py b/activity_browser/ui/web/webutils.py index 072d4d040..1f61e1761 100644 --- a/activity_browser/ui/web/webutils.py +++ b/activity_browser/ui/web/webutils.py @@ -4,7 +4,7 @@ from PySide2 import QtCore, QtGui, QtWebEngineWidgets, QtWidgets # type "localhost:3999" in Chrome for DevTools of AB web content -from activity_browser.utils import get_base_path +from activity_browser.utils import STATIC_DIR os.environ["QTWEBENGINE_REMOTE_DEBUGGING"] = "3999" @@ -56,8 +56,8 @@ def __init__(self, parent=None, url=None, html_file=None): def get_static_js_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "javascript", file_name)) + return str(STATIC_DIR.joinpath("javascript", file_name)) def get_static_css_path(file_name: str = "") -> str: - return str(get_base_path().joinpath("static", "css", file_name)) + return str(STATIC_DIR.joinpath("css", file_name)) diff --git a/activity_browser/utils.py b/activity_browser/utils.py index 18316db2a..99f69164d 100644 --- a/activity_browser/utils.py +++ b/activity_browser/utils.py @@ -1,4 +1,6 @@ +import importlib import os +import importlib.resources from pathlib import Path from typing import Iterable, Tuple @@ -6,13 +8,17 @@ from bw_processing import safe_filename from PySide2 import QtWidgets +import activity_browser from activity_browser.mod import bw2data as bd from .settings import ab_settings +STATIC_DIR = importlib.resources.files(activity_browser) / "static" -def get_base_path() -> Path: - return Path(__file__).resolve().parents[0] + +def get_package_dir() -> Path: + """Return activity_browser package path""" + return Path(importlib.util.find_spec("activity_browser").origin).parent def read_file_text(file_dir: str) -> str: diff --git a/run-activity-browser.py b/run-activity-browser.py index 5131d1fb9..0b03a810d 100644 --- a/run-activity-browser.py +++ b/run-activity-browser.py @@ -1,5 +1,97 @@ # -*- coding: utf-8 -*- -from activity_browser import run_activity_browser -import logging +import importlib +import multiprocessing +import os +import sys -run_activity_browser() +from PySide2.QtCore import QCoreApplication, QObject, QSysInfo, Qt +from PySide2.QtGui import QPixmap +from PySide2.QtWidgets import QApplication +from PySide2.QtWidgets import QSplashScreen + + +class ABApplication(QApplication): + """Matches the ABApplication definied in application.py""" + + _main_window = None + _controllers = None + + @property + def main_window(self) -> QObject: + """Returns the main_window widget of the Activity Browser""" + if self._main_window: + return self._main_window + raise Exception( + "main_window not yet initialized, did you try to access it during startup?" + ) + + @main_window.setter + def main_window(self, widget): + self._main_window = widget + + def show(self): + self.main_window.showMaximized() + + def close(self): + for child in self.children(): + if hasattr(child, "close"): + child.close() + + def deleteLater(self): + self.main_window.deleteLater() + + +if QSysInfo.productType() == "osx": + # https://bugreports.qt.io/browse/QTBUG-87014 + # https://bugreports.qt.io/browse/QTBUG-85546 + # https://github.com/mapeditor/tiled/issues/2845 + # https://doc.qt.io/qt-5/qoperatingsystemversion.html#MacOSBigSur-var + supported = {"10.10", "10.11", "10.12", "10.13", "10.14", "10.15"} + if QSysInfo.productVersion() not in supported: + os.environ["QT_MAC_WANTS_LAYER"] = "1" + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--disable-gpu" + +if QSysInfo.productType() in ["arch", "nixos"]: + os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--no-sandbox" + +QCoreApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True) + + +def show_splash_screen(): + application = ABApplication(sys.argv) + # Create the splash screen + splash = QSplashScreen( + QPixmap( + os.path.join( + os.path.dirname(importlib.util.find_spec("activity_browser").origin), + "static", + "icons", + "main", + "activitybrowser.png", + ) + ).scaledToHeight(480) + ) + splash.setWindowFlag(Qt.WindowStaysOnTopHint) + splash.showMessage( + "

Loading, please wait...

", Qt.AlignBottom | Qt.AlignCenter, Qt.white + ) + + # Show the splash screen + splash.show() + + application.processEvents() + + # from activity_browser import run_activity_browser + activity_browser = importlib.import_module("activity_browser") + application.processEvents() + activity_browser.run_activity_browser(splash) + + +if __name__ == "__main__": + multiprocessing.freeze_support() + # Useful if you want to run some interactive commands using the built + # binary and test out dependencies to ensure that they work properly. + # import code + # code.interact() + + show_splash_screen() diff --git a/scripts/create_macos_package.py b/scripts/create_macos_package.py new file mode 100644 index 000000000..07239c544 --- /dev/null +++ b/scripts/create_macos_package.py @@ -0,0 +1,48 @@ +import platform +import shutil +import subprocess +import sys + + +def build_app(): + pyinstaller = shutil.which("pyinstaller") + if not pyinstaller: + print("ERROR: This script requires pyinstaller.") + sys.exit(1) + + pyinst_cmd = [ + pyinstaller, + "activity-browser.spec", + "--clean", + "-y" + ] + subprocess.check_call(pyinst_cmd) + + +def build_macos_package(application_path='dist/Activity Browser.app', package_path='./Activity Browser-{}.pkg', + sign_identity: str=None): + macos_architecture = 'intel' if platform.processor() == 'i386' else 'arm' + build_cmd = [ + 'pkgbuild', + '--component', + application_path, + "--install-location", + '/Applications', + package_path.format(macos_architecture) + ] + if sign_identity: + build_cmd = build_cmd[0] + ['--sign', sign_identity] + build_cmd[1:] + subprocess.check_call(build_cmd) + + +if __name__ == "__main__": + import argparse + + if not shutil.which("pyinstaller"): + subprocess.check_call(["pip", "install", "--upgrade", "pyinstaller"]) + + parser = argparse.ArgumentParser() + parser.add_argument("--sign-identity", help="signing identity") + options = parser.parse_args() + build_app() + build_macos_package(sign_identity=options.sign_identity) \ No newline at end of file diff --git a/scripts/createmsi.py b/scripts/createmsi.py new file mode 100644 index 000000000..bc0f7dd0a --- /dev/null +++ b/scripts/createmsi.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +""" +This script is for generating MSI packages +for Windows users. + +To generate a self-signed certificate for code signing: + + openssl genrsa -out code-signing.key 2048 + openssl req -new -key code-signing.key -out code-signing.csr + openssl x509 -req -days 365 -in code-signing.csr -signkey code-signing.key -out code-signing.crt + openssl pkcs12 -export -out code-signing.pfx -inkey code-signing.key -in code-signing.crt -name "ABCodeSigningCert" +""" +import logging +import os +import shutil +import subprocess +import sys +import uuid +import xml.etree.ElementTree as ET +from glob import glob + +sys.path.append(os.getcwd()) + +# sometimes the signing tool exe is not on PATH, so allow user to set env to it +WINDOWS_SDK_BIN = os.environ.get("WINDOWS_SDK_BIN", "") +# Elementtree does not support CDATA. So hack it. +WINVER_CHECK = "Installed OR (VersionNT64 > 602)>" +logger = logging.getLogger(__name__) + + +def gen_guid(): + """ + Generate guid + """ + return str(uuid.uuid4()).upper() + + +class Node: + """ + Node to hold path and directory values + """ + + def __init__(self, dirs, files): + self.check_dirs(dirs) + self.check_files(files) + self.dirs = dirs + self.files = files + + @staticmethod + def check_dirs(dirs): + """ + Check to see if directory is instance of list + """ + assert isinstance(dirs, list) + + @staticmethod + def check_files(files): + """ + Check to see if files is instance of list + """ + assert isinstance(files, list) + + +class PackageGenerator: + """ + Package generator for MSI packages + """ + + def __init__(self, signing_certificate=None, version=None): + self.product_name = "Activity Browser" + self.manufacturer = "The Activity Browser Team" + self.version = version or os.environ.get("VERSION", "0.0.0") + if "-" in self.version: + versions = self.version.split("-") + self.version = "{}.{}".format(versions[0], versions[1]) + print("Using version {} instead".format(self.version)) + self.root = None + self.guid = "*" + self.update_guid = "728A617A-4F3F-49BB-B9E5-5D02F037B67B" + self.main_xml = "activitybrowser.wxs" + self.main_o = "activitybrowser.wixobj" + self.final_output = "activitybrowser-{}-64.msi".format(self.version) + self.staging_dirs = ["dist"] + self.progfile_dir = "ProgramFiles64Folder" + redist_globs = [ + "C:\\Program Files (x86)\\Microsoft Visual Studio\\2019\\Community\\VC\\Redist\\MSVC\\*\\MergeModules\\Microsoft_VC142_CRT_x64.msm", + "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Redist\\MSVC\\v*\\MergeModules\\Microsoft_VC143_CRT_x64.msm", + "C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Redist\\MSVC\\*\\MergeModules\\Microsoft_VC143_CRT_x64.msm", + r"C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\Redist\MSVC\v*\MergeModules\Microsoft_VC143_CRT_x64.msm", + ] + redist_path = None + for g in redist_globs: + trials = glob(g) + if len(trials) > 1: + sys.exit("MSM glob matched multiple entries:" + "\n".join(trials)) + if len(trials) == 1: + redist_path = trials[0] + break + if redist_path is None: + sys.exit("No MSMs found.") + self.redist_path = redist_path + self.component_num = 0 + self.feature_properties = { + self.staging_dirs[0]: { + "Id": "MainProgram", + "Title": "Activity Browser", + "Description": "Activity Browser Executables", + "Level": "1", + "AllowAbsent": "no", + }, + } + self.feature_components = {} + for s_d in self.staging_dirs: + self.feature_components[s_d] = [] + self.signing_certificate = signing_certificate + + def build_dist(self): + """ + Build dist file from PyInstaller info + """ + for sdir in self.staging_dirs: + if os.path.exists(sdir): + shutil.rmtree(sdir) + main_stage = self.staging_dirs[0] + + pyinstaller = shutil.which("pyinstaller") + if not pyinstaller: + print("ERROR: This script requires pyinstaller.") + sys.exit(1) + + pyinstaller_tmpdir = "pyinst-tmp" + if os.path.exists(pyinstaller_tmpdir): + shutil.rmtree(pyinstaller_tmpdir) + pyinst_cmd = [ + pyinstaller, + "activity-browser.spec", + "--clean", + "--distpath", + pyinstaller_tmpdir, + ] + subprocess.check_call(pyinst_cmd) + shutil.move(os.path.join(pyinstaller_tmpdir), main_stage) + self.del_infodirs(main_stage) + if not os.path.exists(os.path.join(main_stage, "activity-browser.exe")): + sys.exit("activity-browser exe missing from staging dir.") + + self.sign_files(os.path.join(main_stage, "*.exe")) + + def sign_files(self, pattern: str): + if self.signing_certificate is None: + return + for file in glob(pattern): + logger.info("Signing file: %s", file) + subprocess.check_call([ + os.path.join(WINDOWS_SDK_BIN, 'signtool'), + 'sign', + '/fd', + 'SHA256', + '/t', + 'http://timestamp.digicert.com', + '/f', + self.signing_certificate, + file + ]) + + def del_infodirs(self, dirname): + # Starting with 3.9.something there are some + # extra metadatadirs that have a hyphen in their + # file names. This is a forbidden character in WiX + # filenames so delete them. + for d in glob(os.path.join(dirname, "*-info")): + shutil.rmtree(d) + + def generate_files(self): + """ + Generate package files for MSI installer package + """ + self.root = ET.Element( + "Wix", + { + "xmlns": "http://wixtoolset.org/schemas/v4/wxs", + "xmlns:ui": "http://wixtoolset.org/schemas/v4/wxs/ui", + }, + ) + self.fragments(self.root) + + package = ET.SubElement( + self.root, + "Package", + { + "Name": self.product_name, + "UpgradeCode": self.update_guid, + "Manufacturer": self.manufacturer, + "Compressed": "yes", + "Version": self.version, + }, + ) + + ET.SubElement( + package, + "SummaryInformation", + { + "Keywords": "Installer", + "Description": "{} {} installer".format( + self.product_name, self.version + ), + "Manufacturer": self.manufacturer, + "Comments": "LCA Analysis", + }, + ) + + ET.SubElement( + package, + "Launch", + { + "Message": "This application is only supported on Windows 10 or higher.", + "Condition": "X" * len(WINVER_CHECK), + }, + ) + + ET.SubElement( + package, + "MajorUpgrade", + dict( + DowngradeErrorMessage="A newer version of Activity Browser is already installed.", + AllowDowngrades="no", + AllowSameVersionUpgrades="no", + ), + ) + + ET.SubElement( + package, + "Media", + { + "Id": "1", + "Cabinet": "activity-browser.cab", + "EmbedCab": "yes", + }, + ) + targetdir = ET.SubElement( + package, + "StandardDirectory ", + { + "Id": "ProgramFiles64Folder", + }, + ) + installdir = ET.SubElement( + targetdir, "Directory", {"Id": "INSTALLDIR", "Name": "Activity Browser"} + ) + ET.SubElement( + installdir, + "Merge", + { + "Id": "VCRedist", + "SourceFile": self.redist_path, + "DiskId": "1", + "Language": "0", + }, + ) + + ET.SubElement( + package, + "ui:WixUI", + { + "Id": "WixUI_FeatureTree", + }, + ) + for s_d in self.staging_dirs: + assert os.path.isdir(s_d) + top_feature = ET.SubElement( + package, + "Feature", + { + "Id": "Complete", + "Title": "Activity Browser " + self.version, + "Description": "The complete package", + "Display": "expand", + "Level": "1", + "ConfigurableDirectory": "INSTALLDIR", + }, + ) + ET.SubElement(top_feature, "ComponentRef", dict(Id="ABDesktopShortcut")) + for s_d in self.staging_dirs: + nodes = {} + for root, dirs, files in os.walk(s_d): + cur_node = Node(dirs, files) + nodes[root] = cur_node + self.create_xml(nodes, s_d, installdir, s_d) + self.build_features(top_feature, s_d) + vcredist_feature = ET.SubElement( + top_feature, + "Feature", + { + "Id": "VCRedist", + "Title": "Visual C++ runtime", + "AllowAdvertise": "no", + "Display": "hidden", + "Level": "1", + }, + ) + ET.SubElement(vcredist_feature, "MergeRef", {"Id": "VCRedist"}) + directory_ref = ET.SubElement( + package, "StandardDirectory", dict(Id="DesktopFolder") + ) + desktop_short_cut = ET.SubElement( + directory_ref, + "Component", + {"Id": "ABDesktopShortcut", "Guid": "*"}, + ) + shortcut = ET.SubElement( + desktop_short_cut, + "Shortcut", + dict( + Id="abDesktopShortCut", + Name="Activity Browser", + Description="Activity Browser", + Directory="DesktopFolder", + Target="[INSTALLDIR]activity-browser.exe", + WorkingDirectory="INSTALLDIR", + Icon="ABIcon", + ), + ) + shortcut2 = ET.SubElement( + desktop_short_cut, + "Shortcut", + dict( + Id="abProgramMenuShortCut", + Name="Activity Browser", + Description="Activity Browser", + Directory="ProgramMenuFolder", + Target="[INSTALLDIR]activity-browser.exe", + WorkingDirectory="INSTALLDIR", + Icon="ABIcon", + ), + ) + ET.SubElement( + desktop_short_cut, + "RemoveFolder", + dict(Id="cmdDesktopShortCut", On="uninstall"), + ) + ET.SubElement( + desktop_short_cut, + "RegistryValue", + dict( + Root="HKCU", + Name="installed", + Type="integer", + Value="1", + KeyPath="yes", + Key=r"Software\LCA-Activity Browser\Activity Browser", + ), + ) + ET.ElementTree(self.root).write( + self.main_xml, encoding="utf-8", xml_declaration=True + ) + # ElementTree can not do prettyprinting so do it manually + import xml.dom.minidom + + doc = xml.dom.minidom.parse(self.main_xml) + with open(self.main_xml, "w") as open_file: + open_file.write(doc.toprettyxml()) + + # One last fix, add CDATA. + with open(self.main_xml) as open_file: + data = open_file.read() + data = data.replace("X" * len(WINVER_CHECK), WINVER_CHECK) + with open(self.main_xml, "w") as open_file: + open_file.write(data) + + def fragments(self, root): + fragment = ET.SubElement(root, "Fragment") + ET.SubElement( + fragment, + "Icon", + { + "Id": "ABIcon", + "SourceFile": os.path.abspath( + r"activity_browser\static\icons\activity-browser.ico" + ), + }, + ) + + def build_features(self, top_feature, staging_dir): + """ + Generate build features + """ + feature = ET.SubElement( + top_feature, "Feature", self.feature_properties[staging_dir] + ) + for component_id in self.feature_components[staging_dir]: + ET.SubElement( + feature, + "ComponentRef", + { + "Id": component_id, + }, + ) + + def create_xml(self, nodes, current_dir, parent_xml_node, staging_dir): + """ + Create XML file + """ + cur_node = nodes[current_dir] + if cur_node.files: + component_id = "ApplicationFiles{}".format(self.component_num) + comp_xml_node = ET.SubElement( + parent_xml_node, + "Component", + { + "Id": component_id, + "Bitness": "always64", + "Guid": gen_guid(), + }, + ) + self.feature_components[staging_dir].append(component_id) + if self.component_num == 0: + ET.SubElement( + comp_xml_node, + "Environment", + { + "Id": "Environment", + "Name": "PATH", + "Part": "last", + "System": "yes", + "Action": "set", + "Value": "[INSTALLDIR]", + }, + ) + self.component_num += 1 + for f_node in cur_node.files: + file_id = ( + os.path.join(current_dir, f_node) + .replace("\\", "_") + .replace("#", "_") + .replace("-", "_") + .replace("+", "_")[:72] + ) + ET.SubElement( + comp_xml_node, + "File", + { + "Id": file_id, + "Name": f_node, + "Source": os.path.join(current_dir, f_node), + }, + ) + + for dirname in cur_node.dirs: + dir_id = ( + os.path.join(current_dir, dirname) + .replace("\\", "_") + .replace("/", "_") + .replace("-", "_") + ) + dir_node = ET.SubElement( + parent_xml_node, + "Directory", + { + "Id": dir_id, + "Name": dirname, + }, + ) + self.create_xml( + nodes, os.path.join(current_dir, dirname), dir_node, staging_dir + ) + + def build_package(self): + """ + Generate the MSI package. + """ + subprocess.check_call( + [ + "wix", + "build", + '-bindvariable', 'WixUILicenseRtf=LICENSE.rtf', + "-ext", + "WixToolset.UI.wixext", + "-culture", + "en-us", + "-arch", + "x64", + "-o", + self.final_output, + self.main_xml, + ] + ) + self.sign_files("*.msi") + + +def install_wix(): + try: + subprocess.check_output( + ["dotnet", "nuget", "add", "source", "https://api.nuget.org/v3/index.json"], + stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError as e: + if b'error: The source specified has already been added to the list of available package sources.' not in e.stdout: + raise e + subprocess.check_call(["dotnet", "tool", "install", "--global", "wix"]) + subprocess.check_call( + [ + "wix", + "extension", + "add", + "WixToolset.UI.wixext", + ] + ) + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--signing-certificate", help="local signing certificate (pfx format)") + parser.add_argument("--version", help="version that the msi will be") + options = parser.parse_args() + + if not os.path.exists("activity-browser.spec"): + sys.exit(print("Run me in the top level source dir.")) + if not shutil.which("wix"): + install_wix() + if not shutil.which("pyinstaller"): + subprocess.check_call(["pip", "install", "--upgrade", "pyinstaller"]) + + p = PackageGenerator(**options.__dict__) + p.build_dist() + p.generate_files() + p.build_package() diff --git a/scripts/hooks/hook-bw2io.py b/scripts/hooks/hook-bw2io.py new file mode 100644 index 000000000..55fe566eb --- /dev/null +++ b/scripts/hooks/hook-bw2io.py @@ -0,0 +1,28 @@ +import os + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +BW2IO_DIR = get_package_paths("bw2io")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("bw2io python package directory: %s" % BW2IO_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = [ + (os.path.join(BW2IO_DIR, "data"), "./bw2io/data"), +] + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_BW2IO_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-bw_migrations.py b/scripts/hooks/hook-bw_migrations.py new file mode 100644 index 000000000..09766f992 --- /dev/null +++ b/scripts/hooks/hook-bw_migrations.py @@ -0,0 +1,28 @@ +import os + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +BW_MIGRATIONS_DIR = get_package_paths("bw_migrations")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("bw_migrations python package directory: %s" % BW_MIGRATIONS_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = [ + (os.path.join(BW_MIGRATIONS_DIR, "data"), "./bw_migrations/data"), +] + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_BW_MIGRATIONS_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-ecoinvent_interface.py b/scripts/hooks/hook-ecoinvent_interface.py new file mode 100644 index 000000000..bd14eb81b --- /dev/null +++ b/scripts/hooks/hook-ecoinvent_interface.py @@ -0,0 +1,28 @@ +import os + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +ECOINVENT_INTERFACE_DIR = get_package_paths("ecoinvent_interface")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("ecoinvent_interface python package directory: %s" % ECOINVENT_INTERFACE_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = [ + (os.path.join(ECOINVENT_INTERFACE_DIR, "data"), "./ecoinvent_interface/data"), +] + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_ECOINVENT_INTERFACE_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-fontTools.py b/scripts/hooks/hook-fontTools.py new file mode 100644 index 000000000..d5a4bc991 --- /dev/null +++ b/scripts/hooks/hook-fontTools.py @@ -0,0 +1,48 @@ +import glob +import os +import re + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +FONT_TOOLS_DIR = get_package_paths("fontTools")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("fontTools python package directory: %s" % FONT_TOOLS_DIR) + + +def get_datas(): + """ + Data files for fontTools + + DATAS are in format: tuple(full_path, dest_subdir). + """ + ret = list() + + # Binaries, licenses and readmes in the fontTools/ directory + for filename in glob.glob(os.path.join(FONT_TOOLS_DIR, "*")): + # binaries and datas + if re.match('^.*\.(exe|dll|so|pak|dat|bin|txt)$', filename): + logger.info("Include playhouse data: %s" % filename) + ret.append((os.path.join(FONT_TOOLS_DIR, filename), + os.path.join("fontTools", os.path.dirname(filename).removeprefix(FONT_TOOLS_DIR)))) + + return ret + + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = get_datas() + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_FONTTOOLS_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-networkx.py b/scripts/hooks/hook-networkx.py new file mode 100644 index 000000000..21a8a8976 --- /dev/null +++ b/scripts/hooks/hook-networkx.py @@ -0,0 +1,28 @@ +import os + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +NETWORKX_DIR = get_package_paths("networkx")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("networkx python package directory: %s" % NETWORKX_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = [ + (os.path.join(NETWORKX_DIR, "generators"), "./networkx/generators"), +] + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_NETWORKX_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-playhouse.py b/scripts/hooks/hook-playhouse.py new file mode 100644 index 000000000..96aefe837 --- /dev/null +++ b/scripts/hooks/hook-playhouse.py @@ -0,0 +1,47 @@ +import os +import re + +from PyInstaller import log as logging +from PyInstaller.utils.hooks import get_package_paths + +PLAYHOUSE_DIR = get_package_paths("playhouse")[1] + +logger = logging.getLogger(__name__) + + +def get_datas(): + """ + Data files for playhouse + + DATAS are in format: tuple(full_path, dest_subdir). + """ + ret = list() + + # Binaries, licenses and readmes in the playhouse/ directory + for filename in os.listdir(PLAYHOUSE_DIR): + # binaries and datas + if re.match('^.*\.(exe|dll|so|pak|dat|bin|txt)$', filename): + logger.info("Include playhouse data: %s" % filename) + ret.append((os.path.join(PLAYHOUSE_DIR, filename), + os.path.join("playhouse"))) + + return ret + + +# Info +logger.info("playhouse python package directory: %s" % PLAYHOUSE_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = get_datas() + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_PLAYHOUSE_HOOK_SUCCEEDED"] = "1" diff --git a/scripts/hooks/hook-pyecospold.py b/scripts/hooks/hook-pyecospold.py new file mode 100644 index 000000000..cfdba705a --- /dev/null +++ b/scripts/hooks/hook-pyecospold.py @@ -0,0 +1,28 @@ +import os + +from PyInstaller.utils.hooks import get_package_paths +from PyInstaller import log as logging + +PYECOSPOLD_DIR = get_package_paths("pyecospold")[1] + +logger = logging.getLogger(__name__) + +# Info +logger.info("pyecospold python package directory: %s" % PYECOSPOLD_DIR) + +# Hidden imports. +hiddenimports = [] +# Excluded modules +excludedimports = [] + +# Include binaries +binaries = [] + +# Include datas +datas = [ + (os.path.join(PYECOSPOLD_DIR, "schemas"), "./pyecospold/schemas"), +] + +# Notify pyinstaller.spec code that this hook was executed +# and that it succeeded. +os.environ["PYINSTALLER_PYECOSPOLD_HOOK_SUCCEEDED"] = "1"