Skip to content

Commit bfe8dce

Browse files
authored
WIP: Improvement of the ORM (#10)
This PR is a major refactor of the Model and query system. It adds [SQLModel](https://sqlmodel.tiangolo.com/tutorial/fastapi/limit-and-offset/?h=offset#add-a-limit-and-offset-to-the-query-parameters), which is similar to our current Model, and includes a QueryBuilder on top of it to provide an API closer to Active Record or Eloquent. Here's an example of [grace's thread](https://github.com/Code-Society-Lab/grace/blob/main/bot/models/extensions/thread.py) model: ```python # bot/models/extensions/thread.py from grace.model import Model, Field, Column, Text, Integer class Thread(Model): __tablename__ = 'threads' # only needed because for old tables id: int | None = Field(default=None, primary_key=True) title: str content: str = Field(sa_type=Text) recurrence: Recurrence = Field(sa_column=Column(Integer)) ``` ```python thread = Thread.create( title="Daily Standup", content="Long content here...", recurrence=Recurrence.DAILY ) daily_threads = Thread.where(Thread.recurrence == Recurrence.DAILY).all() thread_by_id = Thread.find(1) thread.recurrence = Recurrence.WEEKLY thread.save() ```
1 parent 3ca9093 commit bfe8dce

31 files changed

Lines changed: 1086 additions & 482 deletions

.flake8

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,42 @@
1-
# This workflow will install Python dependencies, run tests and lint with a single version of Python
2-
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python
3-
4-
name: Grace Framework Tests
1+
name: Grace Framework CI
52

63
on:
74
push:
8-
branches: [ "main" ]
5+
branches: ["main"]
96
pull_request:
10-
branches: [ "main" ]
7+
branches: ["main"]
118

129
permissions:
1310
contents: read
1411

1512
jobs:
16-
build:
17-
13+
test:
1814
runs-on: ubuntu-latest
1915

2016
steps:
21-
- uses: actions/checkout@v4
22-
- name: Set up Python 3.10
23-
uses: actions/setup-python@v3
24-
with:
25-
python-version: "3.10"
26-
- name: Install dependencies
27-
run: |
28-
python -m pip install --upgrade pip
29-
pip install .
30-
- name: Lint with flake8
31-
run: |
32-
# stop the build if there are Python syntax errors or undefined names
33-
flake8 grace --count --select=E9,F63,F7,F82 --show-source --statistics
34-
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
35-
flake8 grace --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
36-
- name: Test with pytest
37-
run: |
38-
pytest -v
17+
- name: Checkout repository
18+
uses: actions/checkout@v4
19+
20+
- name: Set up Python
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: "3.11"
24+
25+
- name: Install dependencies
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install .[dev]
29+
pip install mypy
30+
31+
- name: Run code format check
32+
run: |
33+
black --check .
34+
isort --check-only .
35+
36+
- name: Run type checks
37+
run: |
38+
mypy .
39+
40+
- name: Run tests
41+
run: |
42+
pytest -v

grace/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
__version__ = "0.10.10-alpha"
1+
__version__ = "1.0.0-alpha"
2+
3+
from discord.ext.commands import *

grace/application.py

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,22 @@
1-
from os import environ
21
from configparser import SectionProxy
3-
4-
from coloredlogs import install
52
from logging import basicConfig, critical
63
from logging.handlers import RotatingFileHandler
7-
4+
from os import environ
5+
from pathlib import Path
86
from types import ModuleType
9-
from typing import Generator, Any, Union, Dict, Optional, no_type_check
7+
from typing import Any, Dict, Generator, Optional, Union, no_type_check
108

11-
from sqlalchemy import create_engine
9+
from coloredlogs import install
1210
from sqlalchemy.engine import Engine
1311
from sqlalchemy.exc import OperationalError
14-
from sqlalchemy.orm import (
15-
declarative_base,
16-
sessionmaker,
17-
Session,
18-
DeclarativeMeta
19-
)
20-
from sqlalchemy_utils import (
21-
database_exists,
22-
create_database,
23-
drop_database
24-
)
25-
from pathlib import Path
12+
from sqlalchemy.orm import DeclarativeMeta, declarative_base
13+
from sqlalchemy_utils import create_database, database_exists, drop_database
14+
from sqlmodel import Session, create_engine
15+
2616
from grace.config import Config
2717
from grace.exceptions import ConfigError
2818
from grace.importer import find_all_importables, import_module
29-
19+
from grace.model import Model
3020

3121
ConfigReturn = Union[str, int, float, None]
3222

@@ -43,13 +33,14 @@ class Application:
4333

4434
def __init__(self) -> None:
4535
database_config_path: Path = Path("config/database.cfg")
46-
36+
4737
if not database_config_path.exists():
4838
raise ConfigError("Unable to find the 'database.cfg' file.")
4939

5040
self.__token: str = str(self.config.get("discord", "token"))
5141
self.__engine: Union[Engine, None] = None
5242

43+
self.environment: str = "development"
5344
self.command_sync: bool = True
5445
self.watch: bool = False
5546

@@ -67,8 +58,9 @@ def session(self) -> Session:
6758
"""Instantiate the session for querying the database."""
6859

6960
if not self.__session:
70-
session: sessionmaker = sessionmaker(bind=self.__engine)
71-
self.__session = session()
61+
# session_factory: sessionmaker = sessionmaker(bind=self.__engine)
62+
# scoped_session_ = scoped_session(session_factory)
63+
self.__session = Session(self.__engine)
7264

7365
return self.__session
7466

@@ -99,11 +91,11 @@ def extension_modules(self) -> Generator[str, Any, None]:
9991
def database_infos(self) -> Dict[str, str]:
10092
return {
10193
"dialect": self.session.bind.dialect.name,
102-
"database": self.session.bind.url.database
94+
"database": self.session.bind.url.database,
10395
}
10496

10597
@property
106-
def database_exists(self):
98+
def database_exists(self) -> bool:
10799
return database_exists(self.config.database_uri)
108100

109101
def get_extension_module(self, extension_name) -> Union[str, None]:
@@ -134,9 +126,7 @@ def load_models(self):
134126

135127
def load_logs(self) -> None:
136128
file_handler: RotatingFileHandler = RotatingFileHandler(
137-
f"logs/{self.config.current_environment}.log",
138-
maxBytes=10000,
139-
backupCount=5
129+
f"logs/{self.config.current_environment}.log", maxBytes=10000, backupCount=5
140130
)
141131

142132
basicConfig(
@@ -147,19 +137,24 @@ def load_logs(self) -> None:
147137

148138
install(
149139
self.config.environment.get("log_level"),
150-
fmt="".join([
151-
"[%(asctime)s] %(programname)s %(funcName)s ",
152-
"%(module)s %(levelname)s %(message)s"
153-
]),
140+
fmt="".join(
141+
[
142+
"[%(asctime)s] %(programname)s %(funcName)s ",
143+
"%(module)s %(levelname)s %(message)s",
144+
]
145+
),
154146
programname=self.config.current_environment,
155147
)
156148

157-
def load_database(self):
149+
def load_database(self) -> None:
158150
"""Loads and connects to the database using the loaded config"""
159151

152+
if not self.config.database_uri:
153+
raise ValueError("No database uri.")
154+
160155
self.__engine = create_engine(
161156
self.config.database_uri,
162-
echo=self.config.environment.getboolean("sqlalchemy_echo")
157+
echo=self.config.environment.getboolean("sqlalchemy_echo"),
163158
)
164159

165160
if self.database_exists:
@@ -168,6 +163,8 @@ def load_database(self):
168163
except OperationalError as e:
169164
critical(f"Unable to load the 'database': {e}")
170165

166+
Model.set_engine(self.__engine)
167+
171168
def unload_database(self):
172169
"""Unloads the current database"""
173170

@@ -176,7 +173,7 @@ def unload_database(self):
176173

177174
def reload_database(self):
178175
"""
179-
Reload the database. This function can be use in case
176+
Reload the database. This function can be used in case
180177
there's a dynamic environment change.
181178
"""
182179

@@ -198,11 +195,17 @@ def drop_database(self):
198195
def create_tables(self):
199196
"""Creates all the tables for the current loaded database"""
200197

198+
if not self.__engine:
199+
raise RuntimeError("Database engine is not initialized.")
200+
201201
self.load_database()
202202
self.base.metadata.create_all(self.__engine)
203203

204204
def drop_tables(self):
205205
"""Drops all the tables for the current loaded database"""
206206

207+
if not self.__engine:
208+
raise RuntimeError("Database engine is not initialized.")
209+
207210
self.load_database()
208211
self.base.metadata.drop_all(self.__engine)

grace/bot.py

Lines changed: 26 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,21 @@
1-
from logging import info, warning, critical
1+
from logging import critical, info, warning
2+
23
from apscheduler.schedulers.asyncio import AsyncIOScheduler
3-
from discord import Intents, LoginFailure, Object as DiscordObject
4-
from discord.ext.commands import Bot as DiscordBot, when_mentioned_or
5-
from discord.ext.commands.errors import (
6-
ExtensionNotLoaded,
7-
ExtensionAlreadyLoaded
8-
)
4+
from discord import Intents, LoginFailure
5+
from discord import Object as DiscordObject
6+
from discord.ext.commands import Bot as DiscordBot
7+
from discord.ext.commands import when_mentioned_or
8+
from discord.ext.commands.errors import ExtensionAlreadyLoaded, ExtensionNotLoaded
9+
910
from grace.application import Application, SectionProxy
1011
from grace.watcher import Watcher
1112

12-
# make discord.ext.commands importable from this module
13-
from discord.ext.commands import *
14-
1513

1614
class Bot(DiscordBot):
1715
"""This class is the core of the bot
1816
1917
This class is a subclass of `discord.ext.commands.Bot` and is the core
20-
of the bot. It is responsible for loading the extensions and
18+
of the bot. It is responsible for loading the extensions and
2119
syncing the commands.
2220
2321
The bot is instantiated with the application object and the intents.
@@ -30,23 +28,16 @@ def __init__(self, app: Application, **kwargs) -> None:
3028
self.watcher: Watcher = Watcher(self.on_reload)
3129

3230
command_prefix = kwargs.pop(
33-
'command_prefix',
34-
when_mentioned_or(self.config.get("prefix", "!"))
35-
)
36-
description: str = kwargs.pop(
37-
'description',
38-
self.config.get("description")
39-
)
40-
intents: Intents = kwargs.pop(
41-
'intents',
42-
Intents.default()
31+
"command_prefix", when_mentioned_or(self.config.get("prefix", "!"))
4332
)
33+
description: str = kwargs.pop("description", self.config.get("description"))
34+
intents: Intents = kwargs.pop("intents", Intents.default())
4435

4536
super().__init__(
4637
command_prefix=command_prefix,
4738
description=description,
4839
intents=intents,
49-
**kwargs
40+
**kwargs,
5041
)
5142

5243
async def load_extensions(self) -> None:
@@ -63,8 +54,10 @@ async def sync_commands(self) -> None:
6354

6455
async def invoke(self, ctx):
6556
if ctx.command:
66-
info(f"'{ctx.command}' has been invoked by {ctx.author} "
67-
f"({ctx.author.display_name})")
57+
info(
58+
f"'{ctx.command}' has been invoked by {ctx.author} "
59+
f"({ctx.author.display_name})"
60+
)
6861
await super().invoke(ctx)
6962

7063
async def setup_hook(self) -> None:
@@ -78,13 +71,13 @@ async def setup_hook(self) -> None:
7871

7972
self.scheduler.start()
8073

81-
async def load_extension(self, name: str) -> None:
74+
async def load_extension(self, name: str) -> None: # type: ignore[override]
8275
try:
8376
await super().load_extension(name)
8477
except ExtensionAlreadyLoaded:
8578
warning(f"Extension '{name}' already loaded, skipping.")
8679

87-
async def unload_extension(self, name: str) -> None:
80+
async def unload_extension(self, name: str) -> None: # type: ignore[override]
8881
try:
8982
await super().unload_extension(name)
9083
except ExtensionNotLoaded:
@@ -97,14 +90,16 @@ async def on_reload(self):
9790
await self.unload_extension(module)
9891
await self.load_extension(module)
9992

100-
def run(self) -> None: # type: ignore[override]
93+
def run(self) -> None: # type: ignore[override]
10194
"""Override the `run` method to handle the token retrieval"""
10295
try:
10396
if self.app.token:
10497
super().run(self.app.token)
10598
else:
106-
critical("Unable to find the token. Make sure your current"
107-
"directory contains an '.env' and that "
108-
"'DISCORD_TOKEN' is defined")
99+
critical(
100+
"Unable to find the token. Make sure your current"
101+
"directory contains an '.env' and that "
102+
"'DISCORD_TOKEN' is defined"
103+
)
109104
except LoginFailure as e:
110-
critical(f"Authentication failed : {e}")
105+
critical(f"Authentication failed : {e}")

0 commit comments

Comments
 (0)