diff --git a/fbs/__init__.py b/fbs/__init__.py index d65c1f3..6d351fe 100644 --- a/fbs/__init__.py +++ b/fbs/__init__.py @@ -1,9 +1,10 @@ -from fbs import _state, _defaults -from fbs._settings import load_settings, expand_placeholders +from fbs import _state from fbs._state import LOADED_PROFILES -from fbs_runtime import platform, FbsError -from fbs_runtime.platform import is_ubuntu, is_linux, is_arch_linux, is_fedora -from os.path import normpath, join, exists, abspath +from fbs_runtime import FbsError, _source +from fbs_runtime._fbs import get_core_settings, get_default_profiles +from fbs_runtime._settings import load_settings, expand_placeholders +from fbs_runtime._source import get_settings_paths +from os.path import abspath """ fbs populates SETTINGS with the current build settings. A typical example is @@ -16,8 +17,8 @@ def init(project_dir): Call this if you are invoking neither `fbs` on the command line nor fbs.cmdline.main() from Python. """ - SETTINGS['project_dir'] = abspath(project_dir) - for profile in _get_default_profiles(): + SETTINGS.update(get_core_settings(abspath(project_dir))) + for profile in get_default_profiles(): activate_profile(profile) def activate_profile(profile_name): @@ -29,13 +30,10 @@ def activate_profile(profile_name): production server URL instead of a staging server. """ LOADED_PROFILES.append(profile_name) - json_paths = [ - path_fn('src/build/settings/%s.json' % profile) - for path_fn in (_defaults.path, path) - for profile in LOADED_PROFILES - ] - base_settings = {'project_dir': SETTINGS['project_dir']} - SETTINGS.update(load_settings(filter(exists, json_paths), base_settings)) + project_dir = SETTINGS['project_dir'] + json_paths = get_settings_paths(project_dir, LOADED_PROFILES) + core_settings = get_core_settings(project_dir) + SETTINGS.update(load_settings(json_paths, core_settings)) def path(path_str): """ @@ -51,20 +49,4 @@ def path(path_str): error_message = "Cannot call path(...) until fbs.init(...) has been " \ "called." raise FbsError(error_message) from None - return normpath(join(project_dir, *path_str.split('/'))) - -def _get_default_profiles(): - yield 'base' - # The "secret" profile lets the user store sensitive settings such as - # passwords in src/build/settings/secret.json. When using Git, the user can - # exploit this by adding secret.json to .gitignore, thus preventing it from - # being uploaded to services such as GitHub. - yield 'secret' - yield platform.name().lower() - if is_linux(): - if is_ubuntu(): - yield 'ubuntu' - elif is_arch_linux(): - yield 'arch' - elif is_fedora(): - yield 'fedora' \ No newline at end of file + return _source.path(project_dir, path_str) \ No newline at end of file diff --git a/fbs/_defaults/__init__.py b/fbs/_defaults/__init__.py deleted file mode 100644 index 11f2bfc..0000000 --- a/fbs/_defaults/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from os.path import join, dirname - -def path(path_str): - return join(dirname(__file__), *path_str.split('/')) \ No newline at end of file diff --git a/fbs/_defaults/src/build/settings/base.json b/fbs/_defaults/src/build/settings/base.json index f0cf937..a76f65d 100644 --- a/fbs/_defaults/src/build/settings/base.json +++ b/fbs/_defaults/src/build/settings/base.json @@ -11,6 +11,7 @@ "src/build/docker/fedora/.rpmmacros" ], "hidden_imports": [], + "public_settings": ["app_name", "author", "version"], "docker_images": { "ubuntu": { "build_files": ["requirements/", "src/sign/linux/"], diff --git a/fbs/_defaults/src/build/settings/linux.json b/fbs/_defaults/src/build/settings/linux.json index 701875f..4eeb6d3 100644 --- a/fbs/_defaults/src/build/settings/linux.json +++ b/fbs/_defaults/src/build/settings/linux.json @@ -6,5 +6,6 @@ "depends": [], "files_to_filter": [ "src/installer/linux/usr/share/applications/AppName.desktop" - ] + ], + "public_settings": ["categories", "description", "author_email", "url"] } \ No newline at end of file diff --git a/fbs/builtin_commands/_docker.py b/fbs/builtin_commands/_docker.py index 1a1a4ad..f9382f3 100644 --- a/fbs/builtin_commands/_docker.py +++ b/fbs/builtin_commands/_docker.py @@ -1,8 +1,9 @@ -from fbs import path, SETTINGS, _defaults +from fbs import path, SETTINGS from fbs.builtin_commands import require_existing_project from fbs.cmdline import command from fbs.resources import _copy from fbs_runtime import FbsError +from fbs_runtime._source import default_path from os import listdir from os.path import exists from shutil import rmtree @@ -24,7 +25,7 @@ def buildvm(name): if exists(build_dir): rmtree(build_dir) src_root = 'src/build/docker' - available_vms = set(listdir(_defaults.path(src_root))) + available_vms = set(listdir(default_path(src_root))) if exists(path(src_root)): available_vms.update(listdir(path(src_root))) if name not in available_vms: @@ -33,10 +34,10 @@ def buildvm(name): (name, ''.join(['\n * ' + vm for vm in available_vms])) ) src_dir = src_root + '/' + name - for path_fn in _defaults.path, path: + for path_fn in default_path, path: _copy(path_fn, src_dir, build_dir) settings = SETTINGS['docker_images'].get(name, {}) - for path_fn in _defaults.path, path: + for path_fn in default_path, path: for p in settings.get('build_files', []): _copy(path_fn, p, build_dir) args = ['build', '--pull', '-t', _get_docker_id(name), build_dir] diff --git a/fbs/freeze/__init__.py b/fbs/freeze/__init__.py index d5bcf5b..db6cacb 100644 --- a/fbs/freeze/__init__.py +++ b/fbs/freeze/__init__.py @@ -1,11 +1,16 @@ -from fbs import path, SETTINGS, _defaults +from fbs import path, SETTINGS from fbs._state import LOADED_PROFILES from fbs.resources import _copy +from fbs_runtime._fbs import get_public_settings +from fbs_runtime._source import default_path from fbs_runtime.platform import is_mac from os import rename from os.path import join from pathlib import PurePath from subprocess import run +from tempfile import TemporaryDirectory + +import fbs_runtime._frozen def run_pyinstaller(extra_args=None, debug=False): if extra_args is None: @@ -33,7 +38,9 @@ def run_pyinstaller(extra_args=None, debug=False): ]) if debug: args.append('--debug') - run(args, check=True) + with _PyInstallerRuntimehook() as hook_path: + args.extend(['--runtime-hook', hook_path]) + run(args, check=True) output_dir = path('target/' + app_name + ('.app' if is_mac() else '')) freeze_dir = path('${freeze_dir}') # In most cases, rename(src, dst) silently "works" when src == dst. But on @@ -41,6 +48,24 @@ def run_pyinstaller(extra_args=None, debug=False): if PurePath(output_dir) != PurePath(freeze_dir): rename(output_dir, freeze_dir) +class _PyInstallerRuntimehook: + def __init__(self): + self._tmp_dir = TemporaryDirectory() + def __enter__(self): + module = fbs_runtime._frozen + hook_path = join(self._tmp_dir.name, 'fbs_pyinstaller_hook.py') + with open(hook_path, 'w') as f: + # Inject public settings such as "version" into the binary, so + # they're available at run time: + f.write('\n'.join([ + 'import importlib', + 'module = importlib.import_module(%r)' % module.__name__, + 'module.PUBLIC_SETTINGS = %r' % get_public_settings(SETTINGS) + ])) + return hook_path + def __exit__(self, *_): + self._tmp_dir.cleanup() + def _generate_resources(): """ Copy the data files from src/main/resources to ${freeze_dir}. @@ -53,7 +78,7 @@ def _generate_resources(): resources_dest_dir = join(freeze_dir, 'Contents', 'Resources') else: resources_dest_dir = freeze_dir - for path_fn in _defaults.path, path: + for path_fn in default_path, path: for profile in LOADED_PROFILES: _copy(path_fn, 'src/main/resources/' + profile, resources_dest_dir) _copy(path_fn, 'src/freeze/' + profile, freeze_dir) \ No newline at end of file diff --git a/fbs/installer/__init__.py b/fbs/installer/__init__.py index 3016a48..9b6c86d 100644 --- a/fbs/installer/__init__.py +++ b/fbs/installer/__init__.py @@ -1,7 +1,8 @@ -from fbs import _defaults, path, LOADED_PROFILES +from fbs import path, LOADED_PROFILES from fbs.resources import _copy +from fbs_runtime._source import default_path def _generate_installer_resources(): - for path_fn in _defaults.path, path: + for path_fn in default_path, path: for profile in LOADED_PROFILES: _copy(path_fn, 'src/installer/' + profile, path('target/installer')) \ No newline at end of file diff --git a/fbs/repo/fedora.py b/fbs/repo/fedora.py index 40f9b02..8fc766d 100644 --- a/fbs/repo/fedora.py +++ b/fbs/repo/fedora.py @@ -1,5 +1,6 @@ -from fbs import path, _defaults +from fbs import path from fbs.resources import copy_with_filtering +from fbs_runtime._source import default_path from os import makedirs, rename from os.path import exists from shutil import rmtree, copy @@ -14,7 +15,7 @@ def create_repo_fedora(): repo_file = path('src/repo/fedora/${app_name}.repo') use_default = not exists(repo_file) if use_default: - repo_file = _defaults.path('src/repo/fedora/AppName.repo') + repo_file = default_path('src/repo/fedora/AppName.repo') copy_with_filtering( repo_file, path('target/repo'), files_to_filter=[repo_file] ) diff --git a/fbs/repo/ubuntu.py b/fbs/repo/ubuntu.py index 0f7a0d7..19ea153 100644 --- a/fbs/repo/ubuntu.py +++ b/fbs/repo/ubuntu.py @@ -1,6 +1,7 @@ -from fbs import path, _defaults +from fbs import path from fbs._gpg import preset_gpg_passphrase from fbs.resources import copy_with_filtering +from fbs_runtime._source import default_path from os import makedirs from os.path import exists from shutil import rmtree @@ -17,7 +18,7 @@ def create_repo_ubuntu(): distr_file = 'src/repo/ubuntu/distributions' distr_path = path(distr_file) if not exists(distr_path): - distr_path = _defaults.path(distr_file) + distr_path = default_path(distr_file) copy_with_filtering(distr_path, tmp_dir, files_to_filter=[distr_path]) preset_gpg_passphrase() check_call([ diff --git a/fbs_runtime/_fbs.py b/fbs_runtime/_fbs.py index 3e88a63..cdbb80b 100644 --- a/fbs_runtime/_fbs.py +++ b/fbs_runtime/_fbs.py @@ -1,47 +1,27 @@ -from fbs_runtime import platform, FbsError -from fbs_runtime.platform import is_mac -from os.path import join, exists, realpath, dirname, pardir -from pathlib import PurePath +from fbs_runtime import platform +from fbs_runtime.platform import is_ubuntu, is_linux, is_arch_linux, is_fedora -import errno -import inspect -import os -import sys +def get_core_settings(project_dir): + return { + 'project_dir': project_dir + } -class ResourceLocator: - def __init__(self, resource_dirs): - self._dirs = resource_dirs - def locate(self, *rel_path): - for resource_dir in self._dirs: - resource_path = join(resource_dir, *rel_path) - if exists(resource_path): - return realpath(resource_path) - raise FileNotFoundError( - errno.ENOENT, 'Could not locate resource', os.sep.join(rel_path) - ) +def get_default_profiles(): + result = ['base'] + # The "secret" profile lets the user store sensitive settings such as + # passwords in src/build/settings/secret.json. When using Git, the user can + # exploit this by adding secret.json to .gitignore, thus preventing it from + # being uploaded to services such as GitHub. + result.append('secret') + result.append(platform.name().lower()) + if is_linux(): + if is_ubuntu(): + result.append('ubuntu') + elif is_arch_linux(): + result.append('arch') + elif is_fedora(): + result.append('fedora') + return result -def get_resource_dirs_frozen(): - app_dir = dirname(sys.executable) - return [join(app_dir, pardir, 'Resources') if is_mac() else app_dir] - -def get_resource_dirs_source(appctxt_cls): - project_dir = _get_project_base_dir(appctxt_cls) - resources_dir = join(project_dir, 'src', 'main', 'resources') - return [ - join(resources_dir, platform.name().lower()), - join(resources_dir, 'base'), - join(project_dir, 'src', 'main', 'icons') - ] - -def _get_project_base_dir(appctxt_cls): - class_file = inspect.getfile(appctxt_cls) - p = PurePath(class_file) - while p != p.parent: - parent_names = [p.parents[2].name, p.parents[1].name, p.parent.name] - if parent_names == ['src', 'main', 'python']: - return str(p.parents[3]) - p = p.parent - raise FbsError( - 'Could not determine project base directory for %s. Is it in ' - 'src/main/python?' % appctxt_cls - ) \ No newline at end of file +def get_public_settings(settings): + return {k: settings[k] for k in settings['public_settings']} \ No newline at end of file diff --git a/fbs_runtime/_frozen.py b/fbs_runtime/_frozen.py new file mode 100644 index 0000000..cf29190 --- /dev/null +++ b/fbs_runtime/_frozen.py @@ -0,0 +1,20 @@ +""" +This module contains functions that should only be called when running the +frozen form of the app. +""" + +from os.path import dirname, join, pardir +from fbs_runtime.platform import is_mac + +import sys + +# The contents of this dictionary are injected via a PyInstaller runtime hook. +# See: `fbs.freeze._PyInstallerRuntimehook`. +PUBLIC_SETTINGS = {} + +def get_resource_dirs(): + app_dir = dirname(sys.executable) + return [join(app_dir, pardir, 'Resources') if is_mac() else app_dir] + +def load_public_settings(): + return PUBLIC_SETTINGS \ No newline at end of file diff --git a/fbs_runtime/_resources.py b/fbs_runtime/_resources.py new file mode 100644 index 0000000..fc7ccb3 --- /dev/null +++ b/fbs_runtime/_resources.py @@ -0,0 +1,16 @@ +from os.path import join, exists, realpath + +import errno +import os + +class ResourceLocator: + def __init__(self, resource_dirs): + self._dirs = resource_dirs + def locate(self, *rel_path): + for resource_dir in self._dirs: + resource_path = join(resource_dir, *rel_path) + if exists(resource_path): + return realpath(resource_path) + raise FileNotFoundError( + errno.ENOENT, 'Could not locate resource', os.sep.join(rel_path) + ) \ No newline at end of file diff --git a/fbs/_settings.py b/fbs_runtime/_settings.py similarity index 100% rename from fbs/_settings.py rename to fbs_runtime/_settings.py diff --git a/fbs_runtime/_source.py b/fbs_runtime/_source.py new file mode 100644 index 0000000..0fc65ac --- /dev/null +++ b/fbs_runtime/_source.py @@ -0,0 +1,53 @@ +""" +This module contains functions that should only be called by module `fbs`, or +when running from source. +""" + +from fbs_runtime import FbsError +from fbs_runtime._fbs import get_default_profiles, get_core_settings, \ + get_public_settings +from fbs_runtime._settings import load_settings +from os.path import join, normpath, dirname, pardir, exists +from pathlib import PurePath + +import inspect + +def get_project_dir(appctxt_cls): + class_file = inspect.getfile(appctxt_cls) + p = PurePath(class_file) + while p != p.parent: + parent_names = [p.parents[2].name, p.parents[1].name, p.parent.name] + if parent_names == ['src', 'main', 'python']: + return str(p.parents[3]) + p = p.parent + raise FbsError( + 'Could not determine project base directory for %s. Is it in ' + 'src/main/python?' % appctxt_cls + ) + +def get_resource_dirs(project_dir): + resources = path(project_dir, 'src/main/resources') + result = [join(resources, profile) for profile in get_default_profiles()] + result.append(path(project_dir, 'src/main/icons')) + return result + +def load_public_settings(project_dir): + core_settings = get_core_settings(project_dir) + profiles = get_default_profiles() + json_paths = get_settings_paths(project_dir, profiles) + all_settings = load_settings(json_paths, core_settings) + return get_public_settings(all_settings) + +def get_settings_paths(project_dir, profiles): + return list(filter(exists, ( + path_fn('src/build/settings/%s.json' % profile) + for path_fn in (default_path, lambda p: path(project_dir, p)) + for profile in profiles + ))) + +def default_path(path_str): + defaults_dir = join(dirname(__file__), pardir, 'fbs', '_defaults') + return path(defaults_dir, path_str) + +def path(base_dir, path_str): + return normpath(join(base_dir, *path_str.split('/'))) \ No newline at end of file diff --git a/fbs_runtime/application_context.py b/fbs_runtime/application_context.py index 00970b4..1379b6b 100644 --- a/fbs_runtime/application_context.py +++ b/fbs_runtime/application_context.py @@ -1,6 +1,5 @@ -from fbs_runtime import _state -from fbs_runtime._fbs import ResourceLocator, get_resource_dirs_frozen, \ - get_resource_dirs_source +from fbs_runtime import _state, _frozen, _source +from fbs_runtime._resources import ResourceLocator from fbs_runtime._signal import SignalWakeupHandler from fbs_runtime.excepthook import Excepthook from fbs_runtime.platform import is_windows, is_mac @@ -69,6 +68,15 @@ def excepthook(self): Just return an object with a .install() method. """ return Excepthook() + @cached_property + def public_settings(self): + """ + This dictionary contains the values of the settings listed in setting + "public_settings". Eg. `self.public_settings['version']`. + """ + if is_frozen(): + return _frozen.load_public_settings() + return _source.load_public_settings(self._project_dir) def get_resource(self, *rel_path): """ Return the absolute path to the data file with the given name or @@ -80,10 +88,14 @@ def get_resource(self, *rel_path): @cached_property def _resource_locator(self): if is_frozen(): - resource_dirs = get_resource_dirs_frozen() + resource_dirs = _frozen.get_resource_dirs() else: - resource_dirs = get_resource_dirs_source(self.__class__) + resource_dirs = _source.get_resource_dirs(self._project_dir) return ResourceLocator(resource_dirs) + @cached_property + def _project_dir(self): + assert not is_frozen(), 'Only available when running from source' + return _source.get_project_dir(self.__class__) def run(self): raise NotImplementedError() diff --git a/setup.py b/setup.py index 5b45c9f..32a23c2 100644 --- a/setup.py +++ b/setup.py @@ -30,8 +30,7 @@ def _get_package_data(pkg_dir, data_subdir): url='https://build-system.fman.io', packages=find_packages(exclude=('tests', 'tests.*')), package_data={ - 'fbs._defaults': _get_package_data('fbs/_defaults', 'requirements') + - _get_package_data('fbs/_defaults', 'src'), + 'fbs': _get_package_data('fbs', '_defaults'), 'fbs.builtin_commands': _get_package_data('fbs/builtin_commands', 'project_template'), 'fbs.builtin_commands._gpg': diff --git a/tests/test_fbs/test_settings.py b/tests/test_fbs/test_settings.py new file mode 100644 index 0000000..7067e88 --- /dev/null +++ b/tests/test_fbs/test_settings.py @@ -0,0 +1,28 @@ +from fbs import SETTINGS +from tests.test_fbs import FbsTest + +class LinuxSettingsTest(FbsTest): + def test_default_does_not_overwrite(self): + # Consider the following scenario: The user sets "url" in base.json + # instead of the usual linux.json. If we loaded settings in the + # following order: + # 1) default base + # 2) user base + # 3) default linux + # 4) user linux + # Then 3) would overwrite 2) and thus the user's "url" setting. + # This test ensures that the load order is instead: + # 1) default base + # 2) default linux + # 3) user base + # 4) user linux. + self._update_settings('base.json', {'url': 'build-system.fman.io'}) + + # The project template's linux.json sets url="". This defeats the + # purpose of this test. So delete the setting: + linux_settings = self._read_settings('linux.json') + del linux_settings['url'] + self._write_settings('linux.json', linux_settings) + + self.init_fbs('Linux') + self.assertEqual('build-system.fman.io', SETTINGS['url']) \ No newline at end of file diff --git a/tests/test_fbs_runtime/__init__.py b/tests/test_fbs_runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_fbs/test__settings.py b/tests/test_fbs_runtime/test__settings.py similarity index 69% rename from tests/test_fbs/test__settings.py rename to tests/test_fbs_runtime/test__settings.py index 8f1936c..fbfade5 100644 --- a/tests/test_fbs/test__settings.py +++ b/tests/test_fbs_runtime/test__settings.py @@ -1,38 +1,11 @@ -from fbs import load_settings, SETTINGS +from fbs_runtime._settings import load_settings from os import listdir from os.path import join from tempfile import TemporaryDirectory -from tests.test_fbs import FbsTest from unittest import TestCase import json -class LinuxSettingsTest(FbsTest): - def test_default_does_not_overwrite(self): - # Consider the following scenario: The user sets "url" in base.json - # instead of the usual linux.json. If we loaded settings in the - # following order: - # 1) default base - # 2) user base - # 3) default linux - # 4) user linux - # Then 3) would overwrite 2) and thus the user's "url" setting. - # This test ensures that the load order is instead: - # 1) default base - # 2) default linux - # 3) user base - # 4) user linux. - self._update_settings('base.json', {'url': 'build-system.fman.io'}) - - # The project template's linux.json sets url="". This defeats the - # purpose of this test. So delete the setting: - linux_settings = self._read_settings('linux.json') - del linux_settings['url'] - self._write_settings('linux.json', linux_settings) - - self.init_fbs('Linux') - self.assertEqual('build-system.fman.io', SETTINGS['url']) - class LoadSettingsTest(TestCase): def test_empty(self): self._check({})