Source code for batools.appmodule

# Released under the MIT License. See LICENSE for details.
#
"""Generates parts of babase._app.py.

This includes things like subsystem attributes for all feature-sets that
want them and default app-intent handling.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from batools.featureset import FeatureSet


[docs] def generate_app_module( projroot: str, feature_sets: dict[str, FeatureSet], existing_data: str ) -> str: """Generate babase._app.py based on its existing version.""" # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements import textwrap from efrotools.util import replace_section from efrotools.project import getprojectconfig out = '' fsets = feature_sets out = existing_data info = f'# This section generated by {__name__}; do not edit.' indent = ' ' # Import modules we need for feature-set subsystems. contents = '' for _fsname, fset in sorted(fsets.items()): if fset.has_python_app_subsystem: modname = fset.name_python_package classname = f'{fset.name_camel}AppSubsystem' contents += f'from {modname} import {classname}\n' out = replace_section( out, f'{indent}# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__\n', f'{indent}# __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__\n', textwrap.indent( f'{info}\n\n{contents}\n' if contents else f'{info}\n', indent ), keep_markers=True, ) # Calc which feature-sets are soft-required by any of the ones here # but not included here. For those we'll expose app-subsystems that # always return None. missing_soft_fset_names = set[str]() for fset in fsets.values(): for softreq in fset.soft_requirements: if softreq not in fsets: missing_soft_fset_names.add(softreq) all_fset_names = missing_soft_fset_names | fsets.keys() # Add properties to instantiate feature-set subsystems. contents = '' for fsetname in sorted(all_fset_names): if fsetname in missing_soft_fset_names: contents += ( f'\n' f'@property\n' f'def {fsetname}(self) -> Any | None:\n' f' """Our {fsetname} subsystem (not available' f' in this project)."""\n' f'\n' f' return None\n' ) else: fset = fsets[fsetname] if fset.has_python_app_subsystem: modname = fset.name_python_package classname = f'{fset.name_camel}AppSubsystem' # If they are allowed as a soft requirement, *everyone* # has to access them as TYPE | None. Originally I planned to # add the '| None' *only* if another present feature set was # soft referencing them, but it turns out that code tuned # to expect TYPE hits a lot of 'code will never be executed' # errors in the type checker if we switch it to 'TYPE | None' # so we need to be consistent. if fset.allow_as_soft_requirement: contents += ( f'\n' f'@property\n' f'def {fset.name}(self) -> {classname} | None:\n' f' """Our {fset.name} subsystem (if available)."""\n' f' return self._get_subsystem_property(\n' f" '{fset.name}', " f'self._create_{fset.name}_subsystem\n' f' ) # type: ignore\n' f'\n' f'@staticmethod\n' f'def _create_{fset.name}_subsystem()' f' -> {classname} | None:\n' f' # pylint: disable=cyclic-import\n' f' try:\n' f' from {modname} import {classname}\n' f'\n' f' return {classname}()\n' f' except ImportError:\n' f' return None\n' f' except Exception:\n' f" logging.exception('Error importing" f" {modname}.')\n" f' return None\n' ) else: contents += ( f'\n' '@property\n' f'def {fset.name}(self) -> {classname}:\n' f' """Our {fset.name} subsystem' ' (always available)."""\n' f' return self._get_subsystem_property(\n' f" '{fset.name}', " f'self._create_{fset.name}_subsystem\n' f' ) # type: ignore\n' f'\n' f'@staticmethod\n' f'def _create_{fset.name}_subsystem()' f' -> {classname}:\n' f' # pylint: disable=cyclic-import\n' f'\n' f' from {modname} import {classname}\n' f'\n' f' return {classname}()\n' ) out = replace_section( out, f'{indent}# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__\n', f'{indent}# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__\n', textwrap.indent(f'{info}\n{contents}\n', indent), keep_markers=True, ) # Generate code to create app subsystems in the proper order. all_ss_fsets = { fsetname: fset for fsetname, fset in fsets.items() if fset.has_python_app_subsystem } init_order: list[str] = [] for fsetname, fset in sorted(all_ss_fsets.items()): _add_init(fset, all_ss_fsets, init_order, 0) contents = '# Poke these attrs to create all our subsystems.\n' + ''.join( f'_ = self.{fsetname}\n' for fsetname in init_order ) indent = ' ' out = replace_section( out, f'{indent}# __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__\n', f'{indent}# __FEATURESET_APP_SUBSYSTEM_CREATE_END__\n', textwrap.indent(f'{info}\n\n{contents}\n', indent), keep_markers=True, ) # Generate default app-mode-selection logic. # TODO - make this customizable via project settings or whatnot. default_app_modes: list[str] | None = getprojectconfig(projroot).get( 'default_app_modes' ) if not isinstance(default_app_modes, list) or not all( isinstance(x, str) for x in default_app_modes ): raise RuntimeError( 'Could not load default_app_modes from projectconfig' ) def _module_for_app_mode(amode: str) -> str: return '.'.join(amode.split('.')[:-1]) def _is_valid_app_mode(amode: str) -> bool: # Consider the app mode valid if it comes from a Python # package provided by one of our feature-sets. module = _module_for_app_mode(amode) for featureset in feature_sets.values(): if featureset.name_python_package == module: return True return False default_app_modes = [m for m in default_app_modes if _is_valid_app_mode(m)] contents = ( '# Ask our default app modes to handle it.\n' "# (generated from 'default_app_modes' in projectconfig).\n" ) if not default_app_modes: raise RuntimeError('No valid default_app_modes specified.') for mode in default_app_modes: contents += f'import {_module_for_app_mode(mode)}\n' contents += '\n' contents += 'for appmode in [\n' for mode in default_app_modes: contents += f' {mode},\n' contents += ( ']:\n' ' if appmode.can_handle_intent(intent):\n' ' return appmode\n' '\n' ) contents += 'return None\n' indent = ' ' out = replace_section( out, f'{indent}# __DEFAULT_APP_MODE_SELECTION_BEGIN__\n', f'{indent}# __DEFAULT_APP_MODE_SELECTION_END__\n', textwrap.indent(f'{info}\n\n{contents}\n', indent), keep_markers=True, ) # Disabling this for now; will probably remove permanently. Testable # app mode discovery now uses meta discovery system. if bool(False): contents = ( '# Return all our default_app_modes as testable.\n' "# (generated from 'default_app_modes' in projectconfig).\n" ) for mode in default_app_modes: contents += f'import {_module_for_app_mode(mode)}\n' contents += '\n' contents += 'return [\n' for mode in default_app_modes: contents += f' {mode},\n' contents += ']' out = replace_section( out, f'{indent}# __DEFAULT_TESTABLE_APP_MODES_BEGIN__\n', f'{indent}# __DEFAULT_TESTABLE_APP_MODES_END__\n', textwrap.indent(f'{info}\n\n{contents}\n', indent), keep_markers=True, ) # Note: we *should* format this string, but because this code # runs with every project update I'm just gonna try to keep the # formatting correct manually for now to save a bit of time. # (project update time jumps from 0.3 to 0.5 seconds if I enable # formatting here for just this one file). return out
def _add_init( fset: FeatureSet, allsets: dict[str, FeatureSet], init_order: list[str], depth: int, ) -> None: # If we hit max recursion, we've got a dependency cycle. if depth > 10: raise RuntimeError( 'App subsystem dependency cycle detected' f" (involving feature set '{fset.name}')." ) # If this one is already added, we're done. if fset.name in init_order: return # First add all of its dependencies. for depname in sorted(fset.python_app_subsystem_dependencies): depfset = allsets.get(depname) # Only matters if this is in the actual set of featuresets. if depfset is None: continue _add_init(depfset, allsets, init_order, depth + 1) # We should not have been added via the above code (dependency cycle # should have been detected in that case). assert fset.name not in init_order # Finally add the fset itself. init_order.append(fset.name)