Merge branch 'main' of github.com:mdnapo/mycroforge into main

This commit is contained in:
Donné Napo 2024-06-10 08:21:28 +02:00
commit 5ca27f7f72
12 changed files with 390 additions and 188 deletions

View File

@ -9,14 +9,13 @@ public partial class MycroForge
{
public partial class Generate : Command, ISubCommandOf<Db>
{
public Generate(IEnumerable<ISubCommandOf<Generate>> subCommands) :
public Generate(IEnumerable<ISubCommandOf<Generate>> commands) :
base("generate", "Generate a database item")
{
AddAlias("g");
foreach (var subCommandOf in subCommands.Cast<Command>())
AddCommand(subCommandOf);
foreach (var command in commands.Cast<Command>())
AddCommand(command);
}
}
}
}

View File

@ -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<Db>
{
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");
}
}
}
}

View File

@ -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<Db>
{
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");
}
}
}
}

View File

@ -35,6 +35,8 @@ public static class ServiceCollectionExtensions
// Register "m4g db"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Db>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Run>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Stop>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Migrate>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Rollback>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Db>, Commands.MycroForge.Db.Generate>();

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,10 @@
namespace MycroForge.CLI;
public partial class ProjectConfig
{
public class DbConfig
{
public int DbPort { get; set; }
public int PmaPort { get; set; }
}
}

View File

@ -3,4 +3,5 @@
public partial class ProjectConfig
{
public ApiConfig Api { get; set; } = default!;
public DbConfig Db { get; set; } = default!;
}

View File

@ -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");

View File

@ -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.

131
docs/README.md Normal file
View File

@ -0,0 +1,131 @@
# 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 <name>`
```
Description:
Initialize a new project
Usage:
m4g init <name> [options]
Arguments:
<name> The name of your project
Options:
--without <api|db|git> Features to exclude
-?, -h, --help Show help and usage information
```
Running this command will generate the following project structure.
```
📦<project_name>
┣ 📂.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
```
Let's go through these one by one.
### .git
The project is automatically initialised with git by the `m4g init` command.
If you don't want to use git you can run `m4g init <name> --without git`.
### .venv
To promote isolation of Python dependencies, the project is initialized with a virtual environment by default.
### api/routers/hello.py
This file defines a basic example router, which is imported and mapped in `main.py`.
### db/engine/async_session.py
This file defines the `async_session` function, which can be used to asynchronously connect to a database.
### db/entities/entity_base.py
This is the automatically generated base class that all database entities will inherit from.
SQLAlchemy requires a base entity class to properly function.
### db/entities/versions
This is where the generated database migrations will be kept.
### db/README
This README file is automatically generated by the alembic init command.
### db/env.py
This is the database environment file that is used by alembic to interact with the database.
If you take a close look at the imports, you'll see that the file has been modified to include the entity metadata,
i.e. `EntityBase.metadata` and you should also notice that the `DbSettings` class is used to get the connectionstring.
Any time you generate a new database entity or create a many-to-many relation between two entities, this file will also
be modified.
### db/script.py.mako
This file is automatically generated by the alembic init command.
### db/settings.py
This file defines a class that is responsible for retrieving the connectionstring.
### .gitignore
The default .gitignore file/
### alembic.ini
The alembic ini file
### db.docker-compose.yml
A docker compose file for running a database locally.
### m4g.json
The m4g config file, this file contains some configs that are used by the CLI,
for example the ports to map to the API and database.
### main.py
The entrypoint for the application.
### requirements.txt
The requirements file containing the Python dependencies.

View File

@ -1,6 +1,6 @@
# Quick tutorial
Summary: We're gonna build a todo app to demonstrate how the `m4g` command works.<br/>
Summary: We're gonna build a todo app to demonstrate how the `m4g` command line tool works.<br/>
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.<br/>
You should now be able to log into the database server at http://localhost:8888, with the following credentials.<br/>
**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
```