Compare commits

64 Commits

Author SHA1 Message Date
21f4e7765b Added uvicorn to API feature
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-24 11:52:37 +02:00
33ba944f8a Updated scripts
All checks were successful
Test MycroForge.CLI / test (push) Successful in 2m23s
2024-10-13 18:12:04 +02:00
05051878f2 Cleaning up versions and workflows
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-13 16:03:02 +02:00
3fff9c7dd0 Renamed build job to test 2024-10-13 15:34:33 +02:00
eda7992c23 Apply version 0.0.1
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 1m41s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-13 15:13:50 +02:00
457429f7ec Improved plugin feature
All checks were successful
Build and publish MycroForge.CLI / test (push) Has been skipped
Test MycroForge.CLI / test (push) Successful in 5m39s
2024-10-13 13:39:56 +02:00
8f82360cc7 Forget dollas symbol
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 40s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:28:39 +02:00
8396da2e9a Cleaned up build
Some checks failed
Test MycroForge.CLI / test (push) Has been cancelled
Build and publish MycroForge.CLI / test (push) Failing after 47s
2024-10-06 14:27:10 +02:00
5c7d07afb0 Fixed syntax error
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 41s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:20:47 +02:00
52d2507891 Listing nupkg directory
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 56s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:16:41 +02:00
7d677d27c6 Trying with full path
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 49s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:13:22 +02:00
0f85244681 Changed version variable reference
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 1m5s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:09:00 +02:00
d59bf264b0 Changed dotnet pack verbosity level
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 49s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:06:36 +02:00
9b339c738e Wrapped nuget push argument in quotes
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 42s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 14:00:31 +02:00
165d97245d Properly configured build actions
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 50s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 13:50:21 +02:00
a1ffc51a57 Moved docs to separate repo
Some checks failed
Build and publish MycroForge.CLI / test (push) Has been cancelled
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 13:27:22 +02:00
6464b9f8f3 Merged build & publish steps
Some checks failed
Test MycroForge.CLI / test (push) Has been skipped
Build and publish MycroForge.CLI / test (push) Failing after 41s
2024-10-06 12:52:12 +02:00
30d2ccba76 Fixed secret reference
Some checks failed
Build and publish MycroForge.CLI / test (push) Failing after 45s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:48:56 +02:00
7d61f76b94 Removed comment
All checks were successful
Build and publish MycroForge.CLI / test (push) Successful in 1m25s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:45:27 +02:00
38b83ac8ac Added version to .csproj
All checks were successful
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:43:45 +02:00
f8efd45076 Onelined add source command
All checks were successful
Build MycroForge.CLI / test (push) Successful in 39s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:31:49 +02:00
c1f618ad19 Merged adding source with the publishing
All checks were successful
Build MycroForge.CLI / test (push) Successful in 54s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:27:55 +02:00
5d49b9ab2d Added directory change to MycroForge.CLI
Some checks failed
Build MycroForge.CLI / test (push) Failing after 44s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:24:46 +02:00
bb3d75521e Testing build action
Some checks failed
Build MycroForge.CLI / test (push) Failing after 37s
Test MycroForge.CLI / test (push) Has been skipped
2024-10-06 12:19:20 +02:00
bc1f0fb943 Manually reference MycroForge.Core in MycroForge.PluginTemplate
All checks were successful
Test and build / test (push) Successful in 2m46s
2024-10-06 11:33:36 +02:00
e95bd759c3 Commented all steps except test
Some checks failed
Test and build / test (push) Failing after 41s
2024-10-06 11:24:53 +02:00
2918f87911 Changed line endings
Some checks failed
Test and build / test (push) Failing after 1m37s
2024-10-06 10:20:45 +02:00
33a86882ac Removed driver spec
Some checks are pending
Test and build / test (push) Waiting to run
2024-10-06 10:19:58 +02:00
d95ee39c0b Tried specifying driver too
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 10:00:58 +02:00
9109e9a4c7 Changed back to ubuntu-latest again
Some checks are pending
Test and build / test (push) Waiting to run
2024-10-06 09:55:52 +02:00
67a693b916 Changed .yaml to .yml and changed name back to default
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:53:57 +02:00
b8373f6908 Changed act runner label
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:46:15 +02:00
b14a2e7117 Fixed error in yaml
Some checks failed
Test and build / test (push) Has been cancelled
2024-10-06 09:41:57 +02:00
207345b54b Moved build file to workflows folder 2024-10-06 09:40:27 +02:00
dc8327289f Testing ci pipeline 2024-10-06 09:28:13 +02:00
a88f8a1f11 Added .gitea directory 2024-10-05 16:54:45 +02:00
f676f236b1 Added CLI test project and tests for 'm4g init' command 2024-10-05 13:37:14 +02:00
4f322e56c7 Updated README.md 2024-09-22 19:40:28 +02:00
4b0d1df34f Added missing example in tutorial 2024-09-22 17:33:47 +02:00
128ae21088 Clean up 2024-07-26 16:57:28 +02:00
6301bd438a Additional clean up 2024-07-25 07:35:06 +02:00
3f33035611 Cleaned up 2024-07-25 07:34:27 +02:00
32b7a3c01c Cleaned up project and added constraints for SQLAlchemy types in generate entity 2024-07-24 07:18:42 +02:00
91431fd996 Clean up 2024-07-23 22:04:51 +02:00
5ccb40bb44 Fixed exceptions and added constraints to commands 2024-07-23 21:46:11 +02:00
d210c6ac7c Cleaned up, tested and documented --dbu-platform 2024-07-22 21:27:17 +02:00
aa1c2422ef Ading comments and cleaning up 2024-07-22 06:24:45 +02:00
5698b504e9 Removed bash highlighting support 2024-07-20 00:33:02 +02:00
e2b2c82ff7 Optimised docs build and polished docs 2024-07-19 16:18:24 +02:00
7badcc333b Added command plugin documentation and renamed subCommands to commands in api command code 2024-07-19 15:05:20 +02:00
bf27a344e1 Improved docs 2024-07-19 13:08:59 +02:00
577d61ed42 Added deployment capabilities 2024-07-17 23:20:07 +02:00
777b0fccc8 Updated docs and minor cleanup 2024-07-15 22:49:31 +02:00
d6609fd335 Added .NET 8 as a requirement in install.md 2024-07-15 20:49:24 +02:00
9d1442b46b Supplemented commands documentation 2024-07-15 00:42:26 +02:00
be6b3691b4 Refactored FullyQualifiedName functionality 2024-07-14 23:08:47 +02:00
02a82589ae - Refactored init & features
- Extended documentation
2024-07-14 22:27:32 +02:00
2d4bdbceeb Cleaned up 2024-07-07 23:37:17 +02:00
9c3e2a25c0 Updated docs 2024-07-07 21:24:56 +02:00
3d96389f7f Cleaned up package build 2024-07-05 12:53:35 +02:00
ff9be76bf8 Added .gitkeep to templactes folder 2024-07-05 12:46:45 +02:00
1b6f0ee277 Cleaning up build process 2024-07-05 12:41:52 +02:00
7f67201bb2 Modified plugin system code 2024-07-05 12:35:36 +02:00
c220c214d2 Making plugin system more robust 2024-06-30 13:59:40 +02:00
91 changed files with 1700 additions and 921 deletions

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
# directories
**/bin/
**/obj/
**/out/
.git
.idea
docs
.gitignore
.dockerignore
MycroForge.sln.DotSettings.user
nuget.docker-compose.yml
README.md
# files
Dockerfile*
**/*.md
#MycroForge.PluginTemplate
#MycroForge.PluginTemplate.Package

View File

@@ -0,0 +1,31 @@
name: Build and publish MycroForge.CLI
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.CLI package
on: [ workflow_dispatch ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.CLI
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Get the path to the package
PACKAGE="nupkg/$(ls nupkg)"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@@ -0,0 +1,33 @@
name: Build and publish MycroForge.Core
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.Core package
on: [ workflow_dispatch ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.Core
dotnet publish
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Set the path to the package
VERSION=$(grep '<Version>' < MycroForge.Core.csproj | sed 's/.*<Version>\(.*\)<\/Version>/\1/' | xargs)
PACKAGE="bin/Release/MycroForge.Core.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@@ -0,0 +1,33 @@
name: Build and publish MycroForge.PluginTemplate package
run-name: ${{ gitea.actor }} triggered a build for the MycroForge.PluginTemplate package
on: [ workflow_dispatch ]
jobs:
build:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/main'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Build and publish NuGet package"
run: |
# Build the NuGet package
cd MycroForge.PluginTemplate.Package
dotnet publish
dotnet pack -v m
# Add the NuGet source
dotnet nuget add source --name devdisciples \
--username ${{ secrets.NUGET_USER }} \
--password ${{ secrets.NUGET_PASS }} \
--store-password-in-clear-text \
https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
# Set the path to the package
VERSION=$(grep '<PackageVersion>' < MycroForge.PluginTemplate.Package.csproj | sed 's/.*<PackageVersion>\(.*\)<\/PackageVersion>/\1/' | xargs)
PACKAGE="bin/Release/MycroForge.PluginTemplate.Package.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

20
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,20 @@
name: Test MycroForge.CLI
run-name: ${{ gitea.actor }} triggered a test for the MycroForge.CLI
on: [ push ]
jobs:
test:
runs-on: ubuntu-latest
if: gitea.ref == 'refs/heads/develop'
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
- name: "Reference MycroForge.Core in MycroForge.PluginTemplate"
# The MycroForge.PluginTemplate project references MycroForge.Core as a package and not as a reference.
# This allows the 'm4g plugin init' command to pull in the core package from a package repository.
# To prevent the test command from trying to pull from the package repository, we reference the local project.
run: dotnet add MycroForge.PluginTemplate reference MycroForge.Core
- name: "Run MycroForge.CLI.Tests"
run: dotnet test

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim
# Copy the files to the /tool directory.
WORKDIR /tool
COPY . .
# Manually add a reference to MycroForge.Core in the MycroForge.PluginTemplate project,
# otherwise it will try to fetch it from a nuget repository.
RUN dotnet add MycroForge.PluginTemplate reference MycroForge.Core
# Install the tools required for testing
RUN apt update -y && \
apt upgrade -y && \
apt install -y git && \
apt install -y bash && \
apt install -y python3 && \
apt install -y python3-pip && \
apt install -y python3-venv
# Publish the CLI as a global tool and add the .dotnet/tools folder the the PATH variable.
WORKDIR /tool/MycroForge.CLI
RUN ./scripts/publish-tool.sh
ENV PATH="$PATH:/root/.dotnet/tools"
WORKDIR /test
SHELL ["/bin/bash", "-c"]
ENV PATH="$PATH:/root/.dotnet/tools"
CMD ["sleep", "infinity"]

View File

@@ -0,0 +1,11 @@
using DotNet.Testcontainers.Containers;
namespace MycroForge.CLI.Tests.Extensions;
internal static class ContainerInterfaceExtensions
{
public static Task<byte[]> ReadFileFromRootAsync(this IContainer container, string file)
{
return container.ReadFileAsync($"/test/todo/{file}");
}
}

View File

@@ -0,0 +1 @@
global using NUnit.Framework;

View File

@@ -0,0 +1,222 @@
using System.Text;
using System.Text.Json;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using DotNet.Testcontainers.Images;
using MycroForge.CLI.Tests.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Tests;
public class InitCommandTests
{
private IFutureDockerImage Image = null!;
private IContainer Container = null!;
private async Task CreateImage()
{
Image = new ImageFromDockerfileBuilder()
.WithDockerfileDirectory(CommonDirectoryPath.GetSolutionDirectory(), string.Empty)
.WithDockerfile("Dockerfile")
.WithCleanUp(true)
.Build();
await Image.CreateAsync();
}
private async Task CreateContainer()
{
Container = new ContainerBuilder()
.WithImage(Image)
.WithCleanUp(true)
.Build();
await Container.StartAsync();
}
[OneTimeSetUp]
public async Task SetupOnce()
{
await CreateImage();
await CreateContainer();
await Container.ExecAsync(["m4g", "init", "todo"]);
}
[Test]
public async Task Creates_m4g_json()
{
var bytes = await Container.ReadFileFromRootAsync("m4g.json");
using var stream = new MemoryStream(bytes);
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(stream, DefaultJsonSerializerOptions.Default);
Assert.That(config, Is.Not.Null);
Assert.Multiple(() =>
{
Assert.That(config!.Api, Is.Not.Null);
Assert.That(config.Api.Port, Is.EqualTo(8000));
Assert.That(config.Db, Is.Not.Null);
Assert.That(config.Db.DbhPort, Is.EqualTo(5050));
Assert.That(config.Db.DbuPort, Is.EqualTo(5051));
});
}
[Test]
public async Task Creates_main_py()
{
var bytes = await Container.ReadFileFromRootAsync("main.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from fastapi import FastAPI"));
Assert.That(lines, Does.Contain("from api.routers import hello"));
Assert.That(lines, Does.Contain("app = FastAPI()"));
Assert.That(lines, Does.Contain("app.include_router(prefix=\"/hello\", router=hello.router)"));
});
}
[Test]
public async Task Creates_gitignore()
{
var bytes = await Container.ReadFileFromRootAsync(".gitignore");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain(".venv"));
Assert.That(lines, Does.Contain("__pycache__/"));
});
}
[Test]
public async Task Creates_alembic_ini()
{
var bytes = await Container.ReadFileFromRootAsync("alembic.ini");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("[alembic]"));
Assert.That(lines, Does.Contain("sqlalchemy.url = driver://user:pass@localhost/dbname"));
});
}
[Test]
public async Task Creates_db_docker_compose_yml()
{
var bytes = await Container.ReadFileFromRootAsync("db.docker-compose.yml");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain(" todo_mariadb:"));
Assert.That(lines, Does.Contain(" container_name: 'todo_mariadb'"));
Assert.That(lines, Does.Contain(" - '${DBH_PORT}:3306'"));
Assert.That(lines, Does.Contain(" MYSQL_DATABASE: 'todo'"));
Assert.That(lines, Does.Contain(" - 'todo_mariadb:/var/lib/mysql'"));
Assert.That(lines, Does.Contain(" todo_phpmyadmin:"));
Assert.That(lines, Does.Contain(" container_name: todo_phpmyadmin"));
Assert.That(lines, Does.Contain(" PMA_HOST: todo_mariadb"));
Assert.That(lines, Does.Contain(" - todo_mariadb"));
Assert.That(lines, Does.Contain(" todo_mariadb:"));
});
}
[Test]
public async Task Creates_api__router_hello_py()
{
var bytes = await Container.ReadFileFromRootAsync("api/routers/hello.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from fastapi import APIRouter"));
Assert.That(lines, Does.Contain("from fastapi.responses import JSONResponse"));
Assert.That(lines, Does.Contain("from fastapi.encoders import jsonable_encoder"));
Assert.That(lines, Does.Contain("router = APIRouter()"));
Assert.That(lines, Does.Contain("@router.get(\"/{name}\")"));
Assert.That(lines, Does.Contain("async def hello(name: str):"));
Assert.That(lines, Does.Contain("\treturn JSONResponse(status_code=200, content=jsonable_encoder({'greeting': f\"Hello, {name}!\"}))"));
});
}
[Test]
public async Task Creates_db__engine__async_session_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/engine/async_session.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession"));
Assert.That(lines, Does.Contain("from db.settings import DbSettings"));
Assert.That(lines, Does.Contain("async_engine: AsyncEngine = create_async_engine(DbSettings.get_connectionstring())"));
Assert.That(lines, Does.Contain("def async_session() -> AsyncSession:"));
Assert.That(lines, Does.Contain("\treturn AsyncSession(async_engine, expire_on_commit=False)"));
});
}
[Test]
public async Task Creates_db__entities__entity_base_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/entities/entity_base.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("from sqlalchemy.orm import DeclarativeBase"));
Assert.That(lines, Does.Contain("class EntityBase(DeclarativeBase):"));
Assert.That(lines, Does.Contain("\tpass"));
});
}
[Test]
public async Task Creates_db__settings_py()
{
var bytes = await Container.ReadFileFromRootAsync("db/settings.py");
var text = Encoding.UTF8.GetString(bytes);
var lines = text.Split('\n');
// TestContext.WriteLine(text);
Assert.Multiple(() =>
{
Assert.That(text, Is.Not.Empty);
Assert.That(lines, Does.Contain("class DbSettings:"));
Assert.That(lines, Does.Contain("\tdef get_connectionstring() -> str:"));
Assert.That(lines, Does.Contain("\t\tconnectionstring = \"mysql+asyncmy://root:password@localhost:5050/todo\""));
Assert.That(lines, Does.Contain("\t\treturn connectionstring"));
});
}
}

View File

@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="NUnit" Version="3.13.3"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="6.0.0"/>
<PackageReference Include="Testcontainers" Version="3.10.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MycroForge.Core\MycroForge.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,4 +1,5 @@
using Humanizer;
using MycroForge.CLI.Commands;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
@@ -6,6 +7,8 @@ namespace MycroForge.CLI.CodeGen;
public class CrudRouterGenerator
{
#region Templates
private static readonly string[] Template =
[
"from typing import Annotated",
@@ -20,7 +23,7 @@ public class CrudRouterGenerator
"",
"@router.get(\"/\")",
"async def list(",
"\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]",
"\tservice: Annotated[%service_class_name%, Depends()]",
"):",
"\ttry:",
"\t\tresult = await service.list()",
@@ -32,7 +35,7 @@ public class CrudRouterGenerator
"@router.get(\"/{id}\")",
"async def get_by_id(",
"\tid: int,",
"\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]",
"\tservice: Annotated[%service_class_name%, Depends()]",
"):",
"\ttry:",
"\t\tresult = await service.get_by_id(id)",
@@ -44,7 +47,7 @@ public class CrudRouterGenerator
"@router.post(\"/\")",
"async def create(",
"\trequest: Create%entity_class_name%Request,",
"\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]",
"\tservice: Annotated[%service_class_name%, Depends()]",
"):",
"\ttry:",
"\t\tawait service.create(request.model_dump())",
@@ -57,7 +60,7 @@ public class CrudRouterGenerator
"async def update(",
"\tid: int,",
"\trequest: Update%entity_class_name%Request,",
"\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]",
"\tservice: Annotated[%service_class_name%, Depends()]",
"):",
"\ttry:",
"\t\tupdated = await service.update(id, request.model_dump(exclude_unset=True))",
@@ -69,7 +72,7 @@ public class CrudRouterGenerator
"@router.delete(\"/{id}\")",
"async def delete(",
"\tid: int,",
"\tservice: Annotated[%service_class_name%, Depends(%service_class_name%)]",
"\tservice: Annotated[%service_class_name%, Depends()]",
"):",
"\ttry:",
"\t\tdeleted = await service.delete(id)",
@@ -79,6 +82,8 @@ public class CrudRouterGenerator
"\t\treturn JSONResponse(status_code=500, content=str(ex))",
];
#endregion
private readonly ProjectContext _context;
public CrudRouterGenerator(ProjectContext context)
@@ -86,65 +91,49 @@ public class CrudRouterGenerator
_context = context;
}
public async Task Generate(string path, string entity)
public async Task Generate(FullyQualifiedName fqn)
{
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var serviceClassName = $"{entityClassName}Service";
var entityRoutePrefix = entity.Kebaberize().Pluralize().ToLower();
var serviceClassName = $"{fqn.PascalizedName}Service";
var entityRoutePrefix = fqn.PascalizedName.Kebaberize().Pluralize().ToLower();
var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}";
var serviceFilePath = $"{servicesFolderPath}/{entitySnakeCaseName}_service.py";
var serviceImportPath = serviceFilePath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", string.Empty)
.DeduplicateDots()
.Trim();
var serviceFilePath = Path.Join(
Features.Api.FeatureName, "services", fqn.Namespace, $"{fqn.SnakeCasedName}_service"
);
var serviceImportPath = serviceFilePath.SlashesToDots();
var routerFolderPath = Path.Join(Features.Api.FeatureName, "routers", fqn.Namespace);
var routerFilePath = Path.Join(routerFolderPath, $"{fqn.SnakeCasedName}");
var routerImportPath = routerFolderPath.SlashesToDots();
var requestsFolderPath = Path.Join(Features.Api.FeatureName, "requests", fqn.Namespace);
var routersFolderPath = $"{Features.Api.FeatureName}/routers/{path}";
var routerFilePath = $"{routersFolderPath}/{entitySnakeCaseName}.py";
var routerImportPath = routersFolderPath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", "")
.DeduplicateDots()
.Trim();
var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}";
var createRequestImportPath = $"{requestsFolderPath}/Create{entityClassName}Request"
.Replace('/', '.')
.Replace('\\', '.')
.DeduplicateDots()
var createRequestImportPath = Path.Join(requestsFolderPath, $"Create{fqn.PascalizedName}Request")
.SlashesToDots()
.Underscore()
.ToLower();
var createRequestClassName = $"Create{entityClassName}Request";
var createRequestClassName = $"Create{fqn.PascalizedName}Request";
var updateRequestImportPath = $"{requestsFolderPath}/Update{entityClassName}Request"
.Replace('/', '.')
.Replace('\\', '.')
.DeduplicateDots()
var updateRequestImportPath = Path.Join(requestsFolderPath, $"Update{fqn.PascalizedName}Request")
.SlashesToDots()
.Underscore()
.ToLower();
var updateRequestClassName = $"Update{entityClassName}Request";
var updateRequestClassName = $"Update{fqn.PascalizedName}Request";
var router = string.Join("\n", Template)
.Replace("%service_import_path%", serviceImportPath)
.Replace("%entity_class_name%", entityClassName)
.Replace("%entity_class_name%", fqn.PascalizedName)
.Replace("%service_class_name%", serviceClassName)
.Replace("%create_entity_request_import_path%", createRequestImportPath)
.Replace("%create_entity_request_class_name%", createRequestClassName)
.Replace("%update_entity_request_import_path%", updateRequestImportPath)
.Replace("%update_entity_request_class_name%", updateRequestClassName);
await _context.CreateFile(routerFilePath, router);
await _context.CreateFile($"{routerFilePath}.py", router);
var main = await _context.ReadFile("main.py");
main = new MainModifier(main).Initialize()
.Import(from: routerImportPath, import: entitySnakeCaseName)
.IncludeRouter(prefix: entityRoutePrefix, router: entitySnakeCaseName)
.Import(from: routerImportPath, import: fqn.SnakeCasedName)
.IncludeRouter(prefix: entityRoutePrefix, router: fqn.SnakeCasedName)
.Rewrite();
await _context.WriteFile("main.py", main);

View File

@@ -1,5 +1,4 @@
using Humanizer;
using MycroForge.CLI.Extensions;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.CodeGen;
@@ -65,26 +64,15 @@ public class CrudServiceGenerator
_context = context;
}
public async Task Generate(string path, string entity)
public async Task Generate(FullyQualifiedName fqn)
{
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var entityImportPath = fqn.GetImportPath(root: [Features.Db.FeatureName, "entities"]);
var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}";
var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py";
var entityImportPath = entityFilePath
.Replace('/', '.')
.Replace('\\', '.')
.Replace(".py", string.Empty)
.DeduplicateDots()
.Trim();
var servicesFolderPath = $"{Features.Api.FeatureName}/services/{path}";
var serviceFilePath = $"{servicesFolderPath}/{entity.Underscore().ToLower()}_service.py";
var serviceFilePath = Path.Join(Features.Api.FeatureName, "services", $"{fqn.FilePath}_service.py");
var service = string.Join("\n", Template)
.Replace("%entity_import_path%", entityImportPath)
.Replace("%entity_class_name%", entityClassName)
.Replace("%entity_class_name%", fqn.PascalizedName)
;
await _context.CreateFile(serviceFilePath, service);

View File

@@ -1,4 +1,5 @@
using Humanizer;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.CodeGen;
@@ -95,14 +96,18 @@ public partial class EntityLinker
var left = await LoadEntity(_left);
var right = await LoadEntity(_right);
var associationTable = string.Join('\n', AssociationTable);
associationTable = associationTable
var associationTable = string.Join('\n', AssociationTable)
.Replace("%left_entity%", left.ClassName.Underscore().ToLower())
.Replace("%right_entity%", right.ClassName.Underscore().ToLower())
.Replace("%left_table%", left.TableName)
.Replace("%right_table%", right.TableName);
var associationTablePath =
$"{Features.Db.FeatureName}/entities/associations/{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py";
var associationTablePath = Path.Join(
Features.Db.FeatureName,
"entities",
"associations",
$"{left.TableName.Singularize()}_{right.TableName.Singularize()}_mapping.py"
);
await _context.CreateFile(associationTablePath, associationTable);
@@ -135,24 +140,26 @@ public partial class EntityLinker
var env = await _context.ReadFile($"{Features.Db.FeatureName}/env.py");
env = new DbEnvModifier(env, associationTableImportPath, associationTableImportName).Rewrite();
await _context.WriteFile($"{Features.Db.FeatureName}/env.py", env);
var main = await _context.ReadFile("main.py");
main = new MainModifier(main).Initialize().Import(associationTableImportPath, associationTableImportName).Rewrite();
main = new MainModifier(main)
.Initialize()
.Import(associationTableImportPath, associationTableImportName)
.Rewrite();
await _context.WriteFile("main.py", main);
}
private async Task<EntityModel> LoadEntity(string name)
{
var path = $"{Features.Db.FeatureName}/entities";
var fqn = new FullyQualifiedName(name);
var path = Path.Join(Features.Db.FeatureName, "entities");
if (name.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName)
{
path = Path.Combine(path, fullName[0]);
name = fullName[1];
}
if (fqn.HasNamespace)
path = Path.Join(path, fqn.Namespace);
path = Path.Combine(path, $"{name.Underscore().ToLower()}.py");
var entity = new EntityModel(name, path, await _context.ReadFile(path));
path = Path.Join(path, $"{fqn.SnakeCasedName}.py");
var entity = new EntityModel(fqn.PascalizedName, path, await _context.ReadFile(path));
entity.Initialize();
return entity;
}

View File

@@ -38,12 +38,14 @@ public class MainModifier
public MainModifier IncludeRouter(string prefix, string router)
{
_routerIncludeBuffer.Add($"\napp.include_router(prefix=\"/{prefix}\", router={router}.router)\n");
_routerIncludeBuffer.Add($"app.include_router(prefix=\"/{prefix}\", router={router}.router)");
return this;
}
public string Rewrite()
{
// Make sure to insert the includes before the imports, if done the other way around,
// the insertions of the includes will change the indexes of the imports.
InsertIncludes();
InsertImports();
@@ -54,7 +56,7 @@ public class MainModifier
private void InsertImports()
{
if (_importsBuffer.Count == 0) return;
if (_lastImport is not null)
{
_source.InsertMultiLine(_lastImport.EndIndex, _importsBuffer.ToArray());
@@ -69,15 +71,17 @@ public class MainModifier
{
if (_routerIncludeBuffer.Count == 0) return;
// Prepend an empty string to the router include buffer,
// this will ensure that the new entries are all on separate lines.
var content = _routerIncludeBuffer.Prepend(string.Empty).ToArray();
if (_lastRouterInclude is not null)
{
_source.InsertMultiLine(
_lastRouterInclude.EndIndex, _routerIncludeBuffer.ToArray()
);
_source.InsertMultiLine(_lastRouterInclude.EndIndex, content);
}
else
{
_source.InsertMultiLineAtEnd(_routerIncludeBuffer.ToArray());
_source.InsertMultiLineAtEnd(content);
}
}
}

View File

@@ -1,19 +1,22 @@
using System.Text.RegularExpressions;
using Humanizer;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.CodeGen;
public class RequestClassGenerator
{
public record Import(string Name, List<string> Types)
private static readonly List<string> PythonTypingImports = ["Any", "Dict", "List", "Optional"];
private record Import(string Name, List<string> Types)
{
public bool Match(string type) => Types.Any(t => type == t || type.StartsWith(t));
public string FindType(string type) => Types.First(t => type == t || type.StartsWith(t));
};
public record Field(string Name, string Type);
private record Field(string Name, string Type);
public enum Type
{
@@ -40,29 +43,28 @@ public class RequestClassGenerator
_context = context;
}
public async Task Generate(string path, string entity, Type type)
public async Task Generate(FullyQualifiedName fqn, Type type)
{
var entitySnakeCaseName = entity.Underscore().ToLower();
var entityClassName = entity.Pascalize();
var entitiesFolderPath = $"{Features.Db.FeatureName}/entities/{path}";
var entityFilePath = $"{entitiesFolderPath}/{entitySnakeCaseName}.py";
var entityFilePath = Path.Join(Features.Db.FeatureName, "entities", $"{fqn.FilePath}.py");
var entitySource = await _context.ReadFile(entityFilePath);
var fieldInfo = ReadFields(entitySource);
var fields = string.Join('\n', fieldInfo.Select(x => ToFieldString(x, type)));
var requestsFolderPath = $"{Features.Api.FeatureName}/requests/{path}";
var updateRequestFilePath =
$"{requestsFolderPath}/{type.ToString().ToLower()}_{entitySnakeCaseName}_request.py";
var requestFilePath = Path.Join(
Features.Api.FeatureName,
"requests",
fqn.Namespace,
$"{type.ToString().ToLower()}_{fqn.SnakeCasedName}_request.py"
);
var service = string.Join("\n", Template)
.Replace("%imports%", GetImportString(entitySource, fieldInfo, type))
.Replace("%request_type%", type.ToString().Pascalize())
.Replace("%entity_class_name%", entityClassName)
.Replace("%entity_class_name%", fqn.PascalizedName)
.Replace("%fields%", fields)
;
await _context.CreateFile(updateRequestFilePath, service);
await _context.CreateFile(requestFilePath, service);
}
private string ToFieldString(Field field, Type type)
@@ -100,11 +102,12 @@ public class RequestClassGenerator
.Replace(" ", "");
Console.WriteLine(str); // = "List,Dict,str,Any"
*/
var dissectedTypes = field.Type.Replace("[", ",")
var dissectedTypes = field.Type
.Replace("[", ",")
.Replace("]", "")
.Replace(" ", "")
.Split();
.Split(',');
foreach (var dissectedType in dissectedTypes)
{
if (imports.FirstOrDefault(i => i.Match(dissectedType)) is Import import)
@@ -164,16 +167,16 @@ public class RequestClassGenerator
.Split(',')
.Select(s => s.Trim())
.ToArray();
imports.Add(new Import(name, [..types]));
imports.Add(new Import(name, new List<string>(types)));
}
if (imports.FirstOrDefault(i => i.Name == "typing") is Import typingImport)
{
typingImport.Types.AddRange(["Any", "Dict", "List", "Optional"]);
typingImport.Types.AddRange(PythonTypingImports);
}
else
{
imports.Add(new("typing", ["Any", "Dict", "List", "Optional"]));
imports.Add(new Import("typing", PythonTypingImports));
}
return imports;

View File

@@ -0,0 +1,17 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresFeatureAttribute : Attribute
{
public string FeatureName { get; }
public RequiresFeatureAttribute(string featureName)
{
FeatureName = featureName;
}
public void RequireFeature(string command)
{
if (!Directory.Exists(Path.Join(Environment.CurrentDirectory, FeatureName)))
throw new($"Command '{command}' requires feature {FeatureName}");
}
}

View File

@@ -0,0 +1,17 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresFileAttribute : Attribute
{
public string FilePath { get; }
public RequiresFileAttribute(string filePath)
{
FilePath = filePath;
}
public void RequireFile(string command)
{
if (!File.Exists(Path.Join(Environment.CurrentDirectory, FilePath)))
throw new($"Command '{command}' requires file {FilePath}");
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresPluginAttribute : Attribute
{
public void RequirePluginProject(string command)
{
var currentDirectoryInfo = new DirectoryInfo(Environment.CurrentDirectory);
var matcher = new Matcher()
.AddInclude("*.csproj")
.Execute(new DirectoryInfoWrapper(currentDirectoryInfo));
if (!matcher.HasMatches)
throw new($"Command '{command}' must be run in a command plugin project.");
var csprojFileName = $"{new DirectoryInfo(Environment.CurrentDirectory).Name}.csproj";
bool IsCsprojFile(FilePatternMatch file)
{
return Path.GetFileName(file.Path) == csprojFileName;
}
var hasCsprojFile = matcher.Files.Any(IsCsprojFile);
if (!hasCsprojFile)
throw new($"File '{csprojFileName}' was not found, make sure you're in a command plugin project.");
}
}

View File

@@ -0,0 +1,12 @@
namespace MycroForge.CLI.Commands.Attributes;
public class RequiresVenvAttribute : Attribute
{
public string Path => System.IO.Path.Join(Environment.CurrentDirectory, ".venv");
public void RequireVenv(string command)
{
if (!File.Exists(System.IO.Path.Join(Environment.CurrentDirectory, Path)))
throw new($"Command '{command}' requires directory {Path}");
}
}

View File

@@ -0,0 +1,48 @@
using Humanizer;
using MycroForge.CLI.Extensions;
namespace MycroForge.CLI.Commands;
public class FullyQualifiedName
{
public string Namespace { get; }
public string PascalizedName { get; }
public string SnakeCasedName { get; }
public string FilePath =>
string.IsNullOrEmpty(Namespace.Trim())
? SnakeCasedName
: Path.Join(Namespace, SnakeCasedName);
public bool HasNamespace => Namespace.Length > 0;
public FullyQualifiedName(string name)
{
var path = string.Empty;
if (name.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName)
{
path = fullName[0];
name = fullName[1];
}
Namespace = path;
PascalizedName = name.Pascalize();
SnakeCasedName = SnakeCase(name);
}
public string GetImportPath(params string[] root)
{
if (root.Length == 0)
return string.Join('.', FilePath).SlashesToDots();
var importRoot = string.Join('.', root);
return string.Join('.', SnakeCase(importRoot), FilePath).SlashesToDots();
}
private static string SnakeCase(string value) => value.Underscore().ToLower();
// private static string SlashesToDots(string value) => value.Replace('\\', '.').Replace('/', '.');
}

View File

@@ -0,0 +1,39 @@
using System.CommandLine;
using MycroForge.CLI.Features;
using MycroForge.Core;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Add
{
public class Api : Command, ISubCommandOf<Add>
{
private static readonly Option<int> ApiPortOption = new(name: "--api-port", description: "The API port");
private readonly ProjectContext _context;
private readonly OptionsContainer _optionsContainer;
private readonly List<IFeature> _features;
public Api(ProjectContext context, OptionsContainer optionsContainer, IEnumerable<IFeature> features) :
base(Features.Api.FeatureName, "Add FastAPI to the project")
{
_context = context;
_optionsContainer = optionsContainer;
_features = features.ToList();
AddOption(ApiPortOption);
this.SetHandler(ExecuteAsync, ApiPortOption);
}
private async Task ExecuteAsync(int apiPort)
{
_optionsContainer.Set(new Features.Api.Options { ApiPort = apiPort });
var feature = _features.First(f => f.Name == Features.Api.FeatureName);
await feature.ExecuteAsync(_context);
}
}
}
}

View File

@@ -0,0 +1,63 @@
using System.CommandLine;
using MycroForge.CLI.Features;
using MycroForge.Core;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Add
{
public class Db : Command, ISubCommandOf<Add>
{
private static readonly Option<int> DbhPortOption = new(
aliases: ["--database-host-port", "--dbh-port"],
description: "The database host port"
);
private static readonly Option<int> DbuPortOption = new(
aliases: ["--database-ui-port", "--dbu-port"],
description: "The database UI port"
);
private static readonly Option<ProjectConfig.DbConfig.DbuPlatformOptions> DbuPlatformOption = new(
aliases: ["--database-ui-platform", "--dbu-platform"],
description: "The docker platform for the PhpMyAdmin image"
);
private readonly ProjectContext _context;
private readonly OptionsContainer _optionsContainer;
private readonly List<IFeature> _features;
public Db(ProjectContext context, OptionsContainer optionsContainer, IEnumerable<IFeature> features) :
base(Features.Db.FeatureName, "Add SQLAlchemy & Alembic to the project")
{
_context = context;
_optionsContainer = optionsContainer;
_features = features.ToList();
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption, DbuPlatformOption);
}
private async Task ExecuteAsync(
int dbhPort,
int dbuPort,
ProjectConfig.DbConfig.DbuPlatformOptions dbuPlatform
)
{
_optionsContainer.Set(new Features.Db.Options
{
DbhPort = dbhPort,
DbuPort = dbuPort,
DbuPlatform = dbuPlatform
});
var feature = _features.First(f => f.Name == Features.Db.FeatureName);
await feature.ExecuteAsync(_context);
}
}
}
}

View File

@@ -0,0 +1,32 @@
using System.CommandLine;
using MycroForge.CLI.Features;
using MycroForge.Core;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Add
{
public class Git : Command, ISubCommandOf<Add>
{
private readonly ProjectContext _context;
private readonly List<IFeature> _features;
public Git(ProjectContext context, IEnumerable<IFeature> features) :
base(Features.Git.FeatureName, "Add git to the project")
{
_context = context;
_features = features.ToList();
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
var feature = _features.First(f => f.Name == Features.Git.FeatureName);
await feature.ExecuteAsync(_context);
}
}
}
}

View File

@@ -0,0 +1,32 @@
using System.CommandLine;
using MycroForge.CLI.Features;
using MycroForge.Core;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Add
{
public class GitIgnore : Command, ISubCommandOf<Add>
{
private readonly ProjectContext _context;
private readonly List<IFeature> _features;
public GitIgnore(ProjectContext context, IEnumerable<IFeature> features) :
base(Features.GitIgnore.FeatureName, "Add a default .gitignore file to the project")
{
_context = context;
_features = features.ToList();
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
var feature = _features.First(f => f.Name == Features.GitIgnore.FeatureName);
await feature.ExecuteAsync(_context);
}
}
}
}

View File

@@ -0,0 +1,17 @@
using System.CommandLine;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Add : Command, ISubCommandOf<MycroForge>
{
public Add(IEnumerable<ISubCommandOf<Add>> commands) :
base("add", "Add features to the project")
{
foreach (var command in commands.Cast<Command>())
AddCommand(command);
}
}
}

View File

@@ -28,17 +28,11 @@ public partial class MycroForge
private async Task ExecuteAsync(string entity)
{
var path = string.Empty;
if (entity.Split(':').Select(s => s.Trim()).ToArray() is { Length: 2 } fullName)
{
path = fullName[0];
entity = fullName[1];
}
await new CrudServiceGenerator(_context).Generate(path, entity);
await new RequestClassGenerator(_context).Generate(path, entity, RequestClassGenerator.Type.Create);
await new RequestClassGenerator(_context).Generate(path, entity, RequestClassGenerator.Type.Update);
await new CrudRouterGenerator(_context).Generate(path, entity);
var fqn = new FullyQualifiedName(entity);
await new CrudServiceGenerator(_context).Generate(fqn);
await new RequestClassGenerator(_context).Generate(fqn, RequestClassGenerator.Type.Create);
await new RequestClassGenerator(_context).Generate(fqn, RequestClassGenerator.Type.Update);
await new CrudRouterGenerator(_context).Generate(fqn);
}
}
}

View File

@@ -1,7 +1,7 @@
using System.CommandLine;
using Humanizer;
using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@@ -42,30 +42,29 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
var folderPath = $"{Features.Api.FeatureName}/routers";
var fqn = new FullyQualifiedName(name);
var routersFolderPath = Path.Join(Features.Api.FeatureName, "routers");
_context.AssertDirectoryExists(folderPath);
if (fqn.HasNamespace)
routersFolderPath = Path.Join(routersFolderPath, fqn.Namespace);
if (name.FullyQualifiedName() is { Length: 2 } fullName)
{
folderPath = Path.Combine(folderPath, fullName[0]);
name = fullName[1];
}
var moduleImportPath = folderPath.Replace('\\', '.').Replace('/', '.');
var moduleName = name.Underscore().ToLower();
var fileName = $"{moduleName}.py";
var filePath = Path.Combine(folderPath, fileName);
var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Join(routersFolderPath, fileName);
await _context.CreateFile(filePath, Template);
var moduleImportPath = routersFolderPath
.Replace('\\', '.')
.Replace('/', '.');
var main = await _context.ReadFile("main.py");
main += string.Join('\n',
$"\n\nfrom {moduleImportPath} import {moduleName}",
$"app.include_router(prefix=\"/{name.Kebaberize()}\", router={moduleName}.router)"
);
main = new MainModifier(main)
.Initialize()
.Import(from: moduleImportPath, import: fqn.SnakeCasedName)
.IncludeRouter(prefix: name.Kebaberize(), router: fqn.SnakeCasedName)
.Rewrite();
await _context.WriteFile("main.py", main);
}
}

View File

@@ -1,17 +1,19 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresFeature(Features.Api.FeatureName)]
public partial class Api : Command, ISubCommandOf<MycroForge>
{
public Api(IEnumerable<ISubCommandOf<Api>> subCommands) :
public Api(IEnumerable<ISubCommandOf<Api>> commands) :
base("api", "API related commands")
{
foreach (var subCommandOf in subCommands)
AddCommand((subCommandOf as Command)!);
foreach (var command in commands)
AddCommand((command as Command)!);
}
}
}

View File

@@ -1,8 +1,8 @@
using System.CommandLine;
using System.Text.RegularExpressions;
using Humanizer;
using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@@ -15,11 +15,65 @@ public partial class MycroForge
{
public class Entity : Command, ISubCommandOf<Generate>
{
private record ColumnDefinition(string Name, string NativeType, string OrmType);
#region Hidden region
private static string[] SqlAlchemyTypes =
[
"BigInteger",
"Boolean",
"Date",
"DateTime",
"Enum",
"Double",
"Float",
"Integer",
"Interval",
"LargeBinary",
"MatchType",
"Numeric",
"PickleType",
"SchemaType",
"SmallInteger",
"String",
"Text",
"Time",
"Unicode",
"UnicodeText",
"Uuid",
"ARRAY",
"BIGINT",
"BINARY",
"BLOB",
"BOOLEAN",
"CHAR",
"CLOB",
"DATE",
"DATETIME",
"DECIMAL",
"DOUBLE",
"DOUBLE_PRECISION",
"FLOAT",
"INT",
"JSON",
"INTEGER",
"NCHAR",
"NVARCHAR",
"NUMERIC",
"REAL",
"SMALLINT",
"TEXT",
"TIME",
"TIMESTAMP",
"UUID",
"VARBINARY",
"VARCHAR"
];
private static readonly Regex SqlAlchemyTypeRegex = new(@".*\(.*\)");
private static readonly string[] Template =
[
"from sqlalchemy import %type_imports%",
"from sqlalchemy import %sqlalchemy_imports%",
"from sqlalchemy.orm import Mapped, mapped_column",
$"from {Features.Db.FeatureName}.entities.entity_base import EntityBase",
"",
@@ -56,6 +110,10 @@ public partial class MycroForge
"\tfirst_name:str:String(255)",
])) { AllowMultipleArgumentsPerToken = true };
#endregion
private record ColumnDefinition(string Name, string NativeType, string SqlAlchemyType);
private readonly ProjectContext _context;
public Entity(ProjectContext context) : base("entity", "Generate and database entity")
@@ -69,29 +127,28 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, IEnumerable<string> columns)
{
var folderPath = $"{Features.Db.FeatureName}/entities";
var fqn = new FullyQualifiedName(name);
var folderPath = Path.Join(Features.Db.FeatureName, "entities");
_context.AssertDirectoryExists(Features.Db.FeatureName);
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
var sqlAlchemyColumn = GetColumnDefinitions(columns.ToArray());
var distinctSqlAlchemyColumnTypes = sqlAlchemyColumn
.Select(c => c.SqlAlchemyType.Split('(').First())
.Distinct();
if (name.FullyQualifiedName() is { Length: 2 } fullName)
{
folderPath = Path.Combine(folderPath, fullName[0]);
name = fullName[1];
}
var _columns = GetColumnDefinitions(columns.ToArray());
var className = name.Underscore().Pascalize();
var typeImports = string.Join(", ", _columns.Select(c => c.OrmType.Split('(').First()).Distinct());
var columnDefinitions = string.Join("\n\t", _columns.Select(ColumnToString));
var sqlAlchemyImport = string.Join(", ", distinctSqlAlchemyColumnTypes);
var columnDefinitions = string.Join("\n ", sqlAlchemyColumn.Select(ColumnToString));
var code = string.Join('\n', Template);
code = code.Replace("%type_imports%", typeImports);
code = code.Replace("%class_name%", className);
code = code.Replace("%table_name%", name.Underscore().ToLower().Pluralize());
code = code.Replace("%sqlalchemy_imports%", sqlAlchemyImport);
code = code.Replace("%class_name%", fqn.PascalizedName);
code = code.Replace("%table_name%", fqn.SnakeCasedName.Pluralize());
code = code.Replace("%column_definitions%", columnDefinitions);
var fileName = $"{name.Underscore().ToLower()}.py";
var filePath = Path.Combine(folderPath, fileName);
var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Join(folderPath, fileName);
await _context.CreateFile(filePath, code);
var importPathParts = new[] { folderPath, fileName.Replace(".py", "") }
@@ -104,33 +161,66 @@ public partial class MycroForge
.ToLower();
var env = await _context.ReadFile($"{Features.Db.FeatureName}/env.py");
env = new DbEnvModifier(env, importPath, className).Rewrite();
env = new DbEnvModifier(env, importPath, fqn.PascalizedName).Rewrite();
await _context.WriteFile($"{Features.Db.FeatureName}/env.py", env);
var main = await _context.ReadFile("main.py");
main = new MainModifier(main).Initialize().Import(importPath, className).Rewrite();
main = new MainModifier(main).Initialize().Import(importPath, fqn.PascalizedName).Rewrite();
await _context.WriteFile("main.py", main);
}
private List<ColumnDefinition> GetColumnDefinitions(string[] fields)
private List<ColumnDefinition> GetColumnDefinitions(string[] columns)
{
var definitions = new List<ColumnDefinition>();
foreach (var field in fields)
foreach (var column in columns)
{
if (field.Split(':') is not { Length: 3 } definition)
throw new Exception($"Field definition {field} is invalid.");
if (column.Split(':') is not { Length: 3 } definition)
throw new Exception($"Column definition {column} is invalid.");
definitions.Add(new ColumnDefinition(definition[0], definition[1], definition[2]));
}
ValidateSqlAlchemyColumnTypes(definitions);
return definitions;
}
private static string ColumnToString(ColumnDefinition definition)
private static void ValidateSqlAlchemyColumnTypes(List<ColumnDefinition> definitions)
{
return $"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.OrmType})";
foreach (var column in definitions)
{
if (!SqlAlchemyTypeRegex.IsMatch(column.SqlAlchemyType))
{
var message = new[]
{
$"SQLAlchemy column definition {column.SqlAlchemyType} was not properly defined.",
"Add parentheses and specify parameters if required, an example is provided below.",
" String(255)",
"",
"Available options are:",
string.Join(Environment.NewLine, SqlAlchemyTypes.Select(type => $" - {type}"))
};
throw new(string.Join(Environment.NewLine, message));
}
var type = column.SqlAlchemyType.Split('(').First();
if (!SqlAlchemyTypes.Contains(type))
{
var message = string.Join(Environment.NewLine, [
$"SQLAlchemy column type '{column.SqlAlchemyType}' is not valid, available options are:",
string.Join(Environment.NewLine, SqlAlchemyTypes.Select(type => $" - {type}"))
]);
throw new(message);
}
}
}
private static string ColumnToString(ColumnDefinition definition) =>
$"{definition.Name}: Mapped[{definition.NativeType}] = mapped_column({definition.SqlAlchemyType})";
}
}
}

View File

@@ -27,8 +27,6 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
_context.AssertDirectoryExists($"{Features.Db.FeatureName}/versions");
await _context.Bash(
"source .venv/bin/activate",
$"alembic revision --autogenerate -m \"{name}\" --rev-id $(date -u +\"%Y%m%d%H%M%S\")"

View File

@@ -8,12 +8,12 @@ public partial class MycroForge
{
public partial class Db
{
public partial class Run : Command, ISubCommandOf<Db>
public class Run : Command, ISubCommandOf<Db>
{
private readonly ProjectContext _context;
public Run(ProjectContext context) :
base("run", $"Runs {Features.Db.FeatureName}.docker-compose.yml")
base("run", $"Runs the services defined in {Features.Db.FeatureName}.docker-compose.yml")
{
_context = context;
this.SetHandler(ExecuteAsync);
@@ -22,8 +22,9 @@ public partial class MycroForge
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");
var env = $"DBH_PORT={config.Db.DbhPort} DBU_PORT={config.Db.DbuPort}";
var command = $"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d";
await _context.Bash(command);
}
}
}

View File

@@ -8,12 +8,12 @@ public partial class MycroForge
{
public partial class Db
{
public partial class Stop : Command, ISubCommandOf<Db>
public class Stop : Command, ISubCommandOf<Db>
{
private readonly ProjectContext _context;
public Stop(ProjectContext context) :
base("stop", $"Stops {Features.Db.FeatureName}.docker-compose.yml")
base("stop", $"Stops the services defined in {Features.Db.FeatureName}.docker-compose.yml")
{
_context = context;
this.SetHandler(ExecuteAsync);
@@ -21,9 +21,10 @@ public partial class MycroForge
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");
await _context.Bash(
// Set the log level to ERROR to prevent warnings concerning environment variables not being set.
$"docker --log-level ERROR compose -f {Features.Db.FeatureName}.docker-compose.yml down"
);
}
}
}

View File

@@ -1,10 +1,12 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresFeature(Features.Db.FeatureName)]
public partial class Db : Command, ISubCommandOf<MycroForge>
{
public Db(IEnumerable<ISubCommandOf<Db>> commands)

View File

@@ -1,7 +1,5 @@
using System.CommandLine;
using Humanizer;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@@ -57,18 +55,16 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, bool withSession)
{
var fqn = new FullyQualifiedName(name);
var folderPath = string.Empty;
if (name.FullyQualifiedName() is { Length: 2} fullName)
{
folderPath = Path.Combine(folderPath, fullName[0]);
name = fullName[1];
}
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
var filePath = Path.Combine(folderPath, $"{name.Underscore().ToLower()}.py");
var className = Path.GetFileName(name).Pascalize();
var code = string.Join('\n', withSession ? WithSessionTemplate : DefaultTemplate)
.Replace("%class_name%", className);
var filePath = Path.Join(folderPath, $"{fqn.SnakeCasedName}.py");
var template = withSession ? WithSessionTemplate : DefaultTemplate;
var code = string.Join('\n', template)
.Replace("%class_name%", fqn.PascalizedName);
await _context.CreateFile(filePath, code);
}

View File

@@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@@ -6,11 +7,12 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresFile("requirements.txt")]
public class Hydrate : Command, ISubCommandOf<MycroForge>
{
private readonly ProjectContext _context;
public Hydrate(ProjectContext context)
public Hydrate(ProjectContext context)
: base("hydrate", "Initialize venv and install dependencies from requirements.txt")
{
_context = context;

View File

@@ -0,0 +1,22 @@
using System.CommandLine.Binding;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Init
{
public class Binder : BinderBase<Options>
{
protected override Options GetBoundValue(BindingContext ctx) => new()
{
Name = ctx.ParseResult.GetValueForArgument(NameArgument),
Without = ctx.ParseResult.GetValueForOption(WithoutOption),
ApiPort = ctx.ParseResult.GetValueForOption(ApiPortOption),
DbhPort = ctx.ParseResult.GetValueForOption(DbhPortOption),
DbuPort = ctx.ParseResult.GetValueForOption(DbuPortOption),
DbuPlatform = ctx.ParseResult.GetValueForOption(DbuPlatformOption),
};
}
}
}

View File

@@ -0,0 +1,31 @@
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Init
{
public class Options
{
public string Name { get; set; } = string.Empty;
public IEnumerable<string>? Without { get; set; }
public int? ApiPort { get; set; }
public int? DbhPort { get; set; }
public int? DbuPort { get; set; }
public ProjectConfig.DbConfig.DbuPlatformOptions DbuPlatform { get; set; }
public Features.Api.Options ApiOptions => new()
{
ApiPort = ApiPort <= 0 ? 8000 : ApiPort
};
public Features.Db.Options DbOptions => new()
{
DbhPort = DbhPort <= 0 ? 5050 : DbhPort,
DbuPort = DbuPort <= 0 ? 5051 : DbhPort,
DbuPlatform = DbuPlatform
};
}
}
}

View File

@@ -7,58 +7,12 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
#region GitIgnore
private static readonly string[] GitIgnore =
[
"# Byte-compiled / optimized / DLL files", "__pycache__/", "*.py[cod]", "*$py.class", "# C extensions",
"*.so", "# Distribution / packaging", ".Python", "build/", "develop-eggs/", "dist/", "downloads/", "eggs/",
".eggs/", "lib/", "lib64/", "parts/", "sdist/", "var/", "wheels/", "share/python-wheels/", "*.egg-info/",
".installed.cfg", "*.egg", "MANIFEST", "# PyInstaller",
"# Usually these files are written by a python script from a template",
"# before PyInstaller builds the exe, so as to inject date/other infos into it.", "*.manifest", "*.spec",
"# Installer logs", "pip-log.txt", "pip-delete-this-directory.txt", "# Unit test / coverage reports",
"htmlcov/", ".tox/", ".nox/", ".coverage", ".coverage.*", ".cache", "nosetests.xml", "coverage.xml",
"*.cover", "*.py,cover", ".hypothesis/", ".pytest_cache/", "cover/", "# Translations", "*.mo", "*.pot",
"# Django stuff:", "*.log", "local_settings.py", "db.sqlite3", "db.sqlite3-journal", "# Flask stuff:",
"instance/", ".webassets-cache", "# Scrapy stuff:", ".scrapy", "# Sphinx documentation", "docs/_build/",
"# PyBuilder", ".pybuilder/", "target/", "# Jupyter Notebook", ".ipynb_checkpoints", "# IPython",
"profile_default/", "ipython_config.py", "# pyenv",
"# For a library or package, you might want to ignore these files since the code is",
"# intended to run in multiple environments; otherwise, check them in:", "# .python-version", "# pipenv",
"# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.",
"# However, in case of collaboration, if having platform-specific dependencies or dependencies",
"# having no cross-platform support, pipenv may install dependencies that don't work, or not",
"# install all needed dependencies.", "#Pipfile.lock", "# poetry",
"# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.",
"# This is especially recommended for binary packages to ensure reproducibility, and is more",
"# commonly ignored for libraries.",
"# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control",
"#poetry.lock", "# pdm",
"# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.",
"#pdm.lock",
"# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it",
"# in version control.", "# https://pdm.fming.dev/#use-with-ide", ".pdm.toml",
"# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm", "__pypackages__/",
"# Celery stuff", "celerybeat-schedule", "celerybeat.pid", "# SageMath parsed files", "*.sage.py",
"# Environments", ".env", ".venv", "env/", "venv/", "ENV/", "env.bak/", "venv.bak/",
"# Spyder project settings", ".spyderproject", ".spyproject", "# Rope project settings", ".ropeproject",
"# mkdocs documentation", "/site", "# mypy", ".mypy_cache/", ".dmypy.json", "dmypy.json",
"# Pyre type checker", ".pyre/", "# pytype static type analyzer", ".pytype/", "# Cython debug symbols",
"cython_debug/", "# PyCharm",
"# JetBrains specific template is maintained in a separate JetBrains.gitignore that can",
"# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore",
"# and can be added to the global gitignore or merged into this file. For a more nuclear",
"# option (not recommended) you can uncomment the following to ignore the entire idea folder.", "#.idea/"
];
#endregion
public class Init : Command, ISubCommandOf<MycroForge>
public partial class Init : Command, ISubCommandOf<MycroForge>
{
private static readonly string[] DefaultFeatures =
[
Features.Git.FeatureName,
Features.GitIgnore.FeatureName,
Features.Api.FeatureName,
Features.Db.FeatureName
];
@@ -72,43 +26,66 @@ public partial class MycroForge
AllowMultipleArgumentsPerToken = true
}.FromAmong(DefaultFeatures);
private static readonly Option<int> ApiPortOption =
new(name: "--api-port", description: "The API port");
private static readonly Option<int> DbhPortOption = new(
aliases: ["--database-host-port", "--dbh-port"],
description: "The database host port"
);
private static readonly Option<int> DbuPortOption = new(
aliases: ["--database-ui-port", "--dbu-port"],
description: "The database UI port"
);
private static readonly Option<ProjectConfig.DbConfig.DbuPlatformOptions> DbuPlatformOption = new(
aliases: ["--database-ui-platform", "--dbu-platform"],
description: "The docker platform for the PhpMyAdmin image"
);
private readonly ProjectContext _context;
private readonly List<IFeature> _features;
private readonly OptionsContainer _optionsContainer;
public Init(ProjectContext context, IEnumerable<IFeature> features) :
public Init(ProjectContext context, OptionsContainer optionsContainer, IEnumerable<IFeature> features) :
base("init", "Initialize a new project")
{
_context = context;
_optionsContainer = optionsContainer;
_features = features.ToList();
AddArgument(NameArgument);
AddOption(WithoutOption);
this.SetHandler(ExecuteAsync, NameArgument, WithoutOption);
AddOption(ApiPortOption);
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, new Binder());
}
private async Task ExecuteAsync(string name, IEnumerable<string> without)
private async Task ExecuteAsync(Options options)
{
// Validate excluded features
var withoutList = without.ToList();
var withoutList = (options.Without ?? Enumerable.Empty<string>()).ToList();
foreach (var feature in withoutList)
if (_features.All(f => f.Name != feature))
throw new Exception($"Feature {feature} does not exist.");
// Create the project directory and change the directory for the ProjectContext
var projectRoot = await CreateDirectory(name);
var projectRoot = await CreateDirectory(options.Name);
_context.ChangeRootDirectory(projectRoot);
// Create the config file and initialize the config
await _context.CreateFile("m4g.json", "{}");
// Create the entrypoint file
await _context.CreateFile("main.py");
// Create the default .gitignore folder
await _context.CreateFile(".gitignore", GitIgnore);
// Create the venv
await _context.Bash($"python3 -m venv {Path.Combine(projectRoot, ".venv")}");
await _context.Bash($"python3 -m venv {Path.Join(projectRoot, ".venv")}");
// Pass feature arguments to the ArgsContainer
_optionsContainer.Set(options.ApiOptions);
_optionsContainer.Set(options.DbOptions);
// Initialize default features
foreach (var feature in _features.Where(f => DefaultFeatures.Contains(f.Name)))
@@ -129,7 +106,7 @@ public partial class MycroForge
private async Task<string> CreateDirectory(string name)
{
var directory = Path.Combine(Directory.GetCurrentDirectory(), name);
var directory = Path.Join(Directory.GetCurrentDirectory(), name);
if (Directory.Exists(directory))
throw new Exception($"Directory {directory} already exists.");

View File

@@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Install : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@@ -24,9 +26,17 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages)
{
var packs = packages.ToArray();
if (packs.Length == 0)
{
Console.WriteLine("'m4g install' requires at least one package.");
return;
}
await _context.Bash(
"source .venv/bin/activate",
$"pip install {string.Join(' ', packages)}",
$"pip install {string.Join(' ', packs)}",
"pip freeze > requirements.txt"
);
}

View File

@@ -0,0 +1,39 @@
using System.CommandLine;
using MycroForge.Core;
using MycroForge.Core.Contract;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
public partial class Plugin
{
public class Init : Command, ISubCommandOf<Plugin>
{
private static readonly Argument<string> NamespaceArgument =
new(name: "namespace", description: "The namespace of your project");
private static readonly Argument<string> ClassArgument =
new(name: "class", description: "The class name of the generated command");
private static readonly Argument<string> CommandArgument =
new(name: "command", description: "The command name that will be added to 'm4g'");
private readonly ProjectContext _context;
public Init(ProjectContext context) : base("init", "Initialize a basic plugin project")
{
_context = context;
AddArgument(NamespaceArgument);
AddArgument(ClassArgument);
AddArgument(CommandArgument);
this.SetHandler(ExecuteAsync, NamespaceArgument, ClassArgument, CommandArgument);
}
private async Task ExecuteAsync(string @namespace, string @class, string command)
{
await _context.Bash($"dotnet new m4gp -n {@namespace} --class {@class} --command {command}");
}
}
}
}

View File

@@ -2,6 +2,7 @@
using Humanizer;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@@ -11,6 +12,7 @@ public partial class MycroForge
{
public partial class Plugin
{
[RequiresPlugin]
public class Install : Command, ISubCommandOf<Plugin>
{
public enum TargetPlatform
@@ -42,9 +44,13 @@ public partial class MycroForge
var assemblyName = GetAssemblyName();
var pluginInstallPath = Path.Join(Plugins.RootDirectory, assemblyName);
var platform = target.ToString().Dasherize();
await _context.Bash($"dotnet publish -c Release -r {platform} --output {pluginInstallPath}");
Console.WriteLine($"Successfully installed plugin {assemblyName}");
var exitCode = await _context.Bash(
$"dotnet publish -c Release -r {platform} --output {pluginInstallPath}"
);
Console.WriteLine(exitCode == 0
? $"Successfully installed plugin {assemblyName}"
: $"Could not install {assemblyName}, process exited with code {exitCode}.");
}
private string GetAssemblyName()

View File

@@ -19,7 +19,7 @@ public partial class MycroForge
private void ExecuteAsync()
{
foreach (var plugin in Plugins.Loaded)
Console.WriteLine($"name: {plugin.Name}, command: {plugin.Command}");
Console.WriteLine($"{plugin.Name}");
}
}
}

View File

@@ -1,4 +1,5 @@
using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core;
using MycroForge.Core.Contract;
@@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
[RequiresVenv]
public class Uninstall : Command, ISubCommandOf<MycroForge>
{
private static readonly Argument<IEnumerable<string>> PackagesArgument =
@@ -27,9 +29,17 @@ public partial class MycroForge
private async Task ExecuteAsync(IEnumerable<string> packages, bool yes)
{
var packs = packages.ToArray();
if (packs.Length == 0)
{
Console.WriteLine("'m4g uninstall' requires at least one package.");
return;
}
await _context.Bash(
"source .venv/bin/activate",
$"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packages)}",
$"pip uninstall{(yes ? " --yes " : " ")}{string.Join(' ', packs)}",
"pip freeze > requirements.txt"
);
}

View File

@@ -1,11 +1,10 @@
using System.CommandLine;
using MycroForge.Core.Contract;
using Core_RootCommand = MycroForge.Core.RootCommand;
using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.CLI.Commands;
public sealed partial class MycroForge : Core_RootCommand
public sealed partial class MycroForge : RootCommand
{
public override string Name => "m4g";

View File

@@ -0,0 +1,25 @@
namespace MycroForge.CLI.Commands;
public class OptionsContainer
{
private readonly Dictionary<Type, object> _args = new();
public void Set<T>(T? args)
{
if (args is null)
throw new ArgumentNullException();
_args[args.GetType()] = args;
}
public T Get<T>()
{
if (!_args.ContainsKey(typeof(T)))
throw new KeyNotFoundException();
if (_args[typeof(T)] is not T args)
throw new InvalidCastException();
return args;
}
}

View File

@@ -0,0 +1,96 @@
using System.CommandLine;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Reflection;
using MycroForge.CLI.Commands.Attributes;
namespace MycroForge.CLI.Extensions;
public static class CommandExtensions
{
public static async Task ExecuteAsync(this Commands.MycroForge rootCommand, string[] args)
{
var parser = new CommandLineBuilder(rootCommand)
.AddMiddleware()
.UseDefaults()
.UseExceptionHandler((ex, ctx) =>
{
/*
* Use a custom ExceptionHandler to prevent the System.CommandLine library from printing the StackTrace by default.
*/
Console.WriteLine(ex.Message);
/*
* Set the exit code to a non-zero value to indicate to the shell that the process has failed.
*/
Environment.ExitCode = -1;
})
.Build();
await parser.InvokeAsync(args);
}
private static CommandLineBuilder AddMiddleware(this CommandLineBuilder builder)
{
builder.AddMiddleware(async (context, next) =>
{
var commandChain = context.GetCommandChain();
var commandText = string.Join(' ', commandChain.Select(cmd => cmd.Name));
foreach (var _command in commandChain)
{
if (_command.GetRequiresFeatureAttribute() is RequiresFeatureAttribute requiresFeatureAttribute)
requiresFeatureAttribute.RequireFeature(commandText);
else if (_command.GetRequiresFileAttribute() is RequiresFileAttribute requiresFileAttribute)
requiresFileAttribute.RequireFile(commandText);
else if (_command.GetRequiresPluginAttribute() is RequiresPluginAttribute requiresPluginAttribute)
requiresPluginAttribute.RequirePluginProject(commandText);
else if (_command.GetRequiresVenvAttribute() is RequiresVenvAttribute requiresVenvAttribute)
requiresVenvAttribute.RequireVenv(commandText);
}
await next(context);
});
return builder;
}
private static List<Command> GetCommandChain(this InvocationContext context)
{
var chain = new List<Command>();
/*
* The CommandResult property refers to the last command in the chain.
* So if the command is 'm4g api run' the CommandResult will refer to 'run'.
*/
SymbolResult? cmd = context.ParseResult.CommandResult;
while (cmd is CommandResult result)
{
chain.Add(result.Command);
cmd = cmd.Parent;
}
/*
* Reverse the chain to reflect the actual order of the commands.
*/
chain.Reverse();
return chain;
}
private static RequiresFeatureAttribute? GetRequiresFeatureAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresFeatureAttribute>();
private static RequiresFileAttribute? GetRequiresFileAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresFileAttribute>();
private static RequiresPluginAttribute? GetRequiresPluginAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresPluginAttribute>();
private static RequiresVenvAttribute? GetRequiresVenvAttribute(this Command command) =>
command.GetType().GetCustomAttribute<RequiresVenvAttribute>();
}

View File

@@ -8,13 +8,15 @@ namespace MycroForge.CLI.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection RegisterCommandDefaults(this IServiceCollection services)
public static IServiceCollection RegisterDefaultCommands(this IServiceCollection services)
{
// Register ProjectContext & features
// Register ProjectContext, OptionsContainer & features
services.AddScoped<ProjectContext>();
services.AddScoped<IFeature, Git>();
services.AddScoped<OptionsContainer>();
services.AddScoped<IFeature, Api>();
services.AddScoped<IFeature, Db>();
services.AddScoped<IFeature, Git>();
services.AddScoped<IFeature, GitIgnore>();
// Register "m4g"
services.AddScoped<Commands.MycroForge>();
@@ -22,6 +24,13 @@ public static class ServiceCollectionExtensions
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Install>();
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Uninstall>();
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Hydrate>();
// Register "m4g add"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Add>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Add>, Commands.MycroForge.Add.Api>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Add>, Commands.MycroForge.Add.Db>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Add>, Commands.MycroForge.Add.Git>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Add>, Commands.MycroForge.Add.GitIgnore>();
// Register "m4g generate"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Generate>();
@@ -50,6 +59,7 @@ public static class ServiceCollectionExtensions
// Register "m4g plugin"
services.AddScoped<ISubCommandOf<Commands.MycroForge>, Commands.MycroForge.Plugin>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Plugin>, Commands.MycroForge.Plugin.Init>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Plugin>, Commands.MycroForge.Plugin.List>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Plugin>, Commands.MycroForge.Plugin.Install>();
services.AddScoped<ISubCommandOf<Commands.MycroForge.Plugin>, Commands.MycroForge.Plugin.Uninstall>();

View File

@@ -2,16 +2,8 @@
public static class StringExtensions
{
public static string[] FullyQualifiedName(this string name)
{
return name.Split(':').Select(s => s.Trim()).ToArray();
}
public static string DeduplicateDots(this string path)
{
while (path.Contains(".."))
path = path.Replace("..", ".");
return path.Trim('.');
}
public static string SlashesToDots(this string path) =>
path.Replace('/', '.')
.Replace('\\', '.')
.Trim();
}

View File

@@ -0,0 +1,9 @@
namespace MycroForge.CLI.Features;
public sealed partial class Api
{
public class Options
{
public int? ApiPort { get; set; }
}
}

View File

@@ -1,10 +1,11 @@
using MycroForge.Core;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.Features;
public sealed class Api : IFeature
public sealed partial class Api : IFeature
{
#region Main
#region Templates
private static readonly string[] RouterTemplate =
[
@@ -24,7 +25,6 @@ public sealed class Api : IFeature
"from fastapi import FastAPI",
$"from {FeatureName}.routers import hello",
"",
"",
"app = FastAPI()",
"",
"app.include_router(prefix=\"/hello\", router=hello.router)"
@@ -36,15 +36,23 @@ public sealed class Api : IFeature
public string Name => FeatureName;
private readonly OptionsContainer _optionsContainer;
public Api(OptionsContainer optionsContainer)
{
_optionsContainer = optionsContainer;
}
public async Task ExecuteAsync(ProjectContext context)
{
var config = await context.LoadConfig();
config.Api = new() { Port = 8000 };
var options = _optionsContainer.Get<Options>();
var config = await context.LoadConfig(create: true);
config.Api = new() { Port = options.ApiPort ?? 8000 };
await context.SaveConfig(config);
await context.Bash(
"source .venv/bin/activate",
"python3 -m pip install fastapi uvicorn[standard]",
"python3 -m pip install fastapi uvicorn",
"python3 -m pip freeze > requirements.txt"
);

View File

@@ -0,0 +1,15 @@
using MycroForge.Core;
namespace MycroForge.CLI.Features;
public sealed partial class Db
{
public class Options
{
public int? DbhPort { get; set; }
public int? DbuPort { get; set; }
public ProjectConfig.DbConfig.DbuPlatformOptions DbuPlatform { get; set; }
}
}

View File

@@ -1,9 +1,10 @@
using MycroForge.CLI.CodeGen;
using MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.Features;
public sealed class Db : IFeature
public sealed partial class Db : IFeature
{
#region Defaults
@@ -36,8 +37,7 @@ public sealed class Db : IFeature
private static readonly string[] DockerCompose =
[
"version: '3.8'",
"# Access the database UI at http://localhost:${DB_PORT}.",
"# Access the database UI at http://localhost:${DBU_PORT}.",
"# Login: username = root & password = password",
"",
"services:",
@@ -47,7 +47,7 @@ public sealed class Db : IFeature
" networks:",
" - default",
" ports:",
" - '${DB_PORT}:3306'",
" - '${DBH_PORT}:3306'",
" environment:",
" MYSQL_ROOT_PASSWORD: 'password'",
" MYSQL_USER: 'root'",
@@ -57,10 +57,10 @@ public sealed class Db : IFeature
" - '%app_name%_mariadb:/var/lib/mysql'",
"",
" %app_name%_phpmyadmin:",
" image: phpmyadmin/phpmyadmin",
" image: %dbu_platform%/phpmyadmin",
" container_name: %app_name%_phpmyadmin",
" ports:",
" - '${PMA_PORT}:80'",
" - '${DBU_PORT}:80'",
" networks:",
" - default",
" environment:",
@@ -78,12 +78,25 @@ public sealed class Db : IFeature
public const string FeatureName = "db";
private readonly OptionsContainer _optionsContainer;
public string Name => FeatureName;
public Db(OptionsContainer optionsContainer)
{
_optionsContainer = optionsContainer;
}
public async Task ExecuteAsync(ProjectContext context)
{
var config = await context.LoadConfig();
config.Db = new() { DbPort = 5050, PmaPort = 5051 };
var options = _optionsContainer.Get<Options>();
var config = await context.LoadConfig(create: true);
config.Db = new()
{
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051,
DbuPlatform = options.DbuPlatform
};
await context.SaveConfig(config);
var appName = context.AppName;
@@ -101,7 +114,7 @@ public sealed class Db : IFeature
var settings = string.Join('\n', Settings)
.Replace("%app_name%", appName)
.Replace("%db_port%", config.Db.DbPort.ToString())
.Replace("%db_port%", config.Db.DbhPort.ToString())
;
await context.CreateFile($"{FeatureName}/settings.py", settings);
@@ -110,7 +123,10 @@ public sealed class Db : IFeature
await context.CreateFile($"{FeatureName}/entities/entity_base.py", EntityBase);
var dockerCompose = string.Join('\n', DockerCompose).Replace("%app_name%", appName);
var dockerCompose = string.Join('\n', DockerCompose)
.Replace("%app_name%", appName)
.Replace("%dbu_platform%", options.DbuPlatform.ToString())
;
await context.CreateFile($"{FeatureName}.docker-compose.yml", dockerCompose);
}

View File

@@ -0,0 +1,61 @@
using MycroForge.Core;
namespace MycroForge.CLI.Features;
public class GitIgnore : IFeature
{
#region GitIgnore
private static readonly string[] GitIgnoreTemplate =
[
"# Byte-compiled / optimized / DLL files", "__pycache__/", "*.py[cod]", "*$py.class", "# C extensions",
"*.so", "# Distribution / packaging", ".Python", "build/", "develop-eggs/", "dist/", "downloads/", "eggs/",
".eggs/", "lib/", "lib64/", "parts/", "sdist/", "var/", "wheels/", "share/python-wheels/", "*.egg-info/",
".installed.cfg", "*.egg", "MANIFEST", "# PyInstaller",
"# Usually these files are written by a python script from a template",
"# before PyInstaller builds the exe, so as to inject date/other infos into it.", "*.manifest", "*.spec",
"# Installer logs", "pip-log.txt", "pip-delete-this-directory.txt", "# Unit test / coverage reports",
"htmlcov/", ".tox/", ".nox/", ".coverage", ".coverage.*", ".cache", "nosetests.xml", "coverage.xml",
"*.cover", "*.py,cover", ".hypothesis/", ".pytest_cache/", "cover/", "# Translations", "*.mo", "*.pot",
"# Django stuff:", "*.log", "local_settings.py", "db.sqlite3", "db.sqlite3-journal", "# Flask stuff:",
"instance/", ".webassets-cache", "# Scrapy stuff:", ".scrapy", "# Sphinx documentation", "docs/_build/",
"# PyBuilder", ".pybuilder/", "target/", "# Jupyter Notebook", ".ipynb_checkpoints", "# IPython",
"profile_default/", "ipython_config.py", "# pyenv",
"# For a library or package, you might want to ignore these files since the code is",
"# intended to run in multiple environments; otherwise, check them in:", "# .python-version", "# pipenv",
"# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.",
"# However, in case of collaboration, if having platform-specific dependencies or dependencies",
"# having no cross-platform support, pipenv may install dependencies that don't work, or not",
"# install all needed dependencies.", "#Pipfile.lock", "# poetry",
"# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.",
"# This is especially recommended for binary packages to ensure reproducibility, and is more",
"# commonly ignored for libraries.",
"# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control",
"#poetry.lock", "# pdm",
"# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.",
"#pdm.lock",
"# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it",
"# in version control.", "# https://pdm.fming.dev/#use-with-ide", ".pdm.toml",
"# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm", "__pypackages__/",
"# Celery stuff", "celerybeat-schedule", "celerybeat.pid", "# SageMath parsed files", "*.sage.py",
"# Environments", ".env", ".venv", "env/", "venv/", "ENV/", "env.bak/", "venv.bak/",
"# Spyder project settings", ".spyderproject", ".spyproject", "# Rope project settings", ".ropeproject",
"# mkdocs documentation", "/site", "# mypy", ".mypy_cache/", ".dmypy.json", "dmypy.json",
"# Pyre type checker", ".pyre/", "# pytype static type analyzer", ".pytype/", "# Cython debug symbols",
"cython_debug/", "# PyCharm",
"# JetBrains specific template is maintained in a separate JetBrains.gitignore that can",
"# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore",
"# and can be added to the global gitignore or merged into this file. For a more nuclear",
"# option (not recommended) you can uncomment the following to ignore the entire idea folder.", "#.idea/"
];
#endregion
public const string FeatureName = "gitignore";
public string Name => FeatureName;
public async Task ExecuteAsync(ProjectContext context)
{
await context.CreateFile(".gitignore", GitIgnoreTemplate);
}
}

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Version>0.0.1</Version>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,25 +1,18 @@
using System.CommandLine;
using MycroForge.CLI.Extensions;
using MycroForge.CLI.Extensions;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using RootCommand = MycroForge.CLI.Commands.MycroForge;
using var host = Host
.CreateDefaultBuilder()
.ConfigureServices((_, services) =>
{
services
.RegisterCommandDefaults()
.RegisterDefaultCommands()
.RegisterCommandPlugins()
;
})
.Build();
try
{
await host.Services.GetRequiredService<MycroForge.CLI.Commands.MycroForge>()
.InvokeAsync(args.Length == 0 ? ["--help"] : args);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
var command = host.Services.GetRequiredService<RootCommand>();
await command.ExecuteAsync(args.Length == 0 ? ["--help"] : args);

View File

@@ -1,11 +0,0 @@
#!/usr/bin/bash
TARGET=$1
if [ -z "$TARGET" ]; then
echo "The target platform was not provided."
exit 1
fi
dotnet publish --self-contained -r "$TARGET"
zip -vr "releases/m4g-$TARGET.zip" "bin/Release/net8.0/$TARGET/"

View File

@@ -1,7 +0,0 @@
#!/usr/bin/bash
./scripts/build-executable.sh linux-x64
./scripts/build-executable.sh linux-arm
./scripts/build-executable.sh linux-arm64
./scripts/build-executable.sh osx-x64
./scripts/build-executable.sh osx-arm64

View File

@@ -1,21 +0,0 @@
#!/usr/bin/bash
ZIP=$1
if [ -z "$ZIP" ]; then
echo "The zip file was not provided."
exit 1
fi
TARGET=${ZIP//"m4g-"/}
TARGET=${TARGET//".zip"/}
DIR="/tmp/m4g"
rm -rf "$DIR"
unzip "$ZIP" -d "$DIR"
sudo rm -rf /usr/share/m4g
sudo cp -r "$DIR/bin/Release/net8.0/$TARGET" /usr/share/m4g
sudo unlink /usr/local/bin/m4g 2> /dev/null
sudo ln -s /usr/share/m4g/MycroForge.CLI /usr/local/bin/m4g

View File

@@ -1,19 +0,0 @@
#!/usr/bin/bash
ZIP=$1
if [ -z "$ZIP" ]; then
echo "The zip file was not provided."
exit 1
fi
TARGET=${ZIP//"m4g-"/}
TARGET=${TARGET//".zip"/}
DIR="/tmp/m4g"
rm -rf "$DIR"
unzip "$ZIP" -d "$DIR"
sudo rm -rf /usr/local/bin/m4g
sudo cp -r "$DIR/bin/Release/net8.0/$TARGET" /usr/local/bin/m4g
mv /usr/share/m4g/MycroForge.CLI /usr/share/m4g/m4g

View File

@@ -1,11 +0,0 @@
#!/usr/bin/bash
# Make sure to run this script from the MycroForge.CLI directory and prefixed with sudo!
# Example:
# sudo ./scripts/publish-linux.sh
dotnet publish --self-contained -r linux-x64
sudo rm -rf /usr/share/m4g
sudo cp -r bin/Release/net8.0/linux-x64 /usr/share/m4g
sudo unlink /usr/local/bin/m4g
sudo ln -s /usr/share/m4g/MycroForge.CLI /usr/local/bin/m4g

View File

@@ -0,0 +1,7 @@
#!/usr/bin/bash
dotnet pack -v d
VERSION="1.0.0"
dotnet nuget push nupkg/MycroForge.CLI.$VERSION.nupkg --source devdisciples

View File

@@ -1,4 +1,4 @@
#!/usr/bin/bash
#!/bin/bash
dotnet pack -v d

View File

@@ -48,7 +48,7 @@ public class Source
public Source InsertMultiLineAtEnd(params string[] text)
{
_text += (string.Join('\n', text));
_text += string.Join('\n', text);
return this;
}

View File

@@ -5,7 +5,6 @@ namespace MycroForge.Core.Contract;
public interface ICommandPlugin
{
public string? Name { get; }
public string Command { get; }
public void RegisterServices(IServiceCollection services);
}

View File

@@ -0,0 +1,17 @@
using System.Text.Json;
namespace MycroForge.Core;
public static class DefaultJsonSerializerOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static readonly JsonSerializerOptions CamelCasePrettyPrint = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}

View File

@@ -4,15 +4,12 @@ namespace MycroForge.Core.Extensions;
public static class ObjectStreamExtensions
{
public static async Task<string> SerializeAsync(
this object @object,
JsonSerializerOptions? jsonSerializerOptions = null
)
public static async Task<string> SerializeAsync(this object @object, JsonSerializerOptions? options = null)
{
using var stream = new MemoryStream();
using var reader = new StreamReader(stream);
var options = jsonSerializerOptions ?? Serialization.DefaultJsonSerializerOptions.Default;
options ??= DefaultJsonSerializerOptions.Default;
await JsonSerializer.SerializeAsync(stream, @object, options);
stream.Position = 0;
return await reader.ReadToEndAsync();

View File

@@ -4,6 +4,12 @@
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<PackageId>MycroForge.Core</PackageId>
<Description>The MycroForge core package</Description>
<Version>0.0.1</Version>
<Authors>Donné Napo</Authors>
<Company>Dev Disciples</Company>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,16 @@
namespace MycroForge.Core;
public partial class ProjectConfig
{
public partial class DbConfig
{
public enum DbuPlatformOptions
{
amd64,
arm32v5,
arm32v6,
arm32v7,
arm64v8
}
}
}

View File

@@ -1,10 +1,16 @@
namespace MycroForge.Core;
using System.Text.Json.Serialization;
namespace MycroForge.Core;
public partial class ProjectConfig
{
public class DbConfig
public partial class DbConfig
{
public int DbPort { get; set; }
public int PmaPort { get; set; }
public int DbhPort { get; set; }
public int DbuPort { get; set; }
[JsonIgnore]
public DbuPlatformOptions DbuPlatform { get; set; }
}
}

View File

@@ -9,17 +9,20 @@ 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");
private string ConfigPath => Path.Join(RootDirectory, "m4g.json");
public async Task<ProjectConfig> LoadConfig()
public async Task<ProjectConfig> LoadConfig(bool create = false)
{
if (!File.Exists(ConfigPath))
throw new FileNotFoundException($"File {ConfigPath} does not exist.");
{
if (create) await CreateFile("m4g.json", "{}");
else throw new FileNotFoundException($"File {ConfigPath} does not exist.");
}
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath),
Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint
File.OpenRead(ConfigPath),
DefaultJsonSerializerOptions.CamelCasePrettyPrint
);
if (config is null)
@@ -34,21 +37,9 @@ public class ProjectContext
RootDirectory = path;
}
public void AssertDirectoryExists(string path)
{
var fullPath = Path.Combine(RootDirectory, path);
if (!Directory.Exists(fullPath))
{
throw new(string.Join('\n',
$"{fullPath} does not exist, make sure you're in the correct directory."
));
}
}
public async Task CreateFile(string path, params string[] content)
{
var fullPath = Path.Combine(RootDirectory, path);
var fullPath = Path.Join(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (fileInfo.Exists) return;
@@ -56,11 +47,12 @@ public class ProjectContext
Directory.CreateDirectory(fileInfo.Directory!.FullName);
await File.WriteAllTextAsync(fullPath, string.Join("\n", content));
await Bash($"chmod 777 {fullPath}");
Console.WriteLine($"Created file {path}");
}
public async Task<string> ReadFile(string path)
{
var fullPath = Path.Combine(RootDirectory, path);
var fullPath = Path.Join(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
if (!fileInfo.Exists)
@@ -71,13 +63,14 @@ public class ProjectContext
public async Task WriteFile(string path, params string[] content)
{
var fullPath = Path.Combine(RootDirectory, path);
var fullPath = Path.Join(RootDirectory, path);
var fileInfo = new FileInfo(fullPath);
Directory.CreateDirectory(fileInfo.Directory!.FullName);
await File.WriteAllTextAsync(fullPath, string.Join("\n", content));
Console.WriteLine($"Modified file {path}");
}
public async Task Bash(params string[] script)
public async Task<int> Bash(params string[] script)
{
var info = new ProcessStartInfo
{
@@ -111,21 +104,21 @@ public class ProjectContext
process.BeginErrorReadLine();
await using var input = process.StandardInput;
foreach (var line in script)
await input.WriteLineAsync(line);
// Concat with '&&' operator to make sure that script does not continue on failure.
await input.WriteAsync(string.Join(" && ", script));
await input.FlushAsync();
input.Close();
await process.WaitForExitAsync();
if (process.ExitCode != 0)
Console.WriteLine($"Process finished with exit code {process.ExitCode}.");
Environment.ExitCode = process.ExitCode;
return process.ExitCode;
}
public async Task SaveConfig(ProjectConfig config)
{
var json = await config.SerializeAsync(Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint);
var json = await config.SerializeAsync(DefaultJsonSerializerOptions.CamelCasePrettyPrint);
await File.WriteAllTextAsync(ConfigPath, json);
}
}

View File

@@ -1,20 +0,0 @@
using System.Text.Json;
namespace MycroForge.Core;
public static class Serialization
{
public static class DefaultJsonSerializerOptions
{
public static readonly JsonSerializerOptions Default = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public static readonly JsonSerializerOptions CamelCasePrettyPrint = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
}
}

View File

@@ -0,0 +1,6 @@
### Publish to local nuget repo
```shell
dotnet build -r Release
dotnet nuget push --source devdisciples bin/Release/MycroForge.Core.1.0.0.nupkg
```

View File

@@ -0,0 +1,5 @@
#!/bin/bash
VERSION=$(grep '<Version>' < MycroForge.Core.csproj | sed 's/.*<Version>\(.*\)<\/Version>/\1/' | tr -d '[:space:]')
dotnet build -r Releasedo
dotnet nuget push --source devdisciples "bin/Release/MycroForge.Core.$VERSION.nupkg"

View File

@@ -0,0 +1,5 @@
bin/
obj/
templates/
!templates/.gitkeep
MycroForge.Package.PluginTemplate.sln

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- The package metadata. Fill in the properties marked as TODO below -->
<!-- Follow the instructions on https://learn.microsoft.com/nuget/create-packages/package-authoring-best-practices -->
<PackageId>MycroForge.PluginTemplate.Package</PackageId>
<PackageVersion>0.0.1</PackageVersion>
<Title>A template for generating MycroForge plugins</Title>
<Authors>Donné Napo</Authors>
<Description>Template to use when creating a plugin for the MycroForge CLI.</Description>
<PackageTags>dotnet-new;templates;</PackageTags>
<PackageProjectUrl>https://git.devdisciples.com/devdisciples/mycroforge</PackageProjectUrl>
<PackageType>Template</PackageType>
<TargetFramework>net8.0</TargetFramework>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<NoWarn>$(NoWarn);NU5128</NoWarn>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<ItemGroup>
<Content Include="templates\**\*" Exclude="templates\**\bin\**;templates\**\obj\**"/>
<Compile Remove="**\*"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
### Used resources
https://www.youtube.com/watch?v=XzD-95qfWJM
https://www.youtube.com/watch?v=rdWZo5PD9Ek
https://learn.microsoft.com/en-us/dotnet/core/tutorials/cli-templates-create-template-package?pivots=dotnet-8-0
### Build the package
`dotnet pack`
### Push to devdisciples nuget
`dotnet nuget push bin/Release/MycroForge.PluginTemplate.Package.1.0.0.nupkg --source devdisciples`
### Install template package from local nuget
`dotnet new install MycroForge.PluginTemplate.Package --nuget-source devdisciples`

View File

@@ -0,0 +1,5 @@
#!/bin/env bash
rm -rf templates/MycroForge.PluginTemplate
cp -R ../MycroForge.PluginTemplate templates/MycroForge.PluginTemplate
dotnet pack

View File

@@ -0,0 +1,12 @@
#!/bin/env bash
rm -rf templates/MycroForge.PluginTemplate
cp -R ../MycroForge.PluginTemplate templates/MycroForge.PluginTemplate
dotnet pack
# Set the path to the package
VERSION=$(grep '<PackageVersion>' < MycroForge.PluginTemplate.Package.csproj | sed 's/.*<PackageVersion>\(.*\)<\/PackageVersion>/\1/' | tr -d '[:space:]')
PACKAGE="bin/Release/MycroForge.PluginTemplate.Package.$VERSION.nupkg"
# Push the package
dotnet nuget push "$PACKAGE" --source devdisciples

View File

@@ -0,0 +1,32 @@
{
"$schema": "http://json.schemastore.org/template",
"author": "Donné Napo",
"defaultName": "My.Plugin",
"name": "MycroForge plugin template",
"description": "Creates a basic MycroForge plugin project",
"projectURL": "https://github.com/mdnapo/mycroforge",
"repository": {
"url": "https://github.com/",
"type": "GitHub"
},
"classifications": ["Console","Plugin"],
"identity": "MycroForge.PluginTemplate",
"shortName": "m4gp",
"sourceName": "MycroForge.PluginTemplate",
"tags": {
"language": "C#",
"type": "project"
},
"preferNameDirectory": true,
"symbols": {
"class": {
"type": "parameter",
"replaces": "ExampleCommand",
"fileRename": "ExampleCommand"
},
"command": {
"type": "parameter",
"replaces": "example"
}
}
}

View File

@@ -0,0 +1,36 @@
using System.CommandLine;
using MycroForge.Core;
using MycroForge.Core.Contract;
using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.PluginTemplate;
public class ExampleCommand : Command, ISubCommandOf<RootCommand>
{
private readonly Argument<string> NameArgument =
new(name: "name", description: "The name of the person to greet");
private readonly Option<bool> AllCapsOption =
new(aliases: ["-a", "--all-caps"], description: "Print the name in all caps");
private readonly ProjectContext _context;
public ExampleCommand(ProjectContext context) :
base("example", "A basic command plugin generated by the 'm4g plugin init' command")
{
_context = context;
AddArgument(NameArgument);
AddOption(AllCapsOption);
this.SetHandler(ExecuteAsync, NameArgument, AllCapsOption);
}
private async Task ExecuteAsync(string name, bool allCaps)
{
name = allCaps ? name.ToUpper() : name;
await _context.CreateFile("example.txt",
$"Hello {name}!",
"This file was generated by the 'm4g example' command!"
);
}
}

View File

@@ -0,0 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using MycroForge.Core.Contract;
using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.PluginTemplate;
public class ExampleCommandPlugin : ICommandPlugin
{
public string Name => "MycroForge.PluginTemplate";
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<ISubCommandOf<RootCommand>, ExampleCommand>();
}
}

View File

@@ -7,7 +7,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MycroForge.Core\MycroForge.Core.csproj" />
<Content Include=".template.config\template.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MycroForge.Core" Version="0.0.1" />
</ItemGroup>
</Project>

View File

View File

@@ -1,6 +0,0 @@
namespace MycroForge.TestPlugin;
public static class Constants
{
public const string MainCommandName = "mj";
}

View File

@@ -1,25 +0,0 @@
using System.CommandLine;
using MycroForge.Core;
using MycroForge.Core.Contract;
using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.TestPlugin;
public class MyJewelleryCommand : Command, ISubCommandOf<RootCommand>
{
private readonly ProjectContext _context;
public MyJewelleryCommand(ProjectContext context) :
base(Constants.MainCommandName, "Custom command for My Jewellery specific stuff")
{
_context = context;
this.SetHandler(ExecuteAsync);
}
private async Task ExecuteAsync()
{
await _context.CreateFile("hello_world.txt",
"My Jewellery command plugin is working!"
);
}
}

View File

@@ -1,16 +0,0 @@
using Microsoft.Extensions.DependencyInjection;
using MycroForge.Core.Contract;
using RootCommand = MycroForge.Core.RootCommand;
namespace MycroForge.TestPlugin;
public class MyJewelleryCommandPlugin : ICommandPlugin
{
public string Name => $"{nameof(MycroForge)}.{nameof(TestPlugin)}";
public string Command => Constants.MainCommandName;
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<ISubCommandOf<RootCommand>, MyJewelleryCommand>();
}
}

View File

@@ -4,7 +4,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.CLI", "MycroForg
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.Core", "MycroForge.Core\MycroForge.Core.csproj", "{CFF8BD4E-520D-4319-BA80-3F49B5F493BA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.TestPlugin", "MycroForge.TestPlugin\MycroForge.TestPlugin.csproj", "{7C479E68-98FA-4FBC-B5E4-7116015774B3}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.PluginTemplate", "MycroForge.PluginTemplate\MycroForge.PluginTemplate.csproj", "{114A2B34-D77E-42AE-ADAF-0CD68C7B8D32}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.PluginTemplate.Package", "MycroForge.PluginTemplate.Package\MycroForge.PluginTemplate.Package.csproj", "{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.CLI.Tests", "MycroForge.CLI.Tests\MycroForge.CLI.Tests.csproj", "{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -20,9 +24,17 @@ Global
{CFF8BD4E-520D-4319-BA80-3F49B5F493BA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CFF8BD4E-520D-4319-BA80-3F49B5F493BA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CFF8BD4E-520D-4319-BA80-3F49B5F493BA}.Release|Any CPU.Build.0 = Release|Any CPU
{7C479E68-98FA-4FBC-B5E4-7116015774B3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7C479E68-98FA-4FBC-B5E4-7116015774B3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7C479E68-98FA-4FBC-B5E4-7116015774B3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7C479E68-98FA-4FBC-B5E4-7116015774B3}.Release|Any CPU.Build.0 = Release|Any CPU
{114A2B34-D77E-42AE-ADAF-0CD68C7B8D32}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{114A2B34-D77E-42AE-ADAF-0CD68C7B8D32}.Debug|Any CPU.Build.0 = Debug|Any CPU
{114A2B34-D77E-42AE-ADAF-0CD68C7B8D32}.Release|Any CPU.ActiveCfg = Release|Any CPU
{114A2B34-D77E-42AE-ADAF-0CD68C7B8D32}.Release|Any CPU.Build.0 = Release|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C5C5B9A-3C90-4FE7-A1AC-2F46C3CD0D69}.Release|Any CPU.Build.0 = Release|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{71A7EA9D-3C12-4FDE-BA4F-BDD1961DDA1B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -1,23 +1,3 @@
### Dependencies
### Documentation
- git
- 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.
`Ubuntu-22.04` is the recommended WSL version to use.
### TODO
- Figure out why BashException cannot be caught, can it be due to the differences in scoping?
Because the `Bash` class is static and the services calling `Bash.ExecuteAsync` are in the container.
Maybe this in combination with the async nature of the whole thing?
### Install
Run the install script in the same directory as the downloaded zip. See the example below for linux-x64.
`sudo ./install.sh m4g-<platform>.zip <platform>`
Go to https://m4g.devdisciples.com for the docs.

View File

@@ -1,141 +0,0 @@
# 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 `m4g init` command will initialize new projects with git by default.
If you don't want to use git you can pass the option `--without git` to `m4g init`.
### .venv
To promote isolation of Python dependencies, new projects are initialized with a virtual environment by default.
TODO: This is a good section to introduce the `m4g hydrate` command.
### api/routers/hello.py
This file defines a basic example router, which is imported and mapped in `main.py`. This router is just an example and
can be removed or modified at you discretion.
### db/engine/async_session.py
This file defines the `async_session` function, which can be used to open an asynchronous session to a database.
### db/entities/entity_base.py
This file contains an automatically generated entity base class that derives from the DeclarativeBase.
All entities must inherit from this class, so that SQLAlchemy & alembic can track them. The entities directory is also
where all newly generated entities will be stored.
### db/versions
This is where the generated database migrations will be stored.
### 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 closer look at the imports, you'll see that the file has been modified to assign `EntityBase.metadata` to
a variable called `target_metadata`, this will allow alembic to track changes in your entities. You'll also find 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 to include the generated classes.
### db/script.py.mako
This file is automatically generated by the alembic init command.
### db/settings.py
This file defines the `DbSettings` class, that is responsible for retrieving the database connectionstring.
You will probably want to modify this class to retrieve the connectionstring from a secret manager at some point.
### .gitignore
The default .gitignore file that is generated by the `m4g init` command. Modify this file at your discretion.
### alembic.ini
This file is automatically generated by the alembic init command.
### db.docker-compose.yml
A docker compose file for running a database locally.
### m4g.json
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. When generating entities, many-to-many relations or routers, this file will be
modified to include the generated files.
### requirements.txt
The requirements file containing the Python dependencies.
TODO: introduce the `m4g install` & `m4g uninstall` commands.
## Scripting
TODO: Dedicate a section to scripting

View File

@@ -1,279 +0,0 @@
# Quick tutorial
We're going to build a simple todo app, to demonstrate the capabilities of the MycroForge CLI.
After this tutorial, you should have a solid foundation to start exploring and using MycroForge to develop your
projects.
## General notes
The commands in this tutorial assume that you're running them from a MycroForge root directory.
## Initialize the project
Open a terminal and `cd` into the directory where your project should be created.
Run `m4g init todo-app` to initialize a new project and open the newly created project by running `code todo-app`.
Make sure you have `vscode` on you machine before running this command. If you prefer using another editor, then
manually open the generated `todo-app` folder.
## Setup the database
Our todo app needs to keep track of todos, so it needs a storage mechanism of some sorts. A database should be one
of the first things, if not THE first thing, that comes to mind. Luckily, MycroForge provides you with a locally hosted
database for you project out of the box. This setup, is powered by docker compose and can be examined by opening the
`db.docker-compose.yml` file in the project. Follow along to learn how to use the docker based database when developing
locally.
### Run the database
The first step is to start the database, you can do this by running the following command in a terminal.
`m4g db run`
This command starts the services defined in the `db.docker-compose.yml` file.
You can verify that the services are up by running `docker container ls`. If everything went well, then the previous
command should output the service names defined in `db.docker-compose.yml`.
If you go to [PhpMyAdmin (i.e. http://localhost:5051)](http://localhost:5051), you should now be able to login with the
following credentials.
- user: root
- pass: password
When you're done developing, you can shut down the local database by running `m4g db stop`
### Create the entities
Now that the database is running, we can start to create our entities. Run the commands below to create the `Todo` &
`Tag` entities.
`m4g db generate entity Tag --column "description:str:String(255)"`
`m4g db generate entity Todo --column "description:str:String(255)" -c "is_done:bool:Boolean()"`
After running these commands, you should find the generated entities in the `db/entities` folder of your project.
You should also see that the `main.py` & `db/env.py` files have been modified to include the newly generated entity.
For more information about the `m4g db generate entity` command, you can run `m4g db generate entity -?`.
### Define a many-to-many relation between Todo & Tag
To allow for relations between `Todo` & `Tag`, we'll define a many-to-many relation between the two entities.
This relation makes sense, because a `Todo` can have many `Tags` and a `Tag` could belong to many `Todos`.
You can generate this relation by running the following command from you terminal.
Creating a one-to-many relation would also make sense, but for the purpose of demonstration we're going to demonstrate
the many-to-many relation, because this one is the most complex, since it requires an additional mapping to be included
in the database schema.
`m4g db link many Todo --to-many Tag`
After running this command you should see that both the `Todo` and `Tag` entities now have a new field referencing the
a `List` containing instances of the other entity.
For more information about the `m4g db link` command try running `m4g db link -?`. Note that you can do the same thing
for all sub commands, so if you want to know more about `m4g db link many` you can simply run `m4g db link many -?` to
examine the command. The same is true for all the other commands as well.
### Generate the migration
Now that we've generated our entities, it's time to generate a migration that will apply these changes in the database.
Generate the initial migration by running the following command.
`m4g db generate migration initial_migration`
After running this command, you should see the new migration in the `db/version` directory.
### Apply the migration
The last step for the database setup is to actually apply the new migration to the database. This can be done by running
the following command.
`m4g db migrate`
After running this command, you should now see a populated schema when visiting [PhpMyAdmin](http://localhost:5051).
If for whatever reason you want to undo the last migration, you can simply run `m4g db rollback`.
## Setup the API
### Generate CRUD for Tag & Todo
Our API should provide use with basic endpoint to manage the `Todo` & `Tag` entities, i.e. CRUD functionality.
Writing this code can be boring, since it's pretty much boilerplate with some custom additions sprinkled here and there.
Fortunately, MycroForge can generate a good chunk of this boring code on your behalf. Run the following commands to
generate CRUD functionality for the `Todo` & `Tag` classes.
`m4g api generate crud Tag`
`m4g api generate crud Todo`
After running this command you should see that the `api/requests`,`api/routers` & `api/services` now contain the
relevant classes need to support the generated CRUD functionality. This could should be relatively straightforward, so
we won't dive into it, but feel free to take a break and explore what the generated code actually does. Another thing to
note, is that the generated routers are also automatically included in `main.py`.
### Modify the generated Todo request classes
Since we have a many-to-many relationship between `Todo` & `Tag`, the generated CRUD functionality isn't quite ready
yet. We need to be able to specify which `Tags` to add to a `Todo` when creating or updating it.
To do this, we will allow for a `tag_ids` field in both the `CreateTodoRequest` & the `UpdateTodoRequest`.
This field will contain the ids of the `Tags` that are associated with a `Todo`.
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
The `TodoService` will also need to be updated to accomodate the management of `tag_ids`.
Add the following imports in `api/services/todo_service.py`.
```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
# After
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
# 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
```
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()
# 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()
```
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
# 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)
else:
entity.tags = []
await session.commit()
return True
```
At this point, the app should be ready to test.
TODO: Elaborate!

11
nuget.docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
version: '3.8'
services:
nuget:
image: bagetter/bagetter:latest
container_name: nuget
ports:
- '5555:8080'
volumes:
nuget_data: