import asyncio import logging import os from functools import partial from importlib import import_module from inspect import getmembers from inspect import isclass from pathlib import Path from typing import Dict, Union, Optional, List from flask_sqlalchemy.model import DefaultMeta from quart import Blueprint, session from .helpers import _init_app_config from .protocols import Quart, ImpBlueprint from .registeries import ModelRegistry from .utilities import cast_to_import_str class Imp: app: Quart app_name: str app_path: Path app_folder: Path app_resources_imported: bool = False __model_registry__: ModelRegistry config_path: Path config: Dict def __init__( self, app: Optional[Quart] = None, app_config_file: Optional[str] = None, ignore_missing_env_variables: bool = False, ) -> None: if app is not None: self.init_app(app, app_config_file, ignore_missing_env_variables) def init_app( self, app: Quart, app_config_file: Optional[str] = os.environ.get("IMP_CONFIG"), ignore_missing_env_variables: bool = False, ) -> None: """ Initializes the quart app to work with quart-imp. :raw-html:`
` If no `app_config_file` specified, an attempt to read `IMP_CONFIG` from the environment will be made. :raw-html:`
` If `IMP_CONFIG` is not in the environment variables, an attempt to load `default.config.toml` will be made. :raw-html:`
` `default.config.toml` will be created, and used if not found. :raw-html:`
` ----- :param app: The quart app to initialize. :param app_config_file: The config file to use. :param ignore_missing_env_variables: Will ignore missing environment variables in the config if set to True. :return: None """ if app is None: raise ImportError( "No app was passed in, do ba = Imp(quartapp) or app.initapp(quartapp)" ) if not isinstance(app, Quart): raise TypeError("The app that was passed in is not an instance of Quart") if app_config_file is None: app_config_file = "default.config.toml" self.app = app if "imp" in self.app.extensions: raise ImportError("The app has already been initialized with quart-imp.") self.app_name = app.name self.app_path = Path(self.app.root_path) self.app_folder = self.app_path.parent self.config_path = self.app_path / app_config_file self.config = _init_app_config( self.config_path, ignore_missing_env_variables, self.app ) self.__model_registry__ = ModelRegistry() self.app.extensions["imp"] = self def import_app_resources( self, folder: str = "resources", factories: Optional[List] = None, static_folder: str = "static", templates_folder: str = "templates", files_to_import: Optional[List] = None, folders_to_import: Optional[List] = None, ) -> None: """ Import standard app resources from the specified folder. :raw-html:`
` This will import any resources that have been set to the Quart app. Routes, context processors, cli, etc. :raw-html:`
` **Can only be called once.** :raw-html:`
` If no static and or template folder is found, the static and or template folder will be set to None in the Quart app config. :raw-html:`
` **Small example of usage:** :raw-html:`
` .. code-block:: text imp.import_app_resources(folder="resources") # or imp.import_app_resources() # as the default folder is "resources" :raw-html:`
` This will import all files in the resources folder, and set the Quart app static and template folders to `resources/static` and `resources/templates` respectively. :raw-html:`
` --- `resources` folder structure --- .. code-block:: text app ├── resources │ ├── routes.py │ ├── app_fac.py │ ├── static │ │ └── css │ │ └── style.css │ └── templates │ └── index.html └── ... ... :raw-html:`
` --- `routes.py` file --- .. code-block:: from quart import current_app as app from quart import render_template @app.route("/") def index(): return render_template("index.html") :raw-html:`
` **How factories work** :raw-html:`
` Factories are functions that are called when importing the app resources. Here's an example: :raw-html:`
` .. code-block:: imp.import_app_resources(folder="resources", factories=["development_cli"]) :raw-html:`
` ["development_cli"] => development_cli(app) function will be called, and the current app will be passed in. :raw-html:`
` --- `app_fac.py` file --- .. code-block:: def development_cli(app): @app.cli.command("dev") def dev(): print("dev cli command") :raw-html:`
` **Scoping imports** :raw-html:`
` By default, all files and folders will be imported. To disable this, set files_to_import and or folders_to_import to [None]. :raw-html:`
` .. code-block:: imp.import_app_resources(files_to_import=[None], folders_to_import=[None]) :raw-html:`
` To scope the imports, set the files_to_import and or folders_to_import to a list of files and or folders. :raw-html:`
` files_to_import=["cli.py", "routes.py"] => will only import the files `resources/cli.py` and `resources/routes.py` :raw-html:`
` folders_to_import=["template_filters", "context_processors"] => will import all files in the folders `resources/template_filters/*.py` and `resources/context_processors/*.py` :raw-html:`
` ----- :param folder: The folder to import from, must be relative. :param factories: A list of function names to call with the app instance. :param static_folder: The name of the static folder (if not found will be set to None) :param templates_folder: The name of the templates folder (if not found will be set to None) :param files_to_import: A list of files to import e.g. ["cli.py", "routes.py"] set to ["*"] to import all. :param folders_to_import: A list of folders to import e.g. ["cli", "routes"] set to ["*"] to import all. :return: None """ async def run_async( imp: "Imp", folder: str = "resources", factories: Optional[List] = None, static_folder: str = "static", templates_folder: str = "templates", files_to_import: Optional[List] = None, folders_to_import: Optional[List] = None, ): async with imp.app.app_context(): if factories is None: factories = [] if files_to_import is None: files_to_import = ["*"] if folders_to_import is None: folders_to_import = ["*"] if self.app_resources_imported: raise ImportError("The app resources can only be imported once.") self.app_resources_imported = True def process_module(import_location: str) -> tuple: def gm(mf): return getmembers(mf) module_file = import_module(import_location) quart_instance = ( True if [name for name, value in gm(module_file) if isinstance(value, Quart)] else False ) return module_file, quart_instance resources_folder = self.app_path / folder app_static_folder = resources_folder / static_folder app_templates_folder = resources_folder / templates_folder if not resources_folder.exists(): raise ImportError( f"Cannot find resources collection folder at {resources_folder}" ) if not resources_folder.is_dir(): raise ImportError(f"Global collection must be a folder {resources_folder}") self.app.static_folder = ( app_static_folder.as_posix() if app_static_folder.exists() else None ) self.app.template_folder = ( app_templates_folder.as_posix() if app_templates_folder.exists() else None ) import_all_files = True if "*" in files_to_import else False import_all_folders = True if "*" in folders_to_import else False skip_folders = ( "static", "templates", ) for item in resources_folder.iterdir(): # iter over files and folders in the resources folder if item.is_file() and item.suffix == ".py": # only pull in python files if not import_all_files: # if import_all_files is False, only import the files in the list if item.name not in files_to_import: continue file_module = import_module(cast_to_import_str(self.app_name, item)) for instance_factory in factories: if hasattr(file_module, instance_factory): getattr(file_module, instance_factory)(self.app) if item.is_dir(): # item is a folder if item.name in skip_folders: # skip the static and templates folders continue if not import_all_folders: # if import_all_folders is False, only import the folders in the list if item.name not in folders_to_import: continue for py_file in item.glob("*.py"): dir_module = import_module( f"{cast_to_import_str(self.app_name, item)}.{py_file.stem}" ) for instance_factory in factories: if hasattr(dir_module, instance_factory): getattr(dir_module, instance_factory)(self.app) pfunc = partial( run_async, self, folder=folder, factories=factories, static_folder=static_folder, templates_folder=templates_folder, files_to_import=files_to_import, folders_to_import=folders_to_import, ) asyncio.run(pfunc()) def init_session(self) -> None: """ Initialize the session variables found in the config. Commonly used in `app.before_request`. :raw-html:`
` .. code-block:: @app.before_request def before_request(): imp.init_session() :raw-html:`
` ----- :return: None """ if self.config.get("SESSION"): for key, value in self.config.get("SESSION", {}).items(): if key not in session: session[key] = value def import_blueprint(self, blueprint: str) -> None: """ Import a specified Quart-Imp or standard Quart Blueprint. :raw-html:`
` **Must be setup in a Python package** :raw-html:`
` **Example of a Quart-Imp Blueprint:** :raw-html:`
` Will look for a config.toml file in the blueprint folder. :raw-html:`
` --- Folder structure --- .. code-block:: text app ├── my_blueprint │ ├── routes │ │ └── index.py │ ├── static │ │ └── css │ │ └── style.css │ ├── templates │ │ └── my_blueprint │ │ └── index.html │ ├── __init__.py │ └── config.toml └── ... :raw-html:`
` --- __init__.py --- .. code-block:: from quart_imp import Blueprint bp = Blueprint(__name__) bp.import_resources("routes") @bp.beforeapp_request def beforeapp_request(): bp.init_session() :raw-html:`
` --- config.toml --- .. code-block:: enabled = "yes" [settings] url_prefix = "/my-blueprint" #subdomain = "" #url_defaults = { } #static_folder = "static" #template_folder = "templates" #static_url_path = "/my-blueprint/static" #root_path = "" #cli_group = "" [session] session_values_used_by_blueprint = "will be set by bp.init_session()" :raw-html:`
` **Example of a standard Quart Blueprint:** :raw-html:`
` --- Folder structure --- .. code-block:: text app ├── my_blueprint │ ├── ... │ └── __init__.py └── ... :raw-html:`
` --- __init__.py --- .. code-block:: from quart import Blueprint bp = Blueprint("my_blueprint", __name__, url_prefix="/my-blueprint") @bp.route("/") def index(): return "regular_blueprint" :raw-html:`
` ----- :param blueprint: The blueprint (folder name) to import. Must be relative. :return: None """ if Path(blueprint).is_absolute(): potential_bp = Path(blueprint) else: potential_bp = Path(self.app_path / blueprint) if potential_bp.exists() and potential_bp.is_dir(): try: module = import_module(cast_to_import_str(self.app_name, potential_bp)) for name, value in getmembers(module): if isinstance(value, Blueprint) or isinstance(value, ImpBlueprint): if hasattr(value, "_setup_imp_blueprint"): if getattr(value, "enabled", False): value._setup_imp_blueprint(self) self.app.register_blueprint(value) else: logging.debug(f"Blueprint {name} is disabled") else: self.app.register_blueprint(value) except Exception as e: raise ImportError(f"Error when importing {potential_bp.name}: {e}") def import_blueprints(self, folder: str) -> None: """ Imports all the blueprints in the given folder. :raw-html:`
` **Example folder structure:** :raw-html:`
` .. code-block:: text app ├── blueprints │ ├── regular_blueprint │ │ ├── ... │ │ └── __init__.py │ └── quart_imp_blueprint │ ├── ... │ ├── config.toml │ └── __init__.py └── ... :raw-html:`
` See: `import_blueprint` for more information. :raw-html:`
` ----- :param folder: The folder to import from. Must be relative. """ folder_path = Path(self.app_path / folder) for potential_bp in folder_path.iterdir(): self.import_blueprint(potential_bp.as_posix()) def import_models(self, file_or_folder: str) -> None: """ Imports all the models from the given file or folder. :raw-html:`
` **Each model found will be added to the model registry.** See: `Imp.model()` for more information. :raw-html:`
` **Example usage from files:** :raw-html:`
` .. code-block:: imp.import_models("users.py") imp.import_models("cars.py") :raw-html:`
` -- Folder structure -- .. code-block:: app ├── ... ├── users.py ├── cars.py ├── default.config.toml └── __init__.py :raw-html:`
` **Example usage from folders:** :raw-html:`
` .. code-block:: imp.import_models("models") :raw-html:`
` -- Folder structure -- .. code-block:: app ├── ... ├── models │ ├── users.py │ └── cars.py ├── default.config.toml └── __init__.py :raw-html:`
` **Example of model file:** :raw-html:`
` -- users.py -- .. code-block:: from app.extensions import db class User(db.Model): attribute = db.Column(db.String(255)) :raw-html:`
` ----- :param file_or_folder: The file or folder to import from. Must be relative. :return: None """ def model_processor(path: Path): """ Picks apart the model from_file and builds a registry of the models found. """ import_string = cast_to_import_str(self.app_name, path) try: model_module = import_module(import_string) for name, value in getmembers(model_module, isclass): if hasattr(value, "__tablename__"): self.__model_registry__.add(name, value) except ImportError as e: raise ImportError(f"Error when importing {import_string}: {e}") if Path(file_or_folder).is_absolute(): file_or_folder_path = Path(file_or_folder) else: file_or_folder_path = Path(self.app_path / file_or_folder) if file_or_folder_path.is_file() and file_or_folder_path.suffix == ".py": model_processor(file_or_folder_path) elif file_or_folder_path.is_dir(): for model_file in [ _ for _ in file_or_folder_path.iterdir() if "__" not in _.name ]: model_processor(model_file) def model(self, class_: str) -> DefaultMeta: """ Returns the model class for the given ORM class name. :raw-html:`
` This is used to omit the need to import the models from their locations. :raw-html:`
` **For example, this:** :raw-html:`
` .. code-block:: from app.models.user import User from app.models.cars import Cars :raw-html:`
` **Can be replaced with:** :raw-html:`
` .. code-block:: from app.extensions import imp User = imp.model("User") Cars = imp.model("Cars") :raw-html:`
` imp.model("User") -> :raw-html:`
` Although this method is convenient, you lose out on an IDE's ability of attribute and method suggestions due to the type being unknown. :raw-html:`
` ----- :param class_: The class name of the model to return. :return: The model class [DefaultMeta]. """ return self.__model_registry__.class_(class_) def model_meta(self, class_: Union[str, DefaultMeta]) -> dict: """ Returns meta information for the given ORM class name :raw-html:`
` **Example:** :raw-html:`
` .. code-block:: from app.extensions import imp User = imp.model("User") print(imp.model_meta(User)) # or print(imp.model_meta("User")) :raw-html:`
` Will output: {"location": "app.models.user", "table_name": "user"} :raw-html:`
` **Advanced use case:** `location` can be used to import a function from the model file using Pythons importlib. :raw-html:`
` Here's an example: :raw-html:`
` .. code-block:: from app.extensions import imp users_meta = imp.model_meta("User") users_module = import_module(users_meta["location"]) users_module.some_function() :raw-html:`
` `table_name` is the snake_case version of the class name, pulled from `__table_name__`, which can be useful if you'd like to use the table name in a raw query in a route. :raw-html:`
` ----- :param class_: The class name of the model to return [Class Instance | Name of class as String]. :return: dict of meta-information. """ def check_for_table_name(model_): if not hasattr(model_, "__tablename__"): raise AttributeError(f"{model_} is not a valid model") if isinstance(class_, str): model = self.__model_registry__.class_(class_) check_for_table_name(model) return { "location": model.__module__, "table_name": model.__tablename__, } return { "location": class_.__module__, "table_name": class_.__tablename__, }