From dab7fdbb111f081e0e541b7abff1814945879aa6 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Thu, 30 Jul 2026 22:22:03 -0400 Subject: [PATCH 1/2] Make database configuration optional --- grace/application.py | 37 +++++++++++++++++++++++-------- grace/cli.py | 46 +++++++++++++++++++++++++++++++++------ grace/config.py | 11 ++++++++-- tests/test_application.py | 40 ++++++++++++++++++++++++++++++++++ tests/test_config.py | 14 ++++++++++++ 5 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 tests/test_application.py diff --git a/grace/application.py b/grace/application.py index 6ab2234..3186266 100644 --- a/grace/application.py +++ b/grace/application.py @@ -2,7 +2,6 @@ from logging import basicConfig, critical from logging.handlers import RotatingFileHandler from os import environ -from pathlib import Path from types import ModuleType from typing import Any, Dict, Generator, Optional, Union, no_type_check @@ -31,11 +30,6 @@ class Application: __session: Union[Session, None] = None def __init__(self) -> None: - database_config_path: Path = Path("config/database.cfg") - - if not database_config_path.exists(): - raise ConfigError("Unable to find the 'database.cfg' file.") - self.__token: str = str(self.config.get("discord", "token")) self.__engine: Union[Engine, None] = None @@ -84,8 +78,15 @@ def extension_modules(self) -> Generator[str, Any, None]: continue yield module + @property + def has_database(self) -> bool: + return bool(self.config.database_uri) + @property def database_infos(self) -> Dict[str, str]: + if not self.has_database: + return {} + return { "dialect": self.session.bind.dialect.name, "database": self.session.bind.url.database, @@ -93,6 +94,9 @@ def database_infos(self) -> Dict[str, str]: @property def database_exists(self) -> bool: + if not self.has_database: + return False + return database_exists(self.config.database_uri) def get_extension_module(self, extension_name) -> Union[str, None]: @@ -146,11 +150,13 @@ def load_logs(self) -> None: def load_database(self) -> None: """Loads and connects to the database using the loaded config""" - if not self.config.database_uri: - raise ValueError("No database uri.") + database_uri = self.config.database_uri + + if not database_uri: + return self.__engine = create_engine( - self.config.database_uri, + database_uri, echo=self.config.environment.getboolean("sqlalchemy_echo"), ) @@ -180,18 +186,22 @@ def reload_database(self): def create_database(self): """Creates the database for the current loaded config""" + self._require_database() self.load_database() create_database(self.config.database_uri) def drop_database(self): """Drops the database for the current loaded config""" + self._require_database() self.load_database() drop_database(self.config.database_uri) def create_tables(self): """Creates all the tables for the current loaded database""" + self._require_database() + if not self.__engine: raise RuntimeError("Database engine is not initialized.") @@ -201,8 +211,17 @@ def create_tables(self): def drop_tables(self): """Drops all the tables for the current loaded database""" + self._require_database() + if not self.__engine: raise RuntimeError("Database engine is not initialized.") self.load_database() self.metadata.drop_all(self.__engine) + + def _require_database(self) -> None: + if not self.has_database: + raise ConfigError( + "This project has no database configured. " + "Run 'grace generate database' to add one." + ) diff --git a/grace/cli.py b/grace/cli.py index ba3090f..a11329e 100644 --- a/grace/cli.py +++ b/grace/cli.py @@ -15,9 +15,10 @@ | Environment: {env} | Syncing command: {command_sync} | Watcher enabled: {watch} -| Using database: {database} with {dialect} """.rstrip() +DB_INFO = "| Using database: {database} with {dialect}" + @group() def cli(): @@ -26,9 +27,7 @@ def cli(): @cli.command() @argument("name") -# This database option is currently disabled since the application and config -# does not currently support it. -# @option("--database/--no-database", default=True) +@option("--database/--no-database", default=True) @pass_context def new(ctx, name, database=True): cmd = generate.get_command(ctx, "project") @@ -84,6 +83,9 @@ def run(ctx, sync, watch): def create(ctx): app = ctx.obj["app"] + if not _require_database(app): + return + if app.database_exists: return warning("Database already exists") @@ -96,6 +98,9 @@ def create(ctx): def drop(ctx): app = ctx.obj["app"] + if not _require_database(app): + return + if not app.database_exists: return warning("Database does not exist") @@ -108,6 +113,9 @@ def drop(ctx): def seed(ctx): app = ctx.obj["app"] + if not _require_database(app): + return + if not app.database_exists: return warning("Database does not exist") @@ -122,6 +130,9 @@ def seed(ctx): def up(ctx, revision): app = ctx.obj["app"] + if not _require_database(app): + return + if not app.database_exists: return warning("Database does not exist") @@ -134,6 +145,9 @@ def up(ctx, revision): def down(ctx, revision): app = ctx.obj["app"] + if not _require_database(app): + return + if not app.database_exists: return warning("Database does not exist") @@ -141,21 +155,39 @@ def down(ctx, revision): def _load_database(app): + if not app.has_database: + return + if not app.database_exists: app.create_database() # app.create_tables() +def _require_database(app) -> bool: + if not app.has_database: + warning( + "This project has no database configured. " + "Run 'grace generate database' to add one." + ) + return False + return True + + def _show_application_info(app): + message = APP_INFO + + if app.has_database: + message = f"{message}\n{DB_INFO}" + info( - APP_INFO.format( + message.format( discord_version=discord.__version__, env=app.environment, pid=getpid(), command_sync=app.command_sync, watch=app.watch, - database=app.database_infos["database"], - dialect=app.database_infos["dialect"], + database=app.database_infos.get("database"), + dialect=app.database_infos.get("dialect"), ) ) diff --git a/grace/config.py b/grace/config.py index 14e2add..65d88b8 100644 --- a/grace/config.py +++ b/grace/config.py @@ -85,8 +85,12 @@ def __init__(self) -> None: self.read("config/environment.cfg") @property - def database(self) -> SectionProxy: - return self.__config[f"database.{self.__environment}"] + def database(self) -> Union[SectionProxy, None]: + section = f"database.{self.__environment}" + + if not self.__config.has_section(section): + return None + return self.__config[section] @property def client(self) -> SectionProxy: @@ -102,6 +106,9 @@ def current_environment(self) -> Optional[str]: @property def database_uri(self) -> Union[str, URL, None]: + if not self.database: + return None + if self.database.get("url"): return self.database.get("url") diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 0000000..d89d587 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,40 @@ +import pytest + +from grace.application import Application +from grace.exceptions import ConfigError + + +@pytest.fixture +def app(): + return Application() + + +def test_init_without_database_config(app): + """Test if Application can be instantiated without a database.cfg""" + assert app is not None + + +def test_has_database_without_database_config(app): + """Test if has_database is False when no database is configured""" + assert app.has_database is False + + +def test_database_exists_without_database_config(app): + """Test if database_exists is False when no database is configured""" + assert app.database_exists is False + + +def test_database_infos_without_database_config(app): + """Test if database_infos is empty when no database is configured""" + assert app.database_infos == {} + + +def test_load_database_without_database_config(app): + """Test if load_database is a no-op when no database is configured""" + app.load_database() + + +def test_create_database_without_database_config(app): + """Test if create_database raises a clear error when no database is configured""" + with pytest.raises(ConfigError): + app.create_database() diff --git a/tests/test_config.py b/tests/test_config.py index 1b62b22..2f62757 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,20 @@ def test_set_environment(config): assert config.current_environment == "test" +def test_database_without_database_config(config): + """Test if database is None when no `database.` section is configured""" + config.set_environment("test") + + assert config.database is None + + +def test_database_uri_without_database_config(config): + """Test if database_uri is None when no `database.` section is configured""" + config.set_environment("test") + + assert config.database_uri is None + + # def test_section_name(config): # """Test if the section name is set correctly""" # config.set_environment("test") From f1486b5642ffbaee421938d1b7857c0bd1d6122c Mon Sep 17 00:00:00 2001 From: penguinboi Date: Thu, 30 Jul 2026 22:22:03 -0400 Subject: [PATCH 2/2] Add standalone database generator --- README.md | 3 ++ grace/generator.py | 15 ++++++- grace/generators/database_generator.py | 31 ++++++++++++++ grace/generators/project_generator.py | 6 ++- .../alembic.ini | 0 .../templates/database/cookiecutter.json | 3 ++ .../config => database}/database.cfg | 4 +- .../__init__.py | 0 .../alembic/env.py | 0 .../alembic/script.py.mako | 0 .../alembic/versions/.gitkeep | 0 .../seed.py | 0 .../project/hooks/post_gen_project.py | 11 ----- .../README.md | 6 ++- tests/generators/test_database_generator.py | 41 +++++++++++++++++++ tests/generators/test_project_generator.py | 9 ++++ tests/test_generator.py | 2 +- 17 files changed, 113 insertions(+), 18 deletions(-) create mode 100644 grace/generators/database_generator.py rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }} => database}/alembic.ini (100%) create mode 100644 grace/generators/templates/database/cookiecutter.json rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/config => database}/database.cfg (93%) rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/db => database/{{ cookiecutter.__database_slug }}}/__init__.py (100%) rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/db => database/{{ cookiecutter.__database_slug }}}/alembic/env.py (100%) rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/db => database/{{ cookiecutter.__database_slug }}}/alembic/script.py.mako (100%) rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/db => database/{{ cookiecutter.__database_slug }}}/alembic/versions/.gitkeep (100%) rename grace/generators/templates/{project/{{ cookiecutter.__project_slug }}/db => database/{{ cookiecutter.__database_slug }}}/seed.py (100%) delete mode 100644 grace/generators/templates/project/hooks/post_gen_project.py create mode 100644 tests/generators/test_database_generator.py diff --git a/README.md b/README.md index d0d62c5..cc47122 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ $ grace new my-awesome-bot $ cd my-awesome-bot ``` +By default, a database is scaffolded alongside your bot. Pass `--no-database` to skip +it, and add one later with `grace generate database`. + #### 3- Set your bot token Edit the `.env` in the project directory and set `DISCORD_TOKEN`. diff --git a/grace/generator.py b/grace/generator.py index f3be367..2f1989c 100644 --- a/grace/generator.py +++ b/grace/generator.py @@ -120,7 +120,9 @@ def validate(self, *args, **kwargs): """Validates the arguments passed to the command.""" return True - def generate_template(self, template_dir: str, variables: dict[str, Any] = {}): + def generate_template( + self, template_dir: str, variables: dict[str, Any] = {}, output_dir: str = "" + ): """Generates a template using Cookiecutter. :param template_dir: The name of the template to generate. @@ -128,9 +130,18 @@ def generate_template(self, template_dir: str, variables: dict[str, Any] = {}): :param variables: The variables to pass to the template. (default: {}) :type variables: dict[str, Any] + + :param output_dir: The output directory for the generated template. + (default is "") + :type output_dir: str + + :return: The path to the generated project directory. + :rtype: str """ template = str(self.templates_path / template_dir) - cookiecutter(template, extra_context=variables, no_input=True) + return cookiecutter( + template, extra_context=variables, no_input=True, output_dir=output_dir + ) def generate_file( self, template_dir: str, variables: dict[str, Any] = {}, output_dir: str = "" diff --git a/grace/generators/database_generator.py b/grace/generators/database_generator.py new file mode 100644 index 0000000..4280499 --- /dev/null +++ b/grace/generators/database_generator.py @@ -0,0 +1,31 @@ +from logging import info +from pathlib import Path +from shutil import copy + +from grace.generator import Generator + + +class DatabaseGenerator(Generator): + NAME = "database" + OPTIONS = {} + + def generate(self, output_dir: str = ""): + info(f"Creating database in '{output_dir or '.'}'") + + self.generate_template(self.NAME, output_dir=output_dir) + self._copy_config_files(output_dir) + + def _copy_config_files(self, output_dir: str): + config_dir = Path(output_dir) / "config" + config_dir.mkdir(parents=True, exist_ok=True) + + source = self.templates_path / self.NAME + copy(source / "alembic.ini", Path(output_dir) / "alembic.ini") + copy(source / "database.cfg", config_dir / "database.cfg") + + def validate(self, *_args, **_kwargs) -> bool: + return True + + +def generator() -> Generator: + return DatabaseGenerator() diff --git a/grace/generators/project_generator.py b/grace/generators/project_generator.py index a2e8f82..9500da0 100644 --- a/grace/generators/project_generator.py +++ b/grace/generators/project_generator.py @@ -2,6 +2,7 @@ from re import match from grace.generator import Generator +from grace.generators.database_generator import generator as db_generator class ProjectGenerator(Generator): @@ -11,7 +12,7 @@ class ProjectGenerator(Generator): def generate(self, name: str, database: bool = True): info(f"Creating '{name}'") - self.generate_template( + project_dir = self.generate_template( self.NAME, variables={ "project_name": name, @@ -20,6 +21,9 @@ def generate(self, name: str, database: bool = True): }, ) + if database: + db_generator().generate(output_dir=project_dir) + def validate(self, name: str, **_kwargs) -> bool: """Validate the project name. diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/alembic.ini b/grace/generators/templates/database/alembic.ini similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/alembic.ini rename to grace/generators/templates/database/alembic.ini diff --git a/grace/generators/templates/database/cookiecutter.json b/grace/generators/templates/database/cookiecutter.json new file mode 100644 index 0000000..c2ed5e5 --- /dev/null +++ b/grace/generators/templates/database/cookiecutter.json @@ -0,0 +1,3 @@ +{ + "__database_slug": "db" +} \ No newline at end of file diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/config/database.cfg b/grace/generators/templates/database/database.cfg similarity index 93% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/config/database.cfg rename to grace/generators/templates/database/database.cfg index 1c0a8b5..9ed806a 100644 --- a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/config/database.cfg +++ b/grace/generators/templates/database/database.cfg @@ -30,8 +30,8 @@ url = ${DATABASE_URL} [database.development] adapter = sqlite -database = {{ cookiecutter.__project_slug }}_development.db +database = development.db [database.test] adapter = sqlite -database = {{ cookiecutter.__project_slug }}_test.db +database = test.db diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/__init__.py b/grace/generators/templates/database/{{ cookiecutter.__database_slug }}/__init__.py similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/__init__.py rename to grace/generators/templates/database/{{ cookiecutter.__database_slug }}/__init__.py diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/env.py b/grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/env.py similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/env.py rename to grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/env.py diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/script.py.mako b/grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/script.py.mako similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/script.py.mako rename to grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/script.py.mako diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/versions/.gitkeep b/grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/versions/.gitkeep similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/alembic/versions/.gitkeep rename to grace/generators/templates/database/{{ cookiecutter.__database_slug }}/alembic/versions/.gitkeep diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/seed.py b/grace/generators/templates/database/{{ cookiecutter.__database_slug }}/seed.py similarity index 100% rename from grace/generators/templates/project/{{ cookiecutter.__project_slug }}/db/seed.py rename to grace/generators/templates/database/{{ cookiecutter.__database_slug }}/seed.py diff --git a/grace/generators/templates/project/hooks/post_gen_project.py b/grace/generators/templates/project/hooks/post_gen_project.py deleted file mode 100644 index c55d455..0000000 --- a/grace/generators/templates/project/hooks/post_gen_project.py +++ /dev/null @@ -1,11 +0,0 @@ -import os -import shutil - -options = {"db": "{{ cookiecutter.database }}"} - -for folder, value in options.items(): - if value == "no": - path = folder.strip() - - if path and os.path.exists(path): - shutil.rmtree(path) diff --git a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/README.md b/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/README.md index 6089dc0..ce59805 100644 --- a/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/README.md +++ b/grace/generators/templates/project/{{ cookiecutter.__project_slug }}/README.md @@ -36,11 +36,15 @@ of the installation should complete itself and start the bot. ## Script Usage - **Bot Command(s)**: - `grace start` : Starts the bot (`ctrl+c` to stop the bot) +{% if cookiecutter.database == "yes" -%} - **Database Command(s)**: - `grace db create` : Creates the database and the tables - `grace db drop` : Deletes the tables and the database - `grace db seed` : Seeds the tables (Initialize the default values) - `grace db reset` : Drop, recreate and seeds the database. - +{% else -%} +- **Database**: This project was generated without a database. Run + `grace generate database` to add one. +{% endif %} All commands can take the optional `-e` argument with a string to define the environment.
Available environment: (production, development [default], test) diff --git a/tests/generators/test_database_generator.py b/tests/generators/test_database_generator.py new file mode 100644 index 0000000..b915bf4 --- /dev/null +++ b/tests/generators/test_database_generator.py @@ -0,0 +1,41 @@ +import pytest + +from grace.generator import Generator +from grace.generators.database_generator import DatabaseGenerator + + +@pytest.fixture +def generator(): + return DatabaseGenerator() + + +def test_generate_database(mocker, generator, tmp_path): + """ + Test if the generate method renders the database template and copies + the static alembic.ini and config/database.cfg files into the output dir. + """ + mock_generate_template = mocker.patch.object(Generator, "generate_template") + output_dir = str(tmp_path / "example-project") + + generator.generate(output_dir=output_dir) + + mock_generate_template.assert_called_once_with("database", output_dir=output_dir) + + alembic_ini = tmp_path / "example-project" / "alembic.ini" + database_cfg = tmp_path / "example-project" / "config" / "database.cfg" + + assert alembic_ini.exists() + assert database_cfg.exists() + assert "script_location = db/alembic/" in alembic_ini.read_text() + assert "[database.development]" in database_cfg.read_text() + + +def test_generator_name(generator): + """Test if the generator is registered with the expected name.""" + assert generator.NAME == "database" + assert generator.OPTIONS == {} + + +def test_validate(generator): + """Test if the validate method always returns True.""" + assert generator.validate() is True diff --git a/tests/generators/test_project_generator.py b/tests/generators/test_project_generator.py index 045258d..61c245c 100644 --- a/tests/generators/test_project_generator.py +++ b/tests/generators/test_project_generator.py @@ -14,6 +14,8 @@ def test_generate_project_with_database(mocker, generator): Test if the generate method creates the correct template with a database. """ mock_generate_template = mocker.patch.object(Generator, "generate_template") + mock_generate_template.return_value = "example_project" + mock_db_generator = mocker.patch("grace.generators.project_generator.db_generator") name = "example-project" generator.generate(name, database=True) @@ -22,6 +24,11 @@ def test_generate_project_with_database(mocker, generator): "project", variables={"project_name": name, "project_description": "", "database": "yes"}, ) + # The database must be generated into cookiecutter's actual output + # directory (slug, e.g. underscored), not the raw project name. + mock_db_generator.return_value.generate.assert_called_once_with( + output_dir="example_project" + ) def test_generate_project_without_database(mocker, generator): @@ -30,6 +37,7 @@ def test_generate_project_without_database(mocker, generator): database. """ mock_generate_template = mocker.patch.object(Generator, "generate_template") + mock_db_generator = mocker.patch("grace.generators.project_generator.db_generator") name = "example-project" generator.generate(name, database=False) @@ -38,6 +46,7 @@ def test_generate_project_without_database(mocker, generator): "project", variables={"project_name": name, "project_description": "", "database": "no"}, ) + mock_db_generator.assert_not_called() def test_validate_valid_name(generator): diff --git a/tests/test_generator.py b/tests/test_generator.py index 2e109b8..153eade 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -33,7 +33,7 @@ def test_generate_template(generator): template_path = str(generator.templates_path / "project") cookiecutter.assert_called_once_with( - template_path, extra_context={}, no_input=True + template_path, extra_context={}, no_input=True, output_dir="" )