From 220a818970bd3fcba5274d6578e55be7744a0532 Mon Sep 17 00:00:00 2001 From: mdnapo Date: Sun, 9 Jun 2024 20:35:54 +0200 Subject: [PATCH] Improved db feature and adding documentation --- .../Commands/MycroForge.Db.Generate.cs | 7 +- MycroForge.CLI/Commands/MycroForge.Db.Run.cs | 29 ++ MycroForge.CLI/Commands/MycroForge.Db.Stop.cs | 29 ++ .../Extensions/ServiceCollectionExtensions.cs | 2 + MycroForge.CLI/Features/Api.cs | 9 +- MycroForge.CLI/Features/Db.cs | 68 +++-- MycroForge.CLI/ProjectConfig.DbConfig.cs | 10 + MycroForge.CLI/ProjectConfig.cs | 1 + MycroForge.CLI/ProjectContext.cs | 2 + README.md | 10 +- docs/README.md | 59 ++++ docs/todo-example/README.md | 280 ++++++++---------- 12 files changed, 318 insertions(+), 188 deletions(-) create mode 100644 MycroForge.CLI/Commands/MycroForge.Db.Run.cs create mode 100644 MycroForge.CLI/Commands/MycroForge.Db.Stop.cs create mode 100644 MycroForge.CLI/ProjectConfig.DbConfig.cs create mode 100644 docs/README.md diff --git a/MycroForge.CLI/Commands/MycroForge.Db.Generate.cs b/MycroForge.CLI/Commands/MycroForge.Db.Generate.cs index b4349f3..8404291 100644 --- a/MycroForge.CLI/Commands/MycroForge.Db.Generate.cs +++ b/MycroForge.CLI/Commands/MycroForge.Db.Generate.cs @@ -9,14 +9,13 @@ public partial class MycroForge { public partial class Generate : Command, ISubCommandOf { - public Generate(IEnumerable> subCommands) : + public Generate(IEnumerable> commands) : base("generate", "Generate a database item") { AddAlias("g"); - foreach (var subCommandOf in subCommands.Cast()) - AddCommand(subCommandOf); + foreach (var command in commands.Cast()) + AddCommand(command); } - } } } \ No newline at end of file diff --git a/MycroForge.CLI/Commands/MycroForge.Db.Run.cs b/MycroForge.CLI/Commands/MycroForge.Db.Run.cs new file mode 100644 index 0000000..ef8ced0 --- /dev/null +++ b/MycroForge.CLI/Commands/MycroForge.Db.Run.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using MycroForge.CLI.Commands.Interfaces; + +namespace MycroForge.CLI.Commands; + +public partial class MycroForge +{ + public partial class Db + { + public partial class Run : Command, ISubCommandOf + { + private readonly ProjectContext _context; + + public Run(ProjectContext context) : + base("run", $"Runs {Features.Db.FeatureName}.docker-compose.yml") + { + _context = context; + this.SetHandler(ExecuteAsync); + } + + private async Task ExecuteAsync() + { + var config = await _context.LoadConfig(); + var env = $"DB_PORT={config.Db.DbPort} PMA_PORT={config.Db.PmaPort}"; + await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d"); + } + } + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Commands/MycroForge.Db.Stop.cs b/MycroForge.CLI/Commands/MycroForge.Db.Stop.cs new file mode 100644 index 0000000..cd835cd --- /dev/null +++ b/MycroForge.CLI/Commands/MycroForge.Db.Stop.cs @@ -0,0 +1,29 @@ +using System.CommandLine; +using MycroForge.CLI.Commands.Interfaces; + +namespace MycroForge.CLI.Commands; + +public partial class MycroForge +{ + public partial class Db + { + public partial class Stop : Command, ISubCommandOf + { + private readonly ProjectContext _context; + + public Stop(ProjectContext context) : + base("stop", $"Stops {Features.Db.FeatureName}.docker-compose.yml") + { + _context = context; + this.SetHandler(ExecuteAsync); + } + + private async Task ExecuteAsync() + { + var config = await _context.LoadConfig(); + var env = $"DB_PORT={config.Db.DbPort} PMA_PORT={config.Db.PmaPort}"; + await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml down"); + } + } + } +} \ No newline at end of file diff --git a/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs b/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs index 421f627..2208288 100644 --- a/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs +++ b/MycroForge.CLI/Extensions/ServiceCollectionExtensions.cs @@ -35,6 +35,8 @@ public static class ServiceCollectionExtensions // Register "m4g db" services.AddScoped, Commands.MycroForge.Db>(); + services.AddScoped, Commands.MycroForge.Db.Run>(); + services.AddScoped, Commands.MycroForge.Db.Stop>(); services.AddScoped, Commands.MycroForge.Db.Migrate>(); services.AddScoped, Commands.MycroForge.Db.Rollback>(); services.AddScoped, Commands.MycroForge.Db.Generate>(); diff --git a/MycroForge.CLI/Features/Api.cs b/MycroForge.CLI/Features/Api.cs index abadfba..1f688b0 100644 --- a/MycroForge.CLI/Features/Api.cs +++ b/MycroForge.CLI/Features/Api.cs @@ -37,6 +37,8 @@ public sealed class Api : IFeature public async Task ExecuteAsync(ProjectContext context) { var config = await context.LoadConfig(); + config.Api = new() { Port = 8000 }; + await context.SaveConfig(config); await context.Bash( "source .venv/bin/activate", @@ -49,12 +51,5 @@ public sealed class Api : IFeature var main = await context.ReadFile("main.py"); main = string.Join('\n', MainTemplate) + main; await context.WriteFile("main.py", main); - - config.Api = new() - { - Port = 8000 - }; - - await context.SaveConfig(config); } } \ No newline at end of file diff --git a/MycroForge.CLI/Features/Db.cs b/MycroForge.CLI/Features/Db.cs index 230c031..e118df8 100644 --- a/MycroForge.CLI/Features/Db.cs +++ b/MycroForge.CLI/Features/Db.cs @@ -10,10 +10,8 @@ public sealed class Db : IFeature [ "class DbSettings:", "\tdef get_connectionstring() -> str:", - "\t\t# Example", - "\t\t# connectionstring = \"mysql+asyncmy://root:root@localhost:3306/your_database\"", - "\t\t# return connectionstring", - "\t\traise Exception(\"DbSettings.get_connectionstring was not implemented.\")", + "\t\tconnectionstring = \"mysql+asyncmy://root:password@localhost:%db_port%/%app_name%\"", + "\t\treturn connectionstring", ]; private static readonly string[] AsyncSession = @@ -35,20 +33,44 @@ public sealed class Db : IFeature "\tpass" ]; - private static readonly string[] User = + private static readonly string[] DockerCompose = [ - "from sqlalchemy import String", - "from sqlalchemy.orm import Mapped, mapped_column", - $"from {FeatureName}.entities.entity_base import EntityBase", + "version: '3.8'", + "# Access the database UI at http://localhost:${DB_PORT}.", + "# Login: username = root & password = password", "", - "class User(EntityBase):", - "\t__tablename__ = \"users\"", - "\tid: Mapped[int] = mapped_column(primary_key=True)", - "\tfirstname: Mapped[str] = mapped_column(String(255))", - "\tlastname: Mapped[str] = mapped_column(String(255))", + "services:", + " %app_name%_mariadb:", + " image: 'mariadb:10.4'", + " container_name: '%app_name%_mariadb'", + " networks:", + " - default", + " ports:", + " - '${DB_PORT}:3306'", + " environment:", + " MYSQL_ROOT_PASSWORD: 'password'", + " MYSQL_USER: 'root'", + " MYSQL_PASSWORD: 'password'", + " MYSQL_DATABASE: '%app_name%'", + " volumes:", + " - '%app_name%_mariadb:/var/lib/mysql'", "", - "\tdef __repr__(self) -> str:", - "\t\treturn f\"User(id={self.id!r}, firstname={self.firstname!r}, lastname={self.lastname!r})\"" + " %app_name%_phpmyadmin:", + " image: phpmyadmin/phpmyadmin", + " container_name: %app_name%_phpmyadmin", + " ports:", + " - '${PMA_PORT}:80'", + " networks:", + " - default", + " environment:", + " PMA_HOST: %app_name%_mariadb", + " PMA_PORT: 3306", + " PMA_ARBITRARY: 1", + " links:", + " - %app_name%_mariadb", + "", + "volumes:", + " %app_name%_mariadb:\n", ]; #endregion @@ -60,6 +82,10 @@ public sealed class Db : IFeature public async Task ExecuteAsync(ProjectContext context) { var config = await context.LoadConfig(); + config.Db = new() { DbPort = 5050, PmaPort = 5051 }; + await context.SaveConfig(config); + + var appName = context.AppName; await context.Bash( "source .venv/bin/activate", @@ -70,17 +96,21 @@ public sealed class Db : IFeature var env = await context.ReadFile($"{FeatureName}/env.py"); env = new DbEnvInitializer(env).Rewrite(); - // env = new DbEnvUpdater(env, "user", "User").Rewrite(); await context.WriteFile($"{FeatureName}/env.py", env); - await context.CreateFile($"{FeatureName}/settings.py", Settings); + var settings = string.Join('\n', Settings) + .Replace("%app_name%", appName) + .Replace("%db_port%", config.Db.DbPort.ToString()) + ; + + await context.CreateFile($"{FeatureName}/settings.py", settings); await context.CreateFile($"{FeatureName}/engine/async_session.py", AsyncSession); await context.CreateFile($"{FeatureName}/entities/entity_base.py", EntityBase); - // await context.CreateFile($"{FeatureName}/entities/user.py", User); + var dockerCompose = string.Join('\n', DockerCompose).Replace("%app_name%", appName); - await context.SaveConfig(config); + await context.CreateFile($"{FeatureName}.docker-compose.yml", dockerCompose); } } \ No newline at end of file diff --git a/MycroForge.CLI/ProjectConfig.DbConfig.cs b/MycroForge.CLI/ProjectConfig.DbConfig.cs new file mode 100644 index 0000000..4ba2d72 --- /dev/null +++ b/MycroForge.CLI/ProjectConfig.DbConfig.cs @@ -0,0 +1,10 @@ +namespace MycroForge.CLI; + +public partial class ProjectConfig +{ + public class DbConfig + { + public int DbPort { get; set; } + public int PmaPort { get; set; } + } +} \ No newline at end of file diff --git a/MycroForge.CLI/ProjectConfig.cs b/MycroForge.CLI/ProjectConfig.cs index 80cf920..1f3f9e1 100644 --- a/MycroForge.CLI/ProjectConfig.cs +++ b/MycroForge.CLI/ProjectConfig.cs @@ -3,4 +3,5 @@ public partial class ProjectConfig { public ApiConfig Api { get; set; } = default!; + public DbConfig Db { get; set; } = default!; } \ No newline at end of file diff --git a/MycroForge.CLI/ProjectContext.cs b/MycroForge.CLI/ProjectContext.cs index c58f591..b381ae9 100644 --- a/MycroForge.CLI/ProjectContext.cs +++ b/MycroForge.CLI/ProjectContext.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.Text.Json; +using Humanizer; using MycroForge.CLI.Extensions; namespace MycroForge.CLI; @@ -7,6 +8,7 @@ namespace MycroForge.CLI; public class ProjectContext { public string RootDirectory { get; private set; } = Environment.CurrentDirectory; + public string AppName => Path.GetFileNameWithoutExtension(RootDirectory).Underscore().ToLower(); private string ConfigPath => Path.Combine(RootDirectory, "m4g.json"); diff --git a/README.md b/README.md index 493ff87..3bbe090 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ ### Dependencies -bash (/usr/bin/bash) - -Python 3.10.2 (/usr/bin/python3) -- python3-pip -- python3-venv +- Docker +- bash (/bin/bash) +- Python 3.10.2 (/usr/bin/python3) + - python3-pip + - python3-venv #### Note The MycroForge CLI assumes a linux compatible environment, so on Windows you'll have to use WSL. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..ea9162a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,59 @@ +# General introduction + +## What is MycroForge? + +MycroForge is an opinionated CLI tool that is meant to facilitate the development of FastAPI & SQLAlchemy based backends. +It provides a command line interface that allows users to generate a skeleton project and other common items like +database entities, migrations, routers and even basic CRUD functionality. The main purpose of this tool is to generate +boilerplate code and to provide a unified interface for performing recurrent development activities. + + +## Generating a project + +To generate a project you can run the following command. + +`m4g init ` + +``` +Description: + Initialize a new project + +Usage: + m4g init [options] + +Arguments: + The name of your project + +Options: + --without Features to exclude + -?, -h, --help Show help and usage information + +``` + +Running this command will generate + +``` +📦 + ┣ 📂.git + ┣ 📂.venv + ┣ 📂api + ┃ ┗ 📂routers + ┃ ┃ ┗ 📜hello.py + ┣ 📂db + ┃ ┣ 📂engine + ┃ ┃ ┗ 📜async_session.py + ┃ ┣ 📂entities + ┃ ┃ ┗ 📜entity_base.py + ┃ ┣ 📂versions + ┃ ┣ 📜README + ┃ ┣ 📜env.py + ┃ ┣ 📜script.py.mako + ┃ ┗ 📜settings.py + ┣ 📜.gitignore + ┣ 📜alembic.ini + ┣ 📜db.docker-compose.yml + ┣ 📜m4g.json + ┣ 📜main.py + ┗ 📜requirements.txt +``` + diff --git a/docs/todo-example/README.md b/docs/todo-example/README.md index 0c33296..432b390 100644 --- a/docs/todo-example/README.md +++ b/docs/todo-example/README.md @@ -1,6 +1,6 @@ # Quick tutorial -Summary: We're gonna build a todo app to demonstrate how the `m4g` command works.
+Summary: We're gonna build a todo app to demonstrate how the `m4g` command line tool works.
TODO: Supplement this section. ## Initialize the project @@ -12,71 +12,9 @@ this command. If you prefer using another editor, then manually open the generat ## Setup the database -Summary: This section explains how to interact with the database. -TODO: Supplement this section. +### Start the database -### Create a docker compose file for the database - -Create a file called `docker-compose.yml` in the root of your project with the following content. - -```yaml -version: '3.8' - -services: - # MariaDB for the database - todo_db: - image: 'mariadb:10.4' - container_name: 'todo_db' - restart: unless-stopped - networks: - - default - ports: - - '3306:3306' - environment: - MYSQL_ROOT_PASSWORD: 'root' - MYSQL_USER: 'root' - MYSQL_PASSWORD: 'root' - MYSQL_DATABASE: 'todo' - volumes: - - 'todo_db:/var/lib/mysql' - - # PhpMyAdmin for the database UI - phpmyadmin: - image: phpmyadmin/phpmyadmin - container_name: phpmyadmin - restart: unless-stopped - ports: - - 8888:80 - networks: - - default - environment: - PMA_HOST: todo_db - PMA_PORT: 3306 - PMA_ARBITRARY: 1 - links: - - todo_db - -volumes: - todo_db: -``` - -Run `docker compose up -d` to start the database containers.
-You should now be able to log into the database server at http://localhost:8888, with the following credentials.
-**username**: root, **password**: root - -### Update the database connectionstring - -Replace the contents of `db/settings.py` with the following. - -```python -class DbSettings: - def get_connectionstring() -> str: - connectionstring = "mysql+asyncmy://root:root@localhost:3306/todo" - return connectionstring -``` - -The connectionstring has been hardcoded for demo purposes. -In a production app, you would use some kind of secret manager to retrieve it. +`m4g db run` ### Create the entities @@ -96,7 +34,9 @@ In a production app, you would use some kind of secret manager to retrieve it. `m4g db migrate` -If you inspect the database in [PhpMyAdmin](http://localhost:8888), you should now see a populated schema. +If you inspect the database in [PhpMyAdmin](http://localhost:5051), you should now see a populated schema. + +To stop the database, you can run `m4g db stop` ## Setup the API @@ -106,126 +46,160 @@ If you inspect the database in [PhpMyAdmin](http://localhost:8888), you should n `m4g api generate crud Todo` +### Modify the generated Todo request classes + +Modify `CreateTodoRequest` in `api/requests/create_todo_request.py`, you might need to import `List` from `typing` + +```python +# Before +class CreateTodoRequest(BaseModel): + description: str = None + is_done: bool = None + +# After +class CreateTodoRequest(BaseModel): + description: str = None + is_done: bool = None + tag_ids: Optional[List[int]] = [] +``` + +Modify `UpdateTodoRequest` in `api/requests/update_todo_request.py`, you might need to import `List` from `typing` + +```python +# Before +class UpdateTodoRequest(BaseModel): + description: Optional[str] = None + is_done: Optional[bool] = None + tag_ids: Optional[List[int]] = [] + +# After +class UpdateTodoRequest(BaseModel): + description: Optional[str] = None + is_done: Optional[bool] = None + tag_ids: Optional[List[int]] = [] +``` + ### Modify generated TodoService -Add the following import in `api/services/todo_service.py`. +Add the following imports in `api/services/todo_service.py`. -`from sqlalchemy.orm import selectinload` +```python +from sqlalchemy.orm import selectinload +from db.entities.tag import Tag +``` Modify `TodoService.list` ```python # Before -async with async_session() as session: - stmt = select(Todo) - results = (await session.scalars(stmt)).all() - return results + async with async_session() as session: + stmt = select(Todo) + results = (await session.scalars(stmt)).all() + return results # After -async with async_session() as session: - stmt = select(Todo).options(selectinload(Todo.tags)) - results = (await session.scalars(stmt)).all() - return results + async with async_session() as session: + stmt = select(Todo).options(selectinload(Todo.tags)) + results = (await session.scalars(stmt)).all() + return results ``` Modify `TodoService.get_by_id` ```python # Before -async def get_by_id(self, id: int) -> Optional[Todo]: - async with async_session() as session: - stmt = select(Todo).where(Todo.id == id) - result = (await session.scalars(stmt)).first() - return result + async def get_by_id(self, id: int) -> Optional[Todo]: + async with async_session() as session: + stmt = select(Todo).where(Todo.id == id) + result = (await session.scalars(stmt)).first() + return result # After -async def get_by_id(self, id: int) -> Optional[Todo]: - async with async_session() as session: - stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) - result = (await session.scalars(stmt)).first() - return result + async def get_by_id(self, id: int) -> Optional[Todo]: + async with async_session() as session: + stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) + result = (await session.scalars(stmt)).first() + return result ``` Modify `TodoService.create` ```python # Before -async def create(self, data: Dict[str, Any]) -> None: - async with async_session() as session: - entity = Todo(**data) - session.add(entity) - await session.commit() - + async def create(self, data: Dict[str, Any]) -> None: + async with async_session() as session: + entity = Todo(**data) + session.add(entity) + await session.commit() # After -async def create(self, data: Dict[str, Any]) -> None: - tag_ids = [] - - if "tag_ids" in data.keys(): - tag_ids = data["tag_ids"] - del data["tag_ids"] - - async with async_session() as session: - entity = Todo(**data) - - if len(tag_ids) > 0: - stmt = select(Tag).where(Tag.id.in_(tag_ids)) - result = (await session.scalars(stmt)).all() - entity.tags = list(result) - - session.add(entity) - await session.commit() + async def create(self, data: Dict[str, Any]) -> None: + tag_ids = [] + + if "tag_ids" in data.keys(): + tag_ids = data["tag_ids"] + del data["tag_ids"] + + async with async_session() as session: + entity = Todo(**data) + + if len(tag_ids) > 0: + stmt = select(Tag).where(Tag.id.in_(tag_ids)) + result = (await session.scalars(stmt)).all() + entity.tags = list(result) + + session.add(entity) + await session.commit() ``` Modify `TodoService.update` ```python # Before -async def update(self, id: int, data: Dict[str, Any]) -> bool: - tag_ids = [] - - if "tag_ids" in data.keys(): - tag_ids = data["tag_ids"] - del data["tag_ids"] - - async with async_session() as session: - stmt = select(Todo).where(Todo.id == id) - entity = (await session.scalars(stmt)).first() - - if entity is None: - return False - else: - for key, value in data.items(): - setattr(entity, key, value) - await session.commit() - return True - + async def update(self, id: int, data: Dict[str, Any]) -> bool: + tag_ids = [] + + if "tag_ids" in data.keys(): + tag_ids = data["tag_ids"] + del data["tag_ids"] + + async with async_session() as session: + stmt = select(Todo).where(Todo.id == id) + entity = (await session.scalars(stmt)).first() + + if entity is None: + return False + else: + for key, value in data.items(): + setattr(entity, key, value) + await session.commit() + return True # After -async def update(self, id: int, data: Dict[str, Any]) -> bool: - tag_ids = [] - - if "tag_ids" in data.keys(): - tag_ids = data["tag_ids"] - del data["tag_ids"] - - async with async_session() as session: - stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) - entity = (await session.scalars(stmt)).first() - - if entity is None: - return False - else: - for key, value in data.items(): - setattr(entity, key, value) - - if len(tag_ids) > 0: - stmt = select(Tag).where(Tag.id.in_(tag_ids)) - result = (await session.scalars(stmt)).all() - entity.tags = list(result) + async def update(self, id: int, data: Dict[str, Any]) -> bool: + tag_ids = [] + + if "tag_ids" in data.keys(): + tag_ids = data["tag_ids"] + del data["tag_ids"] + + async with async_session() as session: + stmt = select(Todo).where(Todo.id == id).options(selectinload(Todo.tags)) + entity = (await session.scalars(stmt)).first() + + if entity is None: + return False else: - entity.tags = [] - - await session.commit() - return True + for key, value in data.items(): + setattr(entity, key, value) + + if len(tag_ids) > 0: + stmt = select(Tag).where(Tag.id.in_(tag_ids)) + result = (await session.scalars(stmt)).all() + entity.tags = list(result) + else: + entity.tags = [] + + await session.commit() + return True ```