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/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/grace/generator.py b/grace/generator.py index f3be367..617e69f 100644 --- a/grace/generator.py +++ b/grace/generator.py @@ -120,17 +120,29 @@ 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] = {}): - """Generates a template using Cookiecutter. + def generate_template( + self, template_dir: str, variables: dict[str, Any] = {}, output_dir: str = "" + ): + """Generate a template using Cookiecutter. - :param template_dir: The name of the template to generate. - :type template_dir: str + Renders `template_dir` (a subdirectory of `templates_path`) with the given + variables, writing the result into `output_dir` (defaults to the current + working directory). Returns the path to the generated project directory. - :param variables: The variables to pass to the template. (default: {}) - :type variables: dict[str, Any] + ## Example + + ```python + self.generate_template( + "project", + variables={"project_name": "my-bot"}, + output_dir="my-bot", + ) + ``` """ 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..c813c86 --- /dev/null +++ b/tests/generators/test_database_generator.py @@ -0,0 +1,42 @@ +import pytest + +from grace.generator import Generator +from grace.generators.database_generator import DatabaseGenerator + + +@pytest.fixture +def generator(): + return DatabaseGenerator() + + +def test_generate__expect_database_template_rendered(mocker, generator, tmp_path): + 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) + + +def test_generate__expect_config_files_copied(mocker, generator, tmp_path): + mocker.patch.object(Generator, "generate_template") + output_dir = str(tmp_path / "example-project") + + generator.generate(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_database_generator__expect_name_and_empty_options(generator): + assert generator.NAME == "database" + assert generator.OPTIONS == {} + + +def test_validate__expect_true(generator): + assert generator.validate() is True diff --git a/tests/generators/test_project_generator.py b/tests/generators/test_project_generator.py index 045258d..80651ef 100644 --- a/tests/generators/test_project_generator.py +++ b/tests/generators/test_project_generator.py @@ -9,11 +9,12 @@ def generator(): return ProjectGenerator() -def test_generate_project_with_database(mocker, generator): - """ - Test if the generate method creates the correct template with a database. - """ +def test_generate_with_database__expect_project_and_database_generated( + mocker, generator +): 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,14 +23,16 @@ 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): - """ - Test if the generate method creates the correct template without a - database. - """ +def test_generate_without_database__expect_only_project_generated(mocker, generator): 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 +41,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_application.py b/tests/test_application.py new file mode 100644 index 0000000..1752ba5 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,34 @@ +import pytest + +from grace.application import Application +from grace.exceptions import ConfigError + + +@pytest.fixture +def app(): + return Application() + + +def test_init_without_database__expect_no_error(app): + assert app is not None + + +def test_has_database_without_database__expect_false(app): + assert app.has_database is False + + +def test_database_exists_without_database__expect_false(app): + assert app.database_exists is False + + +def test_database_infos_without_database__expect_empty_dict(app): + assert app.database_infos == {} + + +def test_load_database_without_database__expect_no_op(app): + app.load_database() + + +def test_create_database_without_database__expect_config_error(app): + with pytest.raises(ConfigError): + app.create_database() diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2f72bd3 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,74 @@ +import logging +from unittest.mock import MagicMock + +import pytest + +from grace.cli import _load_database, _require_database, _show_application_info + + +@pytest.fixture +def app(): + return MagicMock( + environment="development", + command_sync=True, + watch=False, + ) + + +def test_require_database_without_database__expect_false(app, caplog): + app.has_database = False + + assert _require_database(app) is False + assert "no database configured" in caplog.text.lower() + + +def test_require_database_with_database__expect_true(app, caplog): + app.has_database = True + + assert _require_database(app) is True + assert caplog.text == "" + + +def test_load_database_without_database__expect_no_create(app): + app.has_database = False + + _load_database(app) + + app.create_database.assert_not_called() + + +def test_load_database_with_missing_database__expect_create_called(app): + app.has_database = True + app.database_exists = False + + _load_database(app) + + app.create_database.assert_called_once() + + +def test_load_database_with_existing_database__expect_create_not_called(app): + app.has_database = True + app.database_exists = True + + _load_database(app) + + app.create_database.assert_not_called() + + +def test_show_application_info_without_database__expect_no_database_line(app, caplog): + app.has_database = False + + with caplog.at_level(logging.INFO): + _show_application_info(app) + + assert "Using database" not in caplog.text + + +def test_show_application_info_with_database__expect_database_line(app, caplog): + app.has_database = True + app.database_infos = {"database": "grace.db", "dialect": "sqlite"} + + with caplog.at_level(logging.INFO): + _show_application_info(app) + + assert "Using database: grace.db with sqlite" in caplog.text diff --git a/tests/test_config.py b/tests/test_config.py index 1b62b22..1d7f99b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -15,6 +15,18 @@ def test_set_environment(config): assert config.current_environment == "test" +def test_database_without_database_section__expect_none(config): + config.set_environment("test") + + assert config.database is None + + +def test_database_uri_without_database_section__expect_none(config): + 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") 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="" )