Skip to content

Commit

Permalink
Merge pull request #2 from ExpressApp/feature/sqlalchemy
Browse files Browse the repository at this point in the history
Feature/sqlalchemy
  • Loading branch information
vonabarak authored Oct 18, 2021
2 parents e0e5390 + e2765e8 commit 398945a
Show file tree
Hide file tree
Showing 32 changed files with 498 additions and 874 deletions.
1 change: 1 addition & 0 deletions boxv2/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"""Module as an executable magic file."""
from boxv2.main import main

main()
14 changes: 9 additions & 5 deletions boxv2/application.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
"""Functions to make bot application."""

import itertools
import os
from typing import Any, Callable, List, Optional
from typing import Callable, Optional

from botx import Bot, Collector
from fastapi import FastAPI
import itertools

from boxv2.endpoints import get_router
from boxv2.plugin import get_plugin_by_path
from boxv2.settings import BaseAppSettings
Expand All @@ -19,9 +20,11 @@ def get_application(settings: Optional[BaseAppSettings] = None) -> FastAPI:

application = FastAPI(title=settings.NAME)
plugin_classes = [get_plugin_by_path(plugin) for plugin in settings.PLUGINS]
dependencies = list(itertools.chain.from_iterable(
[plugin_class.dependencies for plugin_class in plugin_classes]
))
dependencies = list(
itertools.chain.from_iterable(
[plugin_class.dependencies for plugin_class in plugin_classes]
)
)

bot = Bot( # type: ignore
bot_accounts=settings.BOT_CREDENTIALS,
Expand All @@ -38,6 +41,7 @@ def get_application(settings: Optional[BaseAppSettings] = None) -> FastAPI:
application.add_event_handler("shutdown", bot_shutdown(bot))

for plugin in plugin_instances:
setattr(application.state, plugin.get_name(), plugin)
application.add_event_handler("startup", plugin.on_startup)
application.add_event_handler("shutdown", plugin.on_shutdown)

Expand Down
18 changes: 9 additions & 9 deletions boxv2/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
import sys
from pathlib import Path
from tempfile import mkdtemp
from typing import Optional
from typing import Any, Optional

import click
from cookiecutter.exceptions import CookiecutterException, RepositoryNotFound
from cookiecutter.exceptions import CookiecutterException # type: ignore
from cookiecutter.exceptions import RepositoryNotFound
from cookiecutter.main import cookiecutter # type: ignore
from loguru import logger

Expand Down Expand Up @@ -37,7 +38,7 @@ def main(
render_plugins_template(plg, extra_context)


def render_plugins_template(plugin: str, extra_content: dict[str, str]) -> None:
def render_plugins_template(plugin: str, extra_content: dict[str, Any]) -> None:
"""Render plugin's template (if exists) and add it to project."""
temp_dir = Path(mkdtemp())

Expand All @@ -62,7 +63,7 @@ def render_plugins_template(plugin: str, extra_content: dict[str, str]) -> None:
shutil.rmtree(temp_dir)


def render_extra_template(template: str, extra_content: dict[str, str]) -> None:
def render_extra_template(template: str, extra_content: dict[str, Any]) -> None:
"""Render template to overwrite existing default."""
temp_dir = Path(mkdtemp())
try:
Expand All @@ -81,10 +82,10 @@ def render_extra_template(template: str, extra_content: dict[str, str]) -> None:
def _copy_inspect(path: str, names: list[str]) -> set[str]:
ignore = set()
for name in names:
if name != "__pycache__":
logger.debug(f"Copying file {path}/{name}...")
else:
if name == "__pycache__":
ignore.add(name)
else:
logger.debug(f"Copying file {path}/{name}...")
return ignore


Expand All @@ -96,8 +97,7 @@ def _get_logger_level(int_level: int) -> str:
return "DEBUG"
elif int_level == 1:
return "INFO"
else:
return "WARNING"
return "WARNING"


if __name__ == "__main__":
Expand Down
32 changes: 28 additions & 4 deletions boxv2/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import inspect
from pathlib import Path
from typing import Any, Type
from typing import Any, Optional, Type

from botx import Bot
from botx.dependencies.models import Depends
from fastapi import FastAPI
from pydantic import BaseSettings
from pydantic import BaseModel, BaseSettings

from boxv2.utils.import_utils import import_object

Expand All @@ -20,6 +20,13 @@ class Config: # noqa: WPS431
json_loads = lambda x: x # noqa: E731,WPS111


class HealtCheckData(BaseModel):
"""Data returning by plugin's healthcheck method."""

healthy: Optional[bool] = None # True - ok, False - error, None - not supported
information: dict[str, Any] = {}


class BasePlugin:
"""Base class for plugins."""

Expand All @@ -42,10 +49,27 @@ async def on_startup(self, *args: Any, **kwargs: Any) -> None:
async def on_shutdown(self, *args: Any, **kwargs: Any) -> None:
"""Shutdown hook."""

async def healthcheck(self) -> HealtCheckData:
"""Runtime check for plugin functioning."""
return HealtCheckData()

@classmethod
def get_template_path(cls) -> Path:
"""Absolute path to plugin's template directory."""
return Path(inspect.getfile(cls)).resolve().parents[0] / cls.template
plugin_path = Path(inspect.getfile(cls)).resolve().parents[0]
return plugin_path / cls.template

@classmethod
def get_name(cls) -> str:
"""Get plugin's lowercase name."""
module = inspect.getmodule(cls)
if module is not None:
#
# boxv2.plugins.sqlalchemy.plugin.SQLAlchemyPlugin
# ^
# this part is using as name
return module.__name__.split(".")[-2]
return cls.__name__.lower()

def _merge_settings(self) -> None: # noqa: WPS231
app_values = {}
Expand All @@ -61,7 +85,7 @@ def _merge_settings(self) -> None: # noqa: WPS231
app_values[key] = getattr(self.settings, key)

plugin_settings = self.settings_class(**app_values)
for field_name in plugin_settings.__fields__:
for field_name in plugin_settings.__fields__: # noqa: WPS609
self.settings.__dict__[field_name] = getattr(plugin_settings, field_name)


Expand Down
16 changes: 15 additions & 1 deletion boxv2/plugins/debug/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,18 @@
@collector.hidden(command="/_debug:commit_sha")
async def commit_sha(message: Message, bot: Bot) -> None:
"""Show git commit SHA."""
await bot.answer_message(environ.get("COMMIT_SHA"), message)
await bot.answer_message(str(environ.get("COMMIT_SHA")), message)


@collector.hidden(command="/_debug:plugins")
async def plugins(message: Message, bot: Bot) -> None:
"""Show git commit SHA."""
names_and_status = [
(plugin.get_name(), (await plugin.healthcheck()).json(indent=2))
for plugin in bot.state.plugins
]
template = "**{name}**\n ```json\n{status}\n``` \n\n"
text = "\n\n".join(
[template.format(name=name, status=status) for name, status in names_and_status]
)
await bot.answer_message(text, message)
6 changes: 4 additions & 2 deletions boxv2/plugins/logger/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
import json
import logging
import sys
from copy import deepcopy
from pprint import pformat
from typing import TYPE_CHECKING, List, Any
from typing import TYPE_CHECKING, Any

from loguru import logger
from loguru._defaults import LOGURU_FORMAT # noqa: WPS436

from boxv2.plugin import BasePlugin, BasePluginSettings
from copy import deepcopy

if TYPE_CHECKING:
from loguru import Record # noqa: WPS433 # pragma: no cover
Expand Down
22 changes: 20 additions & 2 deletions boxv2/plugins/redis/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

from pydantic import RedisDsn

from boxv2.plugin import BasePlugin, BasePluginSettings
from boxv2.plugin import BasePlugin, BasePluginSettings, HealtCheckData
from boxv2.plugins.redis.repo import RedisRepo


Expand All @@ -22,7 +22,7 @@ class RedisPlugin(BasePlugin):

async def on_startup(self, *args: Any, **kwargs: Any) -> None:
"""Startup hook."""
prefix = self.settings.REDIS_PREFIX or self.settings.NAME
prefix = self._get_prefix()
expire = self.settings.REDIS_EXPIRE or 0
self.redis_repo = await RedisRepo.init(
dsn=str(self.settings.REDIS_DSN), prefix=prefix, expire=expire
Expand All @@ -32,3 +32,21 @@ async def on_startup(self, *args: Any, **kwargs: Any) -> None:
async def on_shutdown(self, *args: Any, **kwargs: Any) -> None:
"""Shutdown hook."""
await self.redis_repo.close()

async def healthcheck(self) -> HealtCheckData:
"""Healthcheck."""
try:
information = await self.redis_repo.redis.info()
except Exception as exc:
return HealtCheckData(healthy=False, info={"error": str(exc)})
return HealtCheckData(
healthy=True,
information={
"server_version": information["server"]["redis_version"],
"dsn": self.settings.REDIS_DSN,
"prefix": self._get_prefix(),
},
)

def _get_prefix(self) -> str:
return self.settings.REDIS_PREFIX or self.settings.NAME
3 changes: 3 additions & 0 deletions boxv2/plugins/sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .plugin import SQLAlchemyPlugin as Plugin

__all__ = ["Plugin"]
62 changes: 62 additions & 0 deletions boxv2/plugins/sqlalchemy/plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Postgresql database plugin."""

from typing import Any, Optional

from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker

from boxv2.plugin import BasePlugin, BasePluginSettings, HealtCheckData
from boxv2.plugins.sqlalchemy.url_scheme_utils import make_url_async


class Settings(BasePluginSettings):
"""Settings for SQLAlchemy ORM plugin."""

POSTGRES_DSN: str
SQL_DEBUG: bool = False


class SQLAlchemyPlugin(BasePlugin):
"""SQLAlchemy ORM plugin."""

settings_class = Settings
_session: Optional[AsyncSession]
engine: Optional[AsyncEngine]

async def on_startup(self, *args: Any, **kwargs: Any) -> None:
"""Startup hook."""
self.engine = create_async_engine(
make_url_async(self.settings.POSTGRES_DSN), echo=self.settings.SQL_DEBUG
)

make_session = sessionmaker(
self.engine, expire_on_commit=False, class_=AsyncSession
)
self._session = make_session()

async def on_shutdown(self, *args: Any, **kwargs: Any) -> None:
"""Shutdown hook."""
if self.session is not None:
await self.session.close()

async def healthcheck(self) -> HealtCheckData:
"""Healthcheck function."""
try:
async with self.session.begin():
rows = await self.session.execute("select version()")
except Exception as exc:
return HealtCheckData(healthy=False, information={"error": str(exc)})
return HealtCheckData(
healthy=True,
info={
"server_version": rows.scalars().one(),
"dsn": self.settings.POSTGRES_DSN,
},
)

@property
def session(self) -> AsyncSession:
"""Return an SQLAlchemy session instance."""
if self._session is None:
raise RuntimeError("Plugin not yet initialized!")
return self._session
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file

[alembic]
script_location = ./migrations
script_location = ./app/db/migrations

[loggers]
keys = root,sqlalchemy,alembic
Expand Down
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,17 @@
from sqlalchemy import engine_from_config, pool

# init config
sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
sys.path.append(str(pathlib.Path(__file__).resolve().parents[3]))

from boxv2.application import get_app_settings # isort:skip
from boxv2.plugins.sqlalchemy.url_scheme_utils import make_url_sync # isort:skip
from app.db.models import Base # isort:skip

settings = get_app_settings()

postgres_dsn = make_url_sync(get_app_settings().POSTGRES_DSN)
context_config = context.config

fileConfig(context_config.config_file_name)

target_metadata = None

context_config.set_main_option("sqlalchemy.url", str(settings.POSTGRES_DSN))
target_metadata = Base.metadata
context_config.set_main_option("sqlalchemy.url", postgres_dsn)


def run_migrations_online() -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Database models declarations."""

from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.declarative import declarative_base

# All models in project must be inherited from this class
Base = declarative_base()


# This is an example model. You may rewrite it a you need or write any other models.
# After it you may run `alembic revision --autogenerate` to generate migrations
# and `alembic upgrade head` to apply it on database.
# Migrations files will be stored at `app/db/migrations/versions`
class Record(Base):
"""Simple database model for example."""

__tablename__ = "record"

id: int = Column(Integer, primary_key=True, autoincrement=True) # noqa: WPS125
record_data: str = Column(String)

def __repr__(self) -> str:
"""Show string representation of record."""
return self.record_data


def get_session() -> AsyncSession:
from app.main import app # should not be imported before app initialization

return app.state.sqlalchemy.session
11 changes: 11 additions & 0 deletions boxv2/plugins/sqlalchemy/url_scheme_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""Utility functions for sqlalchemy plugin."""


def make_url_async(url: str) -> str:
"""Add +asyncpg to url scheme."""
return "postgresql+asyncpg" + url[url.find(":") :] # noqa: WPS336


def make_url_sync(url: str) -> str:
"""Remove +asyncpg from url scheme."""
return "postgresql" + url[url.find(":") :] # noqa: WPS336
3 changes: 0 additions & 3 deletions boxv2/plugins/tortoise/__init__.py

This file was deleted.

Loading

0 comments on commit 398945a

Please sign in to comment.