From 74281cd84ccdc8aef22f0015589411a004806527 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 10 Mar 2025 11:23:46 -0400 Subject: [PATCH 1/5] Fix some typing issue --- grace/generator.py | 13 +++++++------ pyproject.toml | 5 +++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/grace/generator.py b/grace/generator.py index a2dfaab..559da7b 100644 --- a/grace/generator.py +++ b/grace/generator.py @@ -28,6 +28,7 @@ def generator() -> Generator: from grace.exceptions import GeneratorError, ValidationError, NoTemplateError from cookiecutter.main import cookiecutter from jinja2 import Environment, PackageLoader, Template +from typing import Any def register_generators(command_group: Group): @@ -69,7 +70,7 @@ class Generator(Command): - `NAME`: The name of the generator command (must be defined by subclasses). - `OPTIONS`: A dictionary of additional Click options for the command. """ - NAME: str = None + NAME: str | None = None OPTIONS: dict = { } @@ -108,14 +109,14 @@ 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] = {}): """Generates a template using Cookiecutter. :param template_dir: The name of the template to generate. :type template_dir: str :param variables: The variables to pass to the template. (default is {}) - :type variables: dict[str, any] + :type variables: dict[str, Any] """ template = str(self.templates_path / template_dir) cookiecutter(template, extra_context=variables, no_input=True) @@ -123,7 +124,7 @@ def generate_template(self, template_dir: str, variables: dict[str, any] = {}): def generate_file( self, template_dir: str, - variables: dict[str, any] = {}, + variables: dict[str, Any] = {}, output_dir: str = "" ): """Generate a module using jinja2 template. @@ -132,13 +133,13 @@ def generate_file( :type template_dir: str :param variables: The variables to pass to the template. (default is {}) - :type variables: dict[str, any] + :type variables: dict[str, Any] :param output_dir: The output directory for the generated template. (default is None) :type output_dir: str """ env = Environment( - loader=PackageLoader("grace", self.templates_path / template_dir), + loader=PackageLoader('grace', str(self.templates_path / template_dir)), extensions=['jinja2_strcase.StrcaseExtension'] ) diff --git a/pyproject.toml b/pyproject.toml index d973f63..111e9f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "alembic==1.8.1", "cookiecutter", "jinja2-strcase", + "jinja2_pluralize", "mypy", "pytest", "flake8", @@ -43,3 +44,7 @@ packages = ["grace"] [tool.mypy] exclude = ['grace/generators/templates'] + +[[tool.mypy.overrides]] +module = ["jinja2_pluralize.*", "cookiecutter.*"] +follow_untyped_imports = true \ No newline at end of file From 258c01a6c3d5876ee6d914f96b51a0b8b923ae1a Mon Sep 17 00:00:00 2001 From: penguinboi Date: Mon, 10 Mar 2025 12:26:38 -0400 Subject: [PATCH 2/5] Added model generator --- grace/generator.py | 4 ++ grace/generators/model_generator.py | 60 +++++++++++++++++++ .../model/{{ model_name | to_snake }}.py | 9 +++ pyproject.toml | 2 +- 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 grace/generators/model_generator.py create mode 100644 grace/generators/templates/model/{{ model_name | to_snake }}.py diff --git a/grace/generator.py b/grace/generator.py index 559da7b..b5fa8df 100644 --- a/grace/generator.py +++ b/grace/generator.py @@ -22,6 +22,9 @@ def generator() -> Generator: ``` """ +import inflect + + from click import Command, Group from pathlib import Path from grace.importer import import_package_modules @@ -144,6 +147,7 @@ def generate_file( ) env.filters['camel_case_to_space'] = _camel_case_to_space + env.filters['pluralize'] = lambda w: inflect.engine().plural(w) if not env.list_templates(): raise NoTemplateError(f"No templates found in {template_dir}") diff --git a/grace/generators/model_generator.py b/grace/generators/model_generator.py new file mode 100644 index 0000000..499621f --- /dev/null +++ b/grace/generators/model_generator.py @@ -0,0 +1,60 @@ +from grace.generator import Generator +from re import match +from logging import info +from click.core import Argument + + +class ModelGenerator(Generator): + NAME: str = 'model' + OPTIONS: dict = { + "params": [ + Argument(["name"], type=str), + Argument(["params"], type=str, nargs=-1) + ], + } + + def generate(self, name: str, params: tuple[str]): + info(f"Creating model '{name}'") + + columns, types = self.extract_columns(params) + model_columns = map(lambda c: f"{c[0]} = Column({c[1]})", columns) + + self.generate_file( + self.NAME, + variables={ + "model_name": name, + "model_columns": model_columns, + "model_column_types": types + }, + output_dir="bot/models" + ) + + def validate(self, name: str, **_kwargs) -> bool: + """Validate the cog name. + + A valid project name must be 'PascalCase' and contain only + - letters [Aa - Zz] + - numbers [0-9] + + Example: + - HelloWorld + """ + return bool(match(r'^[A-Z][a-zA-Z0-9]*$', name)) + + def extract_columns(self, params: tuple[str]) -> tuple[list, list]: + columns = [] + types = [] + + for param in params: + name, type = param.split(':') + + if type not in types and type != 'Integer': + types.append(type) + + columns.append((name, type)) + + return columns, types + + +def generator() -> Generator: + return ModelGenerator() \ No newline at end of file diff --git a/grace/generators/templates/model/{{ model_name | to_snake }}.py b/grace/generators/templates/model/{{ model_name | to_snake }}.py new file mode 100644 index 0000000..b40ae37 --- /dev/null +++ b/grace/generators/templates/model/{{ model_name | to_snake }}.py @@ -0,0 +1,9 @@ +from sqlalchemy import Column, Integer{{ ", {}".format(','.join(model_column_types)) }} +from bot import app +from grace.model import Model + +class {{ model_name | to_camel }}(app.base, Model): + __tablename__ = "{{ model_name | to_snake | pluralize }}" + + id = Column(Integer, primary_key=True) + {{ model_columns | join('\n ') }} diff --git a/pyproject.toml b/pyproject.toml index 111e9f0..5b5a7f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "alembic==1.8.1", "cookiecutter", "jinja2-strcase", - "jinja2_pluralize", + "inflect", "mypy", "pytest", "flake8", From 357bf8f77a8bcb0d94d6ce602fb8eea875f42b10 Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 5 Apr 2025 07:23:06 -0400 Subject: [PATCH 3/5] Improved model doc --- grace/generators/model_generator.py | 34 ++++++++++++++----- .../model/{{ model_name | to_snake }}.py | 1 + 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/grace/generators/model_generator.py b/grace/generators/model_generator.py index 499621f..fe09d77 100644 --- a/grace/generators/model_generator.py +++ b/grace/generators/model_generator.py @@ -14,7 +14,21 @@ class ModelGenerator(Generator): } def generate(self, name: str, params: tuple[str]): - info(f"Creating model '{name}'") + """Generate a new model file along its migration. + + The model will be created in the `bot/models` directory with + a SQLAlchemy-style definition. You can specify column names and types + during generation using the format `column_name:Type`. + + Supported types are any valid SQLAlchemy column types (e.g., `String`, `Integer`, + `Boolean`, etc.). See https://docs.sqlalchemy.org/en/20/core/types.html + + Example: + ```bash + grace generate model Greeting message:String lang:String + ``` + """ + info(f"Generating model '{name}'") columns, types = self.extract_columns(params) model_columns = map(lambda c: f"{c[0]} = Column({c[1]})", columns) @@ -29,26 +43,30 @@ def generate(self, name: str, params: tuple[str]): output_dir="bot/models" ) + generate_migration(self.app, f"Create {name}") + def validate(self, name: str, **_kwargs) -> bool: - """Validate the cog name. + """Validate the model name. - A valid project name must be 'PascalCase' and contain only - - letters [Aa - Zz] - - numbers [0-9] + A valid model name must: + - Start with an uppercase letter. + - Contain only letters (A-Z, a-z) and numbers (0-9). - Example: + Examples of valid names: - HelloWorld + - User123 + - ProductItem """ return bool(match(r'^[A-Z][a-zA-Z0-9]*$', name)) def extract_columns(self, params: tuple[str]) -> tuple[list, list]: columns = [] - types = [] + types = ['Integer'] for param in params: name, type = param.split(':') - if type not in types and type != 'Integer': + if type not in types: types.append(type) columns.append((name, type)) diff --git a/grace/generators/templates/model/{{ model_name | to_snake }}.py b/grace/generators/templates/model/{{ model_name | to_snake }}.py index b40ae37..d6f2a71 100644 --- a/grace/generators/templates/model/{{ model_name | to_snake }}.py +++ b/grace/generators/templates/model/{{ model_name | to_snake }}.py @@ -2,6 +2,7 @@ from bot import app from grace.model import Model + class {{ model_name | to_camel }}(app.base, Model): __tablename__ = "{{ model_name | to_snake | pluralize }}" From 6908bb45b17e7ca6d7906d10e2126f639bf1a0da Mon Sep 17 00:00:00 2001 From: penguinboi Date: Sat, 5 Apr 2025 07:23:48 -0400 Subject: [PATCH 4/5] Added basic version of migration generation --- grace/cli.py | 27 ++++++++++++++++- grace/config.py | 9 ++---- grace/database.py | 36 ++++++++++++++++++++++ grace/generator.py | 9 +++++- grace/generators/migration_generator.py | 40 +++++++++++++++++++++++++ grace/generators/model_generator.py | 1 + 6 files changed, 114 insertions(+), 8 deletions(-) create mode 100644 grace/database.py create mode 100644 grace/generators/migration_generator.py diff --git a/grace/cli.py b/grace/cli.py index 3f5403f..8956f8a 100644 --- a/grace/cli.py +++ b/grace/cli.py @@ -5,6 +5,7 @@ from logging import info, warning, critical from click import group, argument, option, pass_context, echo from grace.generator import register_generators +from grace.database import up_migration, down_migration from textwrap import dedent @@ -46,8 +47,8 @@ def new(ctx, name, database=True): def app_cli(ctx, environment): app = ctx.obj["app"] - register_generators(generate) app.load(environment) + register_generators(generate) @app_cli.group() @@ -110,6 +111,30 @@ def seed(ctx): seed.seed_database() +# TODO: Add revision # +@db.command() +@pass_context +def up(ctx): + app = ctx.obj["app"] + + if not app.database_exists: + return warning("Database does not exist") + + up_migration(app) + + +# TODO: Add revision # +@db.command() +@pass_context +def down(ctx): + app = ctx.obj["app"] + + if not app.database_exists: + return warning("Database does not exist") + + down_migration(app) + + def _load_database(app): if not app.database_exists: app.create_database() diff --git a/grace/config.py b/grace/config.py index 70e476f..4a4d51d 100644 --- a/grace/config.py +++ b/grace/config.py @@ -101,7 +101,7 @@ def database_uri(self) -> Union[str, URL]: return self.database.get("url") return URL.create( - self.database.get("adapter"), + self.database.get("adapter", "sqlite"), self.database.get("user"), self.database.get("password"), self.database.get("host"), @@ -139,10 +139,7 @@ def get(self, section_key, value_key, fallback=None) -> Optional[Union[str, int, def set_environment(self, environment: str): """Set the environment for the configuration. - :param environment: The environment to set. (Production, Development, Test) + :param environment: The environment to set. :type environment: str """ - if environment in ["production", "development", "test"]: - self.__environment = environment - else: - raise EnvironmentError("You need to pass a valid environment. [Production, Development, Test]") + self.__environment = environment diff --git a/grace/database.py b/grace/database.py new file mode 100644 index 0000000..b2ff12c --- /dev/null +++ b/grace/database.py @@ -0,0 +1,36 @@ +from alembic.config import Config +from alembic.command import revision, upgrade, downgrade, show +from alembic.util.exc import CommandError +from logging import info, fatal + +from alembic.script import ScriptDirectory + + +def generate_migration(app, message): + alembic_cfg = Config("alembic.ini") + alembic_cfg.config_ini_section = app.config.current_environment + + try: + revision( + alembic_cfg, + message=message, + autogenerate=True, + sql=False, + head='base' + ) + except CommandError as e: + fatal(f"Error creating migration: {e}") + + +def up_migration(app, revision='head'): + alembic_cfg = Config("alembic.ini") + alembic_cfg.config_ini_section = app.config.current_environment + + upgrade(alembic_cfg, revision=revision) + + +def down_migration(app, revision='head'): + alembic_cfg = Config("alembic.ini") + alembic_cfg.config_ini_section = app.config.current_environment + + upgrade(alembic_cfg, revision=revision) diff --git a/grace/generator.py b/grace/generator.py index b5fa8df..eb3fc68 100644 --- a/grace/generator.py +++ b/grace/generator.py @@ -27,6 +27,7 @@ def generator() -> Generator: from click import Command, Group from pathlib import Path +from grace.application import Application from grace.importer import import_package_modules from grace.exceptions import GeneratorError, ValidationError, NoTemplateError from cookiecutter.main import cookiecutter @@ -84,6 +85,8 @@ def __init__(self): :raises GeneratorError: If the `NAME` attribute is not defined. """ + self.app: Application | None = None + if not self.NAME: raise GeneratorError("Generator name must be defined.") @@ -93,6 +96,10 @@ def __init__(self): def templates_path(self) -> Path: return Path(__file__).parent / 'generators' / 'templates' + def invoke(self, ctx): + self.app = ctx.obj.get("app") + return super().invoke(ctx) + def generate(self, *args, **kwargs): """Generates template. @@ -159,4 +166,4 @@ def generate_file( rendered_content = template.render(variables) with open(f"{output_dir}/{rendered_filename}", "w") as file: - file.write(rendered_content) \ No newline at end of file + file.write(rendered_content) diff --git a/grace/generators/migration_generator.py b/grace/generators/migration_generator.py new file mode 100644 index 0000000..f318302 --- /dev/null +++ b/grace/generators/migration_generator.py @@ -0,0 +1,40 @@ +from grace.generator import Generator +from click.core import Argument +from logging import info +from grace.database import generate_migration + + +class MigrationGenerator(Generator): + NAME: str = 'migration' + OPTIONS: dict = { + "params": [ + Argument(["message"], type=str), + ], + } + + def generate(self, message: str): + """Generates a new Alembic migration using autogenerate. + + Example: + ```bash + grace generate migration "Add Greeting model" + ``` + """ + info(f"Generating migration '{message}'") + generate_migration(self.app, message) + + def validate(self, message: str, **_kwargs) -> bool: + """ + Validate the migration message. + + The message must be a non-empty string. + + Example: + - AddUserModel + - add_email_to_users + """ + return bool(message.strip()) + + +def generator() -> Generator: + return MigrationGenerator() diff --git a/grace/generators/model_generator.py b/grace/generators/model_generator.py index fe09d77..f8ec577 100644 --- a/grace/generators/model_generator.py +++ b/grace/generators/model_generator.py @@ -2,6 +2,7 @@ from re import match from logging import info from click.core import Argument +from grace.generators.migration_generator import generate_migration class ModelGenerator(Generator): From 5a731ad1d13f869606579ca254dd1aaef2c3e92e Mon Sep 17 00:00:00 2001 From: penguinboi Date: Tue, 8 Apr 2025 12:26:37 -0400 Subject: [PATCH 5/5] Added revision number argument db up and db down --- grace/cli.py | 14 +++++++------- grace/database.py | 9 ++++++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/grace/cli.py b/grace/cli.py index 8956f8a..4ca33bc 100644 --- a/grace/cli.py +++ b/grace/cli.py @@ -111,28 +111,28 @@ def seed(ctx): seed.seed_database() -# TODO: Add revision # @db.command() +@argument("revision", default='head') @pass_context -def up(ctx): +def up(ctx, revision): app = ctx.obj["app"] if not app.database_exists: return warning("Database does not exist") - up_migration(app) + up_migration(app, revision) -# TODO: Add revision # @db.command() +@argument("revision", default='head') @pass_context -def down(ctx): +def down(ctx, revision): app = ctx.obj["app"] if not app.database_exists: return warning("Database does not exist") - down_migration(app) + down_migration(app, revision) def _load_database(app): @@ -159,4 +159,4 @@ def main(): from bot import app, bot app_cli(obj={"app": app, "bot": bot}) except ImportError: - cli() \ No newline at end of file + cli() diff --git a/grace/database.py b/grace/database.py index b2ff12c..93d79a8 100644 --- a/grace/database.py +++ b/grace/database.py @@ -15,14 +15,15 @@ def generate_migration(app, message): alembic_cfg, message=message, autogenerate=True, - sql=False, - head='base' + sql=False ) except CommandError as e: fatal(f"Error creating migration: {e}") def up_migration(app, revision='head'): + info(f"Upgrading revision {revision}") + alembic_cfg = Config("alembic.ini") alembic_cfg.config_ini_section = app.config.current_environment @@ -30,7 +31,9 @@ def up_migration(app, revision='head'): def down_migration(app, revision='head'): + info(f"Downgrading revision {revision}") + alembic_cfg = Config("alembic.ini") alembic_cfg.config_ini_section = app.config.current_environment - upgrade(alembic_cfg, revision=revision) + downgrade(alembic_cfg, revision=revision)