From b15692d7f52b69c069c460bdac47ccf0c8bb3fdd Mon Sep 17 00:00:00 2001 From: Marco Rebhan Date: Thu, 2 Jan 2025 21:35:49 +0100 Subject: [PATCH] Add application bundle support --- mesonbuild/ast/introspection.py | 10 +- mesonbuild/backend/ninjabackend.py | 69 +++++++++- mesonbuild/build.py | 138 +++++++++++++++++++ mesonbuild/envconfig.py | 2 +- mesonbuild/interpreter/interpreter.py | 20 ++- mesonbuild/interpreter/interpreterobjects.py | 3 + mesonbuild/interpreter/type_checking.py | 17 ++- mesonbuild/mesonmain.py | 3 +- mesonbuild/rewriter.py | 2 +- mesonbuild/scripts/merge_plist.py | 68 +++++++++ 10 files changed, 322 insertions(+), 10 deletions(-) create mode 100644 mesonbuild/scripts/merge_plist.py diff --git a/mesonbuild/ast/introspection.py b/mesonbuild/ast/introspection.py index 6bc6286f24f3..0349bd9feb8c 100644 --- a/mesonbuild/ast/introspection.py +++ b/mesonbuild/ast/introspection.py @@ -12,7 +12,7 @@ from .. import compilers, environment, mesonlib, optinterpreter, options from .. import coredata as cdata -from ..build import Executable, Jar, SharedLibrary, SharedModule, StaticLibrary +from ..build import Executable, Jar, SharedLibrary, SharedModule, StaticLibrary, AppBundle from ..compilers import detect_compiler_for from ..interpreterbase import InvalidArguments, SubProject from ..mesonlib import MachineChoice @@ -29,7 +29,7 @@ # TODO: it would be nice to not have to duplicate this BUILD_TARGET_FUNCTIONS = [ 'executable', 'jar', 'library', 'shared_library', 'shared_module', - 'static_library', 'both_libraries' + 'static_library', 'both_libraries', 'app_bundle' ] class IntrospectionHelper: @@ -83,6 +83,7 @@ def __init__(self, 'shared_module': self.func_shared_module, 'static_library': self.func_static_lib, 'both_libraries': self.func_both_lib, + 'app_bundle': self.func_app_bundle, }) def func_project(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Dict[str, TYPE_var]) -> None: @@ -342,6 +343,9 @@ def func_library(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Dict[st def func_jar(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Dict[str, TYPE_var]) -> T.Optional[T.Dict[str, T.Any]]: return self.build_target(node, args, kwargs, Jar) + def func_app_bundle(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Dict[str, TYPE_var]) -> T.Optional[T.Dict[str, T.Any]]: + return self.build_target(node, args, kwargs, AppBundle) + def func_build_target(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Dict[str, TYPE_var]) -> T.Optional[T.Dict[str, T.Any]]: if 'target_type' not in kwargs: return None @@ -360,6 +364,8 @@ def func_build_target(self, node: BaseNode, args: T.List[TYPE_var], kwargs: T.Di return self.build_library(node, args, kwargs) elif target_type == 'jar': return self.build_target(node, args, kwargs, Jar) + elif target_type == 'app_bundle': + return self.build_target(node, args, kwargs, AppBundle) return None def is_subproject(self) -> bool: diff --git a/mesonbuild/backend/ninjabackend.py b/mesonbuild/backend/ninjabackend.py index 70122c3794bc..5524730484f5 100644 --- a/mesonbuild/backend/ninjabackend.py +++ b/mesonbuild/backend/ninjabackend.py @@ -14,6 +14,7 @@ import json import os import pickle +import plistlib import re import subprocess import typing as T @@ -1103,6 +1104,9 @@ def generate_target(self, target) -> None: elem = NinjaBuildElement(self.all_outputs, linker.get_archive_name(outname), 'AIX_LINKER', [outname]) self.add_build(elem) + if isinstance(target, build.AppBundle): + self.generate_bundle_target(target, elem) + def should_use_dyndeps_for_target(self, target: 'build.BuildTarget') -> bool: if not self.ninja_has_dyndeps: return False @@ -1377,6 +1381,8 @@ def generate_rules(self) -> None: extra='restat = 1')) self.add_rule(NinjaRule('COPY_FILE', self.environment.get_build_command() + ['--internal', 'copy'], ['$in', '$out'], 'Copying $in to $out')) + self.add_rule(NinjaRule('MERGE_PLIST', self.environment.get_build_command() + ['--internal', 'merge-plist'], + ['$out', '$in'], 'Merging plist $inputs to $out')) c = self.environment.get_build_command() + \ ['--internal', @@ -1485,6 +1491,51 @@ def generate_jar_target(self, target: build.Jar) -> None: # Create introspection information self.create_target_source_introspection(target, compiler, compile_args, src_list, gen_src_list) + def generate_bundle_target(self, target: build.AppBundle, bin_elem: NinjaBuildElement): + main_binary = target.get_filename() + bundle = f'{main_binary}.app' + bundle_rel = os.path.join(self.get_target_dir(target), bundle) + + presets_path = os.path.join(self.get_target_private_dir(target), 'Presets', 'Info.plist') + presets_fullpath = os.path.join(self.environment.get_build_dir(), presets_path) + os.makedirs(os.path.dirname(presets_fullpath), exist_ok=True) + + with open(presets_fullpath, 'wb') as fp: + dict = { + 'CFBundleExecutable': main_binary, + 'CFBundleInfoDictionaryVersion': '6.0', + 'CFBundlePackageType': 'APPL', + 'NSPrincipalClass': target.default_principal_class, + } + plistlib.dump(dict, fp) + + presets_file = File(True, *os.path.split(presets_path)) + + if target.info_plist: + info_plist = self.generate_merge_plist(target, 'Info.plist', [presets_file, target.info_plist]) + else: + info_plist = presets_file + + layout = build.StructuredSources() + layout.sources[os.path.join(*target.bin_root())] += [target] + layout.sources[os.path.join(*target.info_root())] += [info_plist] + + if target.bundle_resources is not None: + for k, v in target.bundle_resources.sources.items(): + layout.sources[os.path.join(*target.resources_root(), k)] += v + + if target.bundle_contents is not None: + for k, v in target.bundle_contents.sources.items(): + layout.sources[os.path.join(*target.contents_root(), k)] += v + + if target.bundle_extra_binaries is not None: + for k, v in target.bundle_extra_binaries.sources.items(): + layout.sources[os.path.join(*target.bin_root(), k)] += v + + elem = NinjaBuildElement(self.all_outputs, bundle_rel, 'phony', []) + elem.add_orderdep(self.__generate_sources_structure(Path(bundle_rel), layout)[0]) + self.add_build(elem) + def generate_cs_resource_tasks(self, target) -> T.Tuple[T.List[str], T.List[str]]: args = [] deps = [] @@ -1863,7 +1914,7 @@ def generate_cython_transpile(self, target: build.BuildTarget) -> \ def _generate_copy_target(self, src: FileOrString, output: Path) -> None: """Create a target to copy a source file from one location to another.""" if isinstance(src, File): - instr = src.absolute_path(self.environment.source_dir, self.environment.build_dir) + instr = src.rel_to_builddir(self.build_to_src) else: instr = src elem = NinjaBuildElement(self.all_outputs, [str(output)], 'COPY_FILE', [instr]) @@ -3301,6 +3352,18 @@ def generate_shsym(self, target) -> None: elem.add_item('CROSS', '--cross-host=' + self.environment.machines[target.for_machine].system) self.add_build(elem) + def generate_merge_plist(self, target: build.BuildTarget, out_name: str, in_files: T.List[File]) -> File: + out_path = os.path.join(self.get_target_private_dir(target), out_name) + elem = NinjaBuildElement( + self.all_outputs, + out_path, + 'MERGE_PLIST', + [f.rel_to_builddir(self.build_to_src) for f in in_files], + ) + self.add_build(elem) + + return File(True, self.get_target_private_dir(target), out_name) + def get_import_filename(self, target) -> str: return os.path.join(self.get_target_dir(target), target.import_filename) @@ -3808,6 +3871,10 @@ def generate_ending(self) -> None: if self.environment.machines[t.for_machine].is_aix(): linker, stdlib_args = t.get_clink_dynamic_linker_and_stdlibs() t.get_outputs()[0] = linker.get_archive_name(t.get_outputs()[0]) + + if isinstance(t, build.AppBundle): + targetlist.append(os.path.join(self.get_target_dir(t), t.get_outputs()[0] + '.app')) + targetlist.append(os.path.join(self.get_target_dir(t), t.get_outputs()[0])) elem = NinjaBuildElement(self.all_outputs, targ, 'phony', targetlist) diff --git a/mesonbuild/build.py b/mesonbuild/build.py index 5d35e0833943..8a7c18b9ff5d 100644 --- a/mesonbuild/build.py +++ b/mesonbuild/build.py @@ -116,6 +116,7 @@ class DFeatures(TypedDict): known_shmod_kwargs = known_build_target_kwargs | {'vs_module_defs', 'rust_abi'} known_stlib_kwargs = known_build_target_kwargs | {'pic', 'prelink', 'rust_abi'} known_jar_kwargs = known_exe_kwargs | {'main_class', 'java_resources'} +known_nsapp_kwargs = known_exe_kwargs | {'bundle_layout', 'bundle_resources', 'bundle_contents', 'bundle_extra_binaries', 'bundle_exe_dir_name', 'info_plist'} def _process_install_tag(install_tag: T.Optional[T.List[T.Optional[str]]], num_outputs: int) -> T.List[T.Optional[str]]: @@ -3003,6 +3004,143 @@ def get_classpath_args(self): def get_default_install_dir(self) -> T.Union[T.Tuple[str, str], T.Tuple[None, None]]: return self.environment.get_jar_dir(), '{jardir}' +class AppBundle(Executable): + known_kwargs = known_nsapp_kwargs + + typename = 'bundle' + + # As in _CFBundleGetPlatformExecutablesSubdirectoryName (CoreFoundation) + _exe_dir_names_by_system = { + 'darwin': 'MacOS', + 'windows': 'Windows', + 'sunos': 'Solaris', + # _: 'HPUX', + 'cygwin': 'Cygwin', + 'linux': 'Linux', + 'freebsd': 'FreeBSD', + 'emscripten': 'WASI', + } + + def __init__( + self, + name: str, + subdir: str, + subproject: SubProject, + for_machine: MachineChoice, + sources: T.List['SourceOutputs'], + structured_sources: T.Optional[StructuredSources], + objects: T.List[ObjectTypes], + environment: environment.Environment, + compilers: T.Dict[str, 'Compiler'], + kwargs, + ): + self.bundle_resources: T.Optional[StructuredSources] = kwargs['bundle_resources'] + self.bundle_contents: T.Optional[StructuredSources] = kwargs['bundle_contents'] + self.bundle_extra_binaries: T.Optional[T.List[BuildTargetTypes]] = kwargs['bundle_extra_binaries'] + + super().__init__( + name, + subdir, + subproject, + for_machine, + sources, + structured_sources, + objects, + environment, + compilers, + kwargs, + ) + + if self.export_dynamic: + raise InvalidArguments('export_dynamic is not supported for application bundle targets.') + + m = self.environment.machines[self.for_machine] + + bundle_layout = kwargs['bundle_layout'] + + if bundle_layout is None: + if m.is_darwin() and m.subsystem != 'macos': + # iOS and derivatives use the flat bundle style. + bundle_layout = 'flat' + else: + bundle_layout = 'contents' + + self.bundle_layout = bundle_layout + + exe_dir_name = kwargs['bundle_exe_dir_name'] + + if exe_dir_name is None: + exe_dir_name = AppBundle._exe_dir_names_by_system.get(m.system, '') + + self.exe_dir_name = exe_dir_name + + if m.is_darwin() and m.subsystem != 'macos': + self.default_principal_class = 'UIApplication' + else: + self.default_principal_class = 'NSApplication' + + info_plist = kwargs['info_plist'] + + if isinstance(info_plist, str): + if os.path.isabs(info_plist): + info_plist = File.from_absolute_file(info_plist) + else: + info_plist = File.from_source_file(self.environment.source_dir, self.subdir, info_plist) + elif isinstance(info_plist, File): + # When passing a generated file. + pass + elif isinstance(info_plist, (CustomTarget, CustomTargetIndex)): + # When passing output of a Custom Target + info_plist = File.from_built_file(info_plist.get_subdir(), info_plist.get_filename()) + else: + raise InvalidArguments( + 'info_plist must be either a string, ' + 'a file object, a Custom Target, or a Custom Target Index') + + self.info_plist: T.Optional[File] = info_plist + + # Names for these slightly named after their CoreFoundation names. + # 'oldstyle' is bundles with the executable in the top directory and a + # Resources folder, as used in Framework bundles and GNUstep, + # 'contents' is the standard macOS # application bundle layout with + # everything under a Contents directory, 'flat' is the iOS-style bundle + # with every file in the root directory of the bundle. + if self.bundle_layout not in ['oldstyle', 'contents', 'flat']: + raise InvalidArguments('bundle_layout must be one of \'oldstyle\', \'contents\', or \'flat\'.') + + def type_suffix(self): + return "@app" + + def contents_root(self) -> str: + return { + 'oldstyle': [], + 'contents': ['Contents'], + 'flat': [], + }[self.bundle_layout] + + def bin_root(self) -> T.List[str]: + return { + 'oldstyle': [], + 'contents': [x for x in ['Contents', self.exe_dir_name] if x], + 'flat': [], + }[self.bundle_layout] + + def resources_root(self) -> T.List[str]: + return { + 'oldstyle': ['Resources'], + 'contents': ['Contents', 'Resources'], + 'flat': [], + }[self.bundle_layout] + + def info_root(self) -> T.List[str]: + return { + # The only case where Info.plist is in the Resources directory. + 'oldstyle': ['Resources'], + 'contents': ['Contents'], + 'flat': [], + }[self.bundle_layout] + + @dataclass(eq=False) class CustomTargetIndex(CustomTargetBase, HoldableObject): diff --git a/mesonbuild/envconfig.py b/mesonbuild/envconfig.py index 4055b21761c5..d99198ad90ae 100644 --- a/mesonbuild/envconfig.py +++ b/mesonbuild/envconfig.py @@ -313,7 +313,7 @@ def is_darwin(self) -> bool: """ Machine is Darwin (iOS/tvOS/OS X)? """ - return self.system in {'darwin', 'ios', 'tvos'} + return self.system == 'darwin' def is_android(self) -> bool: """ diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py index 02a59e3986d5..6442715da1e5 100644 --- a/mesonbuild/interpreter/interpreter.py +++ b/mesonbuild/interpreter/interpreter.py @@ -81,6 +81,7 @@ INSTALL_TAG_KW, LANGUAGE_KW, NATIVE_KW, + NSAPP_KWS, PRESERVE_PATH_KW, REQUIRED_KW, SHARED_LIB_KWS, @@ -221,6 +222,7 @@ def dump_value(self, arr, list_sep, indent): known_library_kwargs | build.known_exe_kwargs | build.known_jar_kwargs | + build.known_nsapp_kwargs | {'target_type'} ) @@ -345,6 +347,7 @@ def build_func_dict(self) -> None: 'add_project_link_arguments': self.func_add_project_link_arguments, 'add_test_setup': self.func_add_test_setup, 'alias_target': self.func_alias_target, + 'app_bundle': self.func_app_bundle, 'assert': self.func_assert, 'benchmark': self.func_benchmark, 'both_libraries': self.func_both_lib, @@ -427,6 +430,7 @@ def build_holder_map(self) -> None: build.SharedModule: OBJ.SharedModuleHolder, build.Executable: OBJ.ExecutableHolder, build.Jar: OBJ.JarHolder, + build.AppBundle: OBJ.AppBundleHolder, build.CustomTarget: OBJ.CustomTargetHolder, build.CustomTargetIndex: OBJ.CustomTargetIndexHolder, build.Generator: OBJ.GeneratorHolder, @@ -1903,6 +1907,14 @@ def func_jar(self, node: mparser.BaseNode, kwargs: kwtypes.Jar) -> build.Jar: return self.build_target(node, args, kwargs, build.Jar) + @permittedKwargs(build.known_nsapp_kwargs) + @typed_pos_args('app_bundle', str, varargs=SOURCES_VARARGS) + @typed_kwargs('app_bundle', *NSAPP_KWS, allow_unknown=True) + def func_app_bundle( + self, node: mparser.BaseNode, args: T.Tuple[str, SourcesVarargsType], kwargs: kwtypes.BuildTarget + ) -> build.AppBundle: + return self.build_target(node, args, kwargs, build.AppBundle) + @FeatureNewKwargs('build_target', '0.40.0', ['link_whole', 'override_options']) @permittedKwargs(known_build_target_kwargs) @typed_pos_args('build_target', str, varargs=SOURCES_VARARGS) @@ -1911,7 +1923,7 @@ def func_build_target(self, node: mparser.BaseNode, args: T.Tuple[str, SourcesVarargsType], kwargs: kwtypes.BuildTarget ) -> T.Union[build.Executable, build.StaticLibrary, build.SharedLibrary, - build.SharedModule, build.BothLibraries, build.Jar]: + build.SharedModule, build.BothLibraries, build.Jar, build.AppBundle]: target_type = kwargs['target_type'] if target_type == 'executable': @@ -1926,6 +1938,8 @@ def func_build_target(self, node: mparser.BaseNode, return self.build_both_libraries(node, args, kwargs) elif target_type == 'library': return self.build_library(node, args, kwargs) + elif target_type == 'app_bundle': + return self.build_target(node, args, kwargs, build.AppBundle) return self.build_target(node, args, kwargs, build.Jar) @noPosargs @@ -3404,7 +3418,7 @@ def build_target(self, node: mparser.BaseNode, args: T.Tuple[str, SourcesVarargs kwargs['dependencies'] = extract_as_list(kwargs, 'dependencies') kwargs['extra_files'] = self.source_strings_to_files(kwargs['extra_files']) self.check_sources_exist(os.path.join(self.source_root, self.subdir), sources) - if targetclass not in {build.Executable, build.SharedLibrary, build.SharedModule, build.StaticLibrary, build.Jar}: + if targetclass not in {build.Executable, build.SharedLibrary, build.SharedModule, build.StaticLibrary, build.Jar, build.AppBundle}: mlog.debug('Unknown target type:', str(targetclass)) raise RuntimeError('Unreachable code') self.__process_language_args(kwargs) @@ -3458,7 +3472,7 @@ def build_target(self, node: mparser.BaseNode, args: T.Tuple[str, SourcesVarargs kwargs['include_directories'] = self.extract_incdirs(kwargs) - if targetclass is build.Executable: + if targetclass is build.Executable or targetclass is build.AppBundle: kwargs = T.cast('kwtypes.Executable', kwargs) if kwargs['gui_app'] is not None: if kwargs['win_subsystem'] is not None: diff --git a/mesonbuild/interpreter/interpreterobjects.py b/mesonbuild/interpreter/interpreterobjects.py index f4a2b4107ed3..bc487c8af5f6 100644 --- a/mesonbuild/interpreter/interpreterobjects.py +++ b/mesonbuild/interpreter/interpreterobjects.py @@ -1032,6 +1032,9 @@ class SharedModuleHolder(BuildTargetHolder[build.SharedModule]): class JarHolder(BuildTargetHolder[build.Jar]): pass +class AppBundleHolder(BuildTargetHolder[build.AppBundle]): + pass + class CustomTargetIndexHolder(ObjectHolder[build.CustomTargetIndex]): def __init__(self, target: build.CustomTargetIndex, interp: 'Interpreter'): super().__init__(target, interp) diff --git a/mesonbuild/interpreter/type_checking.py b/mesonbuild/interpreter/type_checking.py index ed34be950065..c264e71979ee 100644 --- a/mesonbuild/interpreter/type_checking.py +++ b/mesonbuild/interpreter/type_checking.py @@ -792,6 +792,20 @@ def _convert_darwin_versions(val: T.List[T.Union[str, int]]) -> T.Optional[T.Tup for a in _LANGUAGE_KWS], ] +_EXCLUSIVE_NSAPP_KWS: T.List[KwargInfo] = [ + KwargInfo('bundle_layout', (str, NoneType)), + KwargInfo('bundle_resources', (StructuredSources, NoneType)), + KwargInfo('bundle_contents', (StructuredSources, NoneType)), + KwargInfo('bundle_extra_binaries', (StructuredSources, NoneType)), + KwargInfo('bundle_exe_dir_name', (StructuredSources, NoneType)), + KwargInfo('info_plist', (str, File, CustomTarget, CustomTargetIndex, NoneType)), +] + +NSAPP_KWS: T.List[KwargInfo] = [ + *EXECUTABLE_KWS, + *_EXCLUSIVE_NSAPP_KWS, +] + _SHARED_STATIC_ARGS: T.List[KwargInfo[T.List[str]]] = [ *[l.evolve(name=l.name.replace('_', '_static_'), since='1.3.0') for l in _LANGUAGE_KWS], @@ -818,6 +832,7 @@ def _convert_darwin_versions(val: T.List[T.Union[str, int]]) -> T.Optional[T.Tup *_EXCLUSIVE_SHARED_MOD_KWS, *_EXCLUSIVE_STATIC_LIB_KWS, *_EXCLUSIVE_EXECUTABLE_KWS, + *_EXCLUSIVE_NSAPP_KWS, *_SHARED_STATIC_ARGS, *[a.evolve(deprecated='1.3.0', deprecated_message='The use of "jar" in "build_target()" is deprecated, and this argument is only used by jar()') for a in _EXCLUSIVE_JAR_KWS], @@ -827,7 +842,7 @@ def _convert_darwin_versions(val: T.List[T.Union[str, int]]) -> T.Optional[T.Tup required=True, validator=in_set_validator({ 'executable', 'shared_library', 'static_library', 'shared_module', - 'both_libraries', 'library', 'jar' + 'both_libraries', 'library', 'jar', 'app_bundle' }), since_values={ 'shared_module': '0.51.0', diff --git a/mesonbuild/mesonmain.py b/mesonbuild/mesonmain.py index 2c1ca97a386f..7ed8130498b6 100644 --- a/mesonbuild/mesonmain.py +++ b/mesonbuild/mesonmain.py @@ -209,7 +209,8 @@ def run_script_command(script_name: str, script_args: T.List[str]) -> int: 'delsuffix': 'delwithsuffix', 'gtkdoc': 'gtkdochelper', 'hotdoc': 'hotdochelper', - 'regencheck': 'regen_checker'} + 'regencheck': 'regen_checker', + 'merge-plist': 'merge_plist'} module_name = script_map.get(script_name, script_name) try: diff --git a/mesonbuild/rewriter.py b/mesonbuild/rewriter.py index 919bd3847b13..ab7c33b5b07a 100644 --- a/mesonbuild/rewriter.py +++ b/mesonbuild/rewriter.py @@ -317,7 +317,7 @@ def supported_element_nodes(cls): 'operation': (str, None, ['src_add', 'src_rm', 'target_rm', 'target_add', 'extra_files_add', 'extra_files_rm', 'info']), 'sources': (list, [], None), 'subdir': (str, '', None), - 'target_type': (str, 'executable', ['both_libraries', 'executable', 'jar', 'library', 'shared_library', 'shared_module', 'static_library']), + 'target_type': (str, 'executable', ['both_libraries', 'executable', 'jar', 'library', 'shared_library', 'shared_module', 'static_library', 'app_bundle']), } } diff --git a/mesonbuild/scripts/merge_plist.py b/mesonbuild/scripts/merge_plist.py new file mode 100644 index 000000000000..5345668f0153 --- /dev/null +++ b/mesonbuild/scripts/merge_plist.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: 2025 Marco Rebhan + +from __future__ import annotations + +""" +Helper script to merge two plist files. +""" + +import plistlib +import typing as T + + +def run(args: T.List[str]) -> int: + if len(args) < 1: + return 1 + + [out, *inputs] = args + + data = dict() + + for path in inputs: + try: + fp = open(path, 'rb') + except OSError as e: + print(f'merge-plist: cannot open \'{path}\': {e.strerror}') + return 1 + + with fp: + try: + new_data = plistlib.load(fp) + except plistlib.InvalidFileException as e: + print(f'merge-plist: cannot parse \'{path}\': {e}') + return 1 + except OSError as e: + print(f'merge-plist: cannot read \'{path}\': {e}') + return 1 + + data = merge(data, new_data) + + try: + fp = open(out, 'wb') + except OSError as e: + print(f'merge-plist: cannot create \'{out}\': {e.strerror}') + return 1 + + with fp: + try: + plistlib.dump(data, fp) + except OSError as e: + print(f'merge-plist: cannot write \'{path}\': {e}') + return 1 + + +def merge(prev: T.Any, next: T.Any) -> T.Any: + if isinstance(prev, dict) and isinstance(next, dict): + out = prev.copy() + + for k, v in next.items(): + if k in out: + out[k] = merge(out[k], v) + else: + out[k] = v + return out + elif isinstance(prev, list) and isinstance(next, list): + return prev + next + else: + return next