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__,
}