# 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
import textwrap
from efrotools.project import getprojectconfig
from efrotools.util import replace_section
if TYPE_CHECKING:
from typing import Any
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."""
out = existing_data
info = f'# This section generated by {__name__}; do not edit.'
# Generate feature-set app subsystem imports
out = _generate_subsystem_imports(out, feature_sets, info)
# Generate feature-set app subsystem properties
out = _generate_subsystem_properties(out, feature_sets, info)
# Generate app subsystem creation code
out = _generate_subsystem_creation(out, feature_sets, info)
# Generate default app-mode selection logic
out = _generate_app_mode_selection(out, projroot, feature_sets, info)
# Generate get_convenience_imports method
out = _generate_get_convenience_imports(out, projroot, feature_sets, info)
# Generate testable app modes (currently disabled)
out = _generate_testable_app_modes(out, projroot, feature_sets, info)
# 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 _generate_subsystem_imports(
out: str, feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate import statements for feature-set app subsystems."""
indent = ' '
# Import modules we need for feature-set subsystems.
contents = ''
for _fsname, fset in sorted(feature_sets.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,
)
return out
def _generate_subsystem_properties(
out: str, feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate property methods for feature-set app subsystems."""
# 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 feature_sets.values():
for softreq in fset.soft_requirements:
if softreq not in feature_sets:
missing_soft_fset_names.add(softreq)
all_fset_names = missing_soft_fset_names | feature_sets.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 = feature_sets[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" balog.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'
)
indent = ' '
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,
)
return out
def _generate_subsystem_creation(
out: str, feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate code to create app subsystems in the proper order."""
# Generate code to create app subsystems in the proper order.
all_ss_fsets = {
fsetname: fset
for fsetname, fset in feature_sets.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,
)
return out
def _generate_app_mode_selection(
out: str, projroot: str, feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate default app-mode selection logic."""
# 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,
)
return out
def _generate_get_convenience_imports(
out: str, projroot: str, feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate get_convenience_imports() method body."""
default_imports: dict[str, str | None] | None = getprojectconfig(
projroot
).get('default_convenience_imports')
if default_imports is None:
default_imports = {}
# Filter imports to only include those from available feature sets
available_imports = {}
for module_name, alias in default_imports.items():
for fset in feature_sets.values():
if module_name.startswith(fset.name_python_package):
available_imports[module_name] = alias
break
if available_imports:
contents = 'return {\n'
for module_name, alias in available_imports.items():
if alias is None:
contents += f' \'{module_name}\': None,\n'
else:
contents += f' \'{module_name}\': \'{alias}\',\n'
contents += '}\n'
else:
contents = 'return {}\n'
indent = ' '
out = replace_section(
out,
f'{indent}# __GET_DEFAULT_CONVENIENCE_IMPORTS_BEGIN__\n',
f'{indent}# __GET_DEFAULT_CONVENIENCE_IMPORTS_END__\n',
textwrap.indent(f'{info}\n\n{contents}\n', indent),
keep_markers=True,
)
return out
def _generate_testable_app_modes(
out: str, projroot: str, _feature_sets: dict[str, FeatureSet], info: str
) -> str:
"""Generate testable app modes (currently disabled)."""
# Disabling this for now; will probably remove permanently. Testable
# app mode discovery now uses meta discovery system.
if bool(False):
# Get default app modes for reference
default_app_modes: list[str] | None = getprojectconfig(projroot).get(
'default_app_modes'
)
if not isinstance(default_app_modes, list):
default_app_modes = []
def _module_for_app_mode(amode: str) -> str:
return '.'.join(amode.split('.')[:-1])
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 += ']'
indent = ' '
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)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum