Compare commits

29 Commits

Author SHA1 Message Date
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
82 changed files with 9860 additions and 916 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,21 @@
name: Test and build
run-name: ${{ gitea.actor }} is building MycroForge.CLI
on: [ push ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
- uses: https://github.com/actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
# - name: "Run dotnet restore"
# run: dotnet restore
# - name: "Run dotnet build"
# run: dotnet build --no-restore
- name: "Reference MycroForge.Core in MycroForge.PluginTemplate"
run: dotnet add MycroForge.PluginTemplate reference MycroForge.Core
- name: "Run MycroForge.CLI.Tests"
# run: dotnet test --no-build --verbosity normal
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",
@@ -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

@@ -96,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);
@@ -136,21 +140,25 @@ 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 fqn = new FullyQualifiedName(name);
var path = $"{Features.Db.FeatureName}/entities";
var path = Path.Join(Features.Db.FeatureName, "entities");
if (fqn.HasPath)
path = Path.Combine(path, fqn.Path);
if (fqn.HasNamespace)
path = Path.Join(path, fqn.Namespace);
path = Path.Combine(path, $"{fqn.SnakeCasedName}.py");
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

@@ -1,28 +1,48 @@
using Humanizer;
using MycroForge.CLI.Extensions;
namespace MycroForge.CLI.Commands;
public class FullyQualifiedName
{
public string Path { get; }
public string Namespace { get; }
public string PascalizedName { get; }
public string SnakeCasedName { get; }
public bool HasPath => Path.Length > 0;
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];
}
Path = path;
Namespace = path;
PascalizedName = name.Pascalize();
SnakeCasedName = name.Underscore().ToLower();
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

@@ -21,6 +21,11 @@ public partial class MycroForge
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;
@@ -34,12 +39,22 @@ public partial class MycroForge
AddOption(DbhPortOption);
AddOption(DbuPortOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, DbhPortOption, DbuPortOption, DbuPlatformOption);
}
private async Task ExecuteAsync(int dbhPort, int dbuPort)
private async Task ExecuteAsync(
int dbhPort,
int dbuPort,
ProjectConfig.DbConfig.DbuPlatformOptions dbuPlatform
)
{
_optionsContainer.Set(new Features.Db.Options { DbhPort = dbhPort, DbuPort = dbuPort });
_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

@@ -29,11 +29,10 @@ public partial class MycroForge
private async Task ExecuteAsync(string entity)
{
var fqn = new FullyQualifiedName(entity);
await new CrudServiceGenerator(_context).Generate(fqn.Path, fqn.PascalizedName);
await new RequestClassGenerator(_context).Generate(fqn.Path, fqn.PascalizedName, RequestClassGenerator.Type.Create);
await new RequestClassGenerator(_context).Generate(fqn.Path, fqn.PascalizedName, RequestClassGenerator.Type.Update);
await new CrudRouterGenerator(_context).Generate(fqn.Path, fqn.PascalizedName);
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

@@ -2,7 +2,6 @@ using System.CommandLine;
using Humanizer;
using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract;
using MycroForge.CLI.Extensions;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
@@ -44,19 +43,17 @@ public partial class MycroForge
private async Task ExecuteAsync(string name)
{
var fqn = new FullyQualifiedName(name);
var folderPath = $"{Features.Api.FeatureName}/routers";
var routersFolderPath = Path.Join(Features.Api.FeatureName, "routers");
_context.AssertDirectoryExists(folderPath);
if (fqn.HasNamespace)
routersFolderPath = Path.Join(routersFolderPath, fqn.Namespace);
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Combine(folderPath, fileName);
var filePath = Path.Join(routersFolderPath, fileName);
await _context.CreateFile(filePath, Template);
var moduleImportPath = folderPath
var moduleImportPath = routersFolderPath
.Replace('\\', '.')
.Replace('/', '.');

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")
@@ -70,25 +128,27 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, IEnumerable<string> columns)
{
var fqn = new FullyQualifiedName(name);
var folderPath = $"{Features.Db.FeatureName}/entities";
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 (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
var _columns = GetColumnDefinitions(columns.ToArray());
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("%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 = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Combine(folderPath, fileName);
var filePath = Path.Join(folderPath, fileName);
await _context.CreateFile(filePath, code);
var importPathParts = new[] { folderPath, fileName.Replace(".py", "") }
@@ -109,25 +169,58 @@ public partial class MycroForge
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

@@ -23,7 +23,8 @@ public partial class MycroForge
{
var config = await _context.LoadConfig();
var env = $"DBH_PORT={config.Db.DbhPort} DBU_PORT={config.Db.DbuPort}";
await _context.Bash($"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d");
var command = $"{env} docker compose -f {Features.Db.FeatureName}.docker-compose.yml up -d";
await _context.Bash(command);
}
}
}

View File

@@ -21,9 +21,10 @@ public partial class MycroForge
private async Task ExecuteAsync()
{
var config = await _context.LoadConfig();
var env = $"DB_PORT={config.Db.DbhPort} PMA_PORT={config.Db.DbuPort}";
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;
@@ -60,10 +58,10 @@ public partial class MycroForge
var fqn = new FullyQualifiedName(name);
var folderPath = string.Empty;
if (fqn.HasPath)
folderPath = Path.Combine(folderPath, fqn.Path);
if (fqn.HasNamespace)
folderPath = Path.Join(folderPath, fqn.Namespace);
var filePath = Path.Combine(folderPath, $"{fqn.SnakeCasedName}.py");
var filePath = Path.Join(folderPath, $"{fqn.SnakeCasedName}.py");
var template = withSession ? WithSessionTemplate : DefaultTemplate;
var code = string.Join('\n', template)
.Replace("%class_name%", fqn.PascalizedName);

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

@@ -15,6 +15,7 @@ public partial class MycroForge
ApiPort = ctx.ParseResult.GetValueForOption(ApiPortOption),
DbhPort = ctx.ParseResult.GetValueForOption(DbhPortOption),
DbuPort = ctx.ParseResult.GetValueForOption(DbuPortOption),
DbuPlatform = ctx.ParseResult.GetValueForOption(DbuPlatformOption),
};
}
}

View File

@@ -1,4 +1,6 @@
namespace MycroForge.CLI.Commands;
using MycroForge.Core;
namespace MycroForge.CLI.Commands;
public partial class MycroForge
{
@@ -11,6 +13,7 @@ public partial class MycroForge
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()
{
@@ -20,7 +23,8 @@ public partial class MycroForge
public Features.Db.Options DbOptions => new()
{
DbhPort = DbhPort <= 0 ? 5050 : DbhPort,
DbuPort = DbuPort <= 0 ? 5051 : DbhPort
DbuPort = DbuPort <= 0 ? 5051 : DbhPort,
DbuPlatform = DbuPlatform
};
}
}

View File

@@ -39,6 +39,11 @@ public partial class MycroForge
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;
@@ -55,6 +60,7 @@ public partial class MycroForge
AddOption(ApiPortOption);
AddOption(DbhPortOption);
AddOption(DbuPortOption);
AddOption(DbuPlatformOption);
this.SetHandler(ExecuteAsync, new Binder());
}
@@ -75,7 +81,7 @@ public partial class MycroForge
await _context.CreateFile("main.py");
// 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);
@@ -100,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 =
@@ -28,7 +30,7 @@ public partial class MycroForge
if (packs.Length == 0)
{
Console.WriteLine("m4g install requires at least one package.");
Console.WriteLine("'m4g install' requires at least one package.");
return;
}

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

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

@@ -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,7 +8,7 @@ namespace MycroForge.CLI.Extensions;
public static class ServiceCollectionExtensions
{
public static IServiceCollection RegisterCommandDefaults(this IServiceCollection services)
public static IServiceCollection RegisterDefaultCommands(this IServiceCollection services)
{
// Register ProjectContext, OptionsContainer & features
services.AddScoped<ProjectContext>();

View File

@@ -2,11 +2,8 @@
public static class StringExtensions
{
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

@@ -1,10 +1,15 @@
namespace MycroForge.CLI.Features;
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

@@ -37,7 +37,6 @@ public sealed partial class Db : IFeature
private static readonly string[] DockerCompose =
[
"version: '3.8'",
"# Access the database UI at http://localhost:${DBU_PORT}.",
"# Login: username = root & password = password",
"",
@@ -58,7 +57,7 @@ public sealed partial 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:",
" - '${DBU_PORT}:80'",
@@ -87,15 +86,16 @@ public sealed partial class Db : IFeature
{
_optionsContainer = optionsContainer;
}
public async Task ExecuteAsync(ProjectContext context)
{
var options = _optionsContainer.Get<Options>();
var config = await context.LoadConfig(create: true);
config.Db = new()
{
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051
DbhPort = options.DbhPort ?? 5050,
DbuPort = options.DbuPort ?? 5051,
DbuPlatform = options.DbuPlatform
};
await context.SaveConfig(config);
@@ -123,7 +123,10 @@ public sealed partial 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

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

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

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

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

@@ -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 DbhPort { get; set; }
public int DbuPort { get; set; }
[JsonIgnore]
public DbuPlatformOptions DbuPlatform { get; set; }
}
}

View File

@@ -9,7 +9,7 @@ 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(bool create = false)
@@ -21,8 +21,8 @@ public class ProjectContext
}
var config = await JsonSerializer.DeserializeAsync<ProjectConfig>(
File.OpenRead(ConfigPath),
Serialization.DefaultJsonSerializerOptions.CamelCasePrettyPrint
File.OpenRead(ConfigPath),
DefaultJsonSerializerOptions.CamelCasePrettyPrint
);
if (config is null)
@@ -37,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;
@@ -59,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)
@@ -74,10 +63,11 @@ 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)
@@ -114,21 +104,19 @@ 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;
}
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,4 @@
#!/bin/bash
dotnet build -r Release
dotnet nuget push --source devdisciples bin/Release/MycroForge.Core.1.0.0.nupkg

View File

@@ -8,7 +8,7 @@ https://learn.microsoft.com/en-us/dotnet/core/tutorials/cli-templates-create-tem
### Build the package
`dotnet pack`
### Push to local nuget
### Push to devdisciples nuget
`dotnet nuget push bin/Release/MycroForge.PluginTemplate.Package.1.0.0.nupkg --source devdisciples`
### Install template package from local nuget

View File

View File

@@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MycroForge.PluginTemplate",
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
Debug|Any CPU = Debug|Any CPU
@@ -30,5 +32,9 @@ Global
{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,29 +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>`
### Add DevDisciples NuGet source
```bash
dotnet nuget add source --name devdisciples --username username --password password https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json --store-password-in-clear-text
```
Go to https://m4g.devdisciples.com for the docs.

3
docs/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.docusaurus/
node_modules/
.k8s/

3
docs/.gitignore vendored
View File

@@ -18,3 +18,6 @@
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.k8s/remote*.yml
!.k8s/remote.example.yml

61
docs/.k8s/local.yml Normal file
View File

@@ -0,0 +1,61 @@
# =========================================
# App manifest
# =========================================
---
# App Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: m4g-docs-deployment
spec:
replicas: 1
selector:
matchLabels:
app: m4g-docs
template:
metadata:
labels:
app: m4g-docs
spec:
containers:
- name: m4g-docs
image: m4gdocs:latest
imagePullPolicy: Never
ports:
- containerPort: 80
---
# App Service
apiVersion: v1
kind: Service
metadata:
name: m4g-docs-service
spec:
selector:
app: m4g-docs
ports:
- protocol: TCP
port: 80
targetPort: 80
type: NodePort
---
# App Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: m4g-docs-ingress
spec:
ingressClassName: caddy
rules:
- host: m4g.docs.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: m4g-docs-service
port:
number: 80

View File

@@ -0,0 +1,67 @@
# =========================================
# App manifest
# =========================================
---
# App Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: m4g-docs-deployment
spec:
replicas: 1
selector:
matchLabels:
app: m4g-docs
template:
metadata:
labels:
app: m4g-docs
spec:
containers:
- name: m4g-docs
image: git.devdisciples.com/devdisciples/m4gdocs:latest
imagePullPolicy: Always
ports:
- containerPort: 80
---
# App Service
apiVersion: v1
kind: Service
metadata:
name: m4g-docs-service
spec:
selector:
app: m4g-docs
ports:
- protocol: TCP
port: 80
targetPort: 80
type: NodePort
---
# App Ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: m4g-docs-ingress
annotations:
cert-manager.io/cluster-issuer: lets-encrypt
spec:
ingressClassName: public
tls:
- hosts:
- m4g.example.com
secretName: example-tls-secret
rules:
- host: m4g.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: m4g-docs-service
port:
number: 80

27
docs/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
# syntax=docker/dockerfile:1
# Stage 1: Base image.
## Start with a base image containing NodeJS so we can build Docusaurus.
FROM node:lts AS base
## Disable colour output from yarn to make logs easier to read.
ENV FORCE_COLOR=0
## Enable corepack.
RUN corepack enable
## Set the working directory to `/opt/docusaurus`.
WORKDIR /opt/docusaurus
# Stage 2b: Production build mode.
FROM base AS prod
## Set the working directory to `/opt/docusaurus`.
WORKDIR /opt/docusaurus
## Copy over the source code.
COPY . /opt/docusaurus/
## Install dependencies with `--immutable` to ensure reproducibility.
RUN npm ci
## Build the static site.
RUN npm run build
## Use a stable nginx image
FROM nginx:stable-alpine AS deploy
WORKDIR /home/node/app
COPY --chown=node:node --from=prod /opt/docusaurus/build/ /usr/share/nginx/html/

View File

@@ -39,3 +39,24 @@ $ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
### Build Docker image
```bash
docker build -t git.devdisciples.com/devdisciples/m4gdocs:latest -t m4gdocs:latest .
```
### Push Docker image
```bash
docker push git.devdisciples.com/devdisciples/m4gdocs:latest
```
### Update in Kubernetes Cluster
Update the image by deleting the old pod, this will automatically pull in the latest image and deploy it.
Run the following command on the relevant node to achieve this.
```bash
kubectl delete pods -l app=m4g-docs
```

View File

@@ -1,3 +1,7 @@
---
sidebar_position: 5
---
# Commands
```

View File

@@ -12,7 +12,8 @@ Usage:
m4g add db [options]
Options:
--database-host-port, --dbh-port <database-host-port> The database host port
--database-ui-port, --dbu-port <database-ui-port> The database UI port
-?, -h, --help Show help and usage information
--database-host-port, --dbh-port <database-host-port> The database host port
--database-ui-port, --dbu-port <database-ui-port> The database UI port
--database-ui-platform, --dbu-platform <linux_amd64|linux_arm32v5|linux_arm32v6|linux_arm32v7|linux_arm64v8> The docker platform for the PhpMyAdmin image
-?, -h, --help Show help and usage information
```

View File

@@ -15,9 +15,10 @@ Arguments:
<name> The name of your project
Options:
--without <api|db|git|gitignore> Features to exclude
--api-port <api-port> The API port
--database-host-port, --dbh-port <database-host-port> The database host port
--database-ui-port, --dbu-port <database-ui-port> The database UI port
-?, -h, --help Show help and usage information
--without <api|db|git|gitignore> Features to exclude
--api-port <api-port> The API port
--database-host-port, --dbh-port <database-host-port> The database host port
--database-ui-port, --dbu-port <database-ui-port> The database UI port
--database-ui-platform, --dbu-platform <amd64|arm32v5|arm32v6|arm32v7|arm64v8> The docker platform for the PhpMyAdmin image
-?, -h, --help Show help and usage information
```

View File

@@ -0,0 +1,178 @@
---
sidebar_position: 6
---
# Command plugins
MycroForge has a plugin system that allows you to extend the CLI with your own commands.
This section will guide you through the process of creating your own extension to the `m4g` command.
MycroForge is written in C# sharp and this is the same for plugins, so decent knowledge about `C#` & `.NET` is required.
In this tutorial we will create a command plugin that extens the `m4g` command with a `dotenv` sub command.
What this command will do is generate a `.env` file in the current directory and print a message to the console.
## Setup
To start creating command plugins for MycroFroge, make sure you've added the devdisciples package repository.
This can be done by running the following command.
```
dotnet nuget add source --name devdisciples https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
```
Run the following command to add the `MycroForge.PluginTemplate.Package`.
```
dotnet add package --source devdisciples --version 1.0.0 MycroForge.PluginTemplate.Package
```
## Initialize a plugin package
Generate a template plugin project by running the following command.
```
m4g plugin init My.Dotenv.Plugin
```
This should generate the following folder structure.
```
My.Dotenv.Plugin
┣ 📜HelloWorldCommand.cs
┣ 📜HelloWorldCommandPlugin.cs
┗ 📜My.Dotenv.Plugin.csproj
```
Rename the following files. Also rename the classes in these files, the easiest way in `vscode` is to right click the class name and select the `Rename symbol` action. Note that this action does not (necessarily) rename the files!
```
HelloWorldCommand.cs => DotenvCommand.cs
HelloWorldCommandPlugin.cs => DotenvCommandPlugin.cs
```
Modify `Name` property in `DotenvCommandPlugin.cs`.
```cs
// Before
public class DotenvCommandPlugin : ICommandPlugin
{
public string Name => "My.Plugin";
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<ISubCommandOf<RootCommand>, HelloWorldCommand>();
}
}
// After
public class DotenvCommandPlugin : ICommandPlugin
{
public string Name => "My.Dotenv.Plugin";
public void RegisterServices(IServiceCollection services)
{
services.AddScoped<ISubCommandOf<RootCommand>, HelloWorldCommand>();
}
}
```
Modify `DotenvCommand.cs`.
```cs
// Before
public class DotenvCommand : 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 DotenvCommand(ProjectContext context) :
base("hello", "An example command generated by dotnet new using the m4gp template")
{
_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("hello_world.txt",
$"Hello {name}!",
"This file was generated by your custom command!"
);
}
}
// After
public class DotenvCommand : Command, ISubCommandOf<RootCommand>
{
private readonly Argument<string> VarsArgument =
new(name: "vars", description: "Env vars to include in the .env file separated by ';'");
private readonly Option<bool> PrintOption =
new(aliases: ["-o", "--overwrite"], description: "Overwrite the .env file if it exists");
private readonly ProjectContext _context;
public DotenvCommand(ProjectContext context) :
// dotenv = the name of the sub command that will be added to the m4g command
base("dotenv", "Generate a .env file in the current directory")
{
_context = context;
AddArgument(VarsArgument);
AddOption(PrintOption);
this.SetHandler(ExecuteAsync, VarsArgument, PrintOption);
}
private async Task ExecuteAsync(string vars, bool overwrite)
{
var path = Path.Join(Environment.CurrentDirectory, ".env");
if (File.Exists(path))
{
if (overwrite)
{
await _context.WriteFile(".env", content);
return;
}
Console.WriteLine($"File {path} already exists, add the -o or --overwrite flag to overwrite it.");
}
var content = string.Join(Environment.NewLine, vars.Split(';'));
await _context.CreateFile(".env", content);
}
}
```
## Install the plugin
Open a terminal an make sure you're in the root directory of the plugin, i.e. the `My.Dotenv.Plugin` folder.
Run the following command to install the plugin.
```
m4g plugin install --platform <platform=linux_arm|linux_arm64|linux_x64|osx_arm64|osx_x64>
```
Make sure to choose the right platform option for your machine.
If everything went well then running `m4g` should now also show a `dotenv` command.
## Test the plugin
Try running `m4g dotenv "FIRSTNAME=JOHN;LASTNAME=JOE"`, this should generate a `.env` in the current directory with the vars you specified.
## Uninstall the plugin
Uninstall the plugin by running `m4g plugin install My.Dotenv.Plugin`.
## Resources
For examples of how the core commands are implemented, you can take a look at the commands in the [MycroForge.CLI.Commands](https://git.devdisciples.com/devdisciples/mycroforge/src/branch/main/MycroForge.CLI/Commands) namespace.
The MycroForge.CLI project uses [SystemCommand.Line](https://learn.microsoft.com/en-us/dotnet/standard/commandline/get-started-tutorial) for the CLI support, check out the Microsoft documentation for more info.

View File

@@ -0,0 +1,78 @@
---
sidebar_position: 2
---
# Getting Started
## Requirements
To use MycroForge, ensure you have the following dependencies installed:
- [**bash**](https://www.gnu.org/software/bash/)
- [**git**](https://git-scm.com/)
- [**Python 3.10**](https://www.python.org/downloads/release/python-3100/)
- [**Docker**](https://www.docker.com/)
- [**.NET 8**](https://dotnet.microsoft.com/en-us/download/dotnet/8.0)
- [**XCode Command Line Tools (MacOS only)**](https://mac.install.guide/commandlinetools/)
## Prior knowledge
Since this tool is meant to generate FastAPI & SQLAlchemy code, you should have decent knowledge of both frameworks. If not, then please follow the links below to learn more about them.
- [FastAPI](https://fastapi.tiangolo.com/)
- [SQLAlchemy](https://www.sqlalchemy.org/)
### Adding the Package Registry
Before installing MycroForge, add the package registry by running the following command:
```
dotnet nuget add source --name devdisciples https://git.devdisciples.com/api/packages/devdisciples/nuget/index.json
```
### Install
```
dotnet tool install -g MycroForge.CLI
```
### Uninstall
```
dotnet tool install -g MycroForge.CLI
```
### Windows
MycroForge is designed to run in a POSIX compliant environment. It has been tested on Windows using WSL2 with Ubuntu 22.04.03. So it is recommended to run MycroForge within the same or a similar WSL2 distribution for optimal performance.
### MacOS
#### Post install steps
After installing MycroForge, the dotnet CLI will show a message with some instructions to make the `m4g` command available in `zsh`.
It should look similar to the example below.
```sh
Tools directory '/Users/username/.dotnet/tools' is not currently on the PATH environment variable.
If you are using zsh, you can add it to your profile by running the following command:
cat << \EOF >> ~/.zprofile
# Add .NET Core SDK tools
export PATH="$PATH:/Users/username/.dotnet/tools"
EOF
And run zsh -l to make it available for current session.
You can only add it to the current session by running the following command:
export PATH="$PATH:/Users/username/.dotnet/tools"
```
### Known issues
#### FastAPI swagger blank screen (MacOS only)
If you see a blank screen when opening the FastAPI Swagger documentation, then make sure you've activated the Safari developer tools.
#### Database container doesn't start after running `m4g db run`
If the database, i.e. the mariadb container, doesn't successfully start after running `m4g db run` the first time, then try running it again. This often works, but yeah...it's hacky. However, at the time of writing it's unknown what causes this.

View File

@@ -1,21 +0,0 @@
---
sidebar_position: 2
---
# Install
## Requirements
MycroForge has the following dependencies.
- bash
- git
- Python3 (3.10)
- Docker
- .NET 8
### Windows
To simplify the implementation of this tool, it assumes that it's running in a POSIX compliant environment.
MycroForge has been developed and tested on Windows in WSL2 Ubuntu 22.04.03.
So when running on Windows, it's recommended to run MycroForge in the same environment or atleast in a similar WSL2 distro.

View File

@@ -2,144 +2,15 @@
sidebar_position: 1
---
# Intro
## What is MycroForge?
Welcome to **MycroForge** an opinionated CLI tool designed to streamline the development of FastAPI and SQLAlchemy-based backends. With MycroForge, you can effortlessly create backend projects through a convenient command line interface.
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.
## Key Features
## 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.
## Plugin system
TODO: Dedicate a section to the Plugin system
- **Project Skeleton Generation:** Quickly generate a well-structured project skeleton tailored for FastAPI and SQLAlchemy, ensuring you start with best practices.
- **Database Entities:** Easily create and manage database entities, simplifying your database interactions.
- **Migrations:** Handle database migrations seamlessly, allowing for smooth transitions and updates.
- **Routers:** Generate and manage routers to keep your application modular and organized.
- **CRUD Functionality:** Automatically generate basic CRUD (Create, Read, Update, Delete) operations to accelerate your development process.

View File

@@ -1,36 +1,10 @@
# General introduction
---
sidebar_position: 4
---
## What is MycroForge?
# Project layout
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.
When you generate a new project with `m4g init <project_name>`, it will create a folder like the example below.
```
📦<project_name>
@@ -67,7 +41,8 @@ If you don't want to use git you can pass the option `--without git` to `m4g ini
### .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.
When you clone a MycroForge repository from git, it won't have a `.venv` folder yet.
You can run `m4g hydrate` in the root folder of the project to restore the dependencies.
### api/routers/hello.py
@@ -133,9 +108,4 @@ 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
Whenever you run `m4g install` or `m4g uninstall` this file will be updated too.

View File

@@ -4,20 +4,21 @@ sidebar_position: 3
# 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.
In this tutorial, we'll build a simple todo app to demonstrate the capabilities of the MycroForge CLI.
By the end, you should have a solid foundation to start exploring and using MycroForge for your projects.
## General notes
The commands in this tutorial assume that you're running them from a MycroForge root directory.
The commands in this tutorial assume that you are running them from the root directory of your MycroForge project.
## Initialize the project
## 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.
Open a terminal and navigate (`cd`) to the directory where your project should be created.
Run the following command to initialize a new project and open it in VSCode:
```bash
m4g init todo-app
```
## Setup the database
@@ -31,28 +32,43 @@ locally.
The first step is to start the database, you can do this by running the following command in a terminal.
`m4g db run`
```bash
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
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`
:::info
If you're running on MacOS, Docker might complain about a platform mismatch for PhpMyAdmin.
In that case you might need to specify the platform for the PhpMyAdmin image.
You can do this by passing the `--dbu-platform` flag to `m4g init`.
Run `m4g init -?` for all the available options.
If you've already initialized a project, you can also change the platform prefix of the PhpMyAdmin image in the `db.docker-compose.yml`.
:::
### Create the entities
Now that the database is running, we can start to create our entities. Run the commands below to create the `Todo` &
Now that the database is running, we can start creating our entities. Run the commands below to create the `Todo` &
`Tag` entities.
`m4g db generate entity Tag --column "description:str:String(255)"`
```bash
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()"`
```bash
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.
@@ -68,7 +84,9 @@ Creating a one-to-many relation would also make sense, but for the purpose of de
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`
```bash
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.
@@ -82,7 +100,9 @@ examine the command. The same is true for all the other commands as well.
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`
```bash
m4g db generate migration initial_migration
```
After running this command, you should see the new migration in the `db/version` directory.
@@ -91,7 +111,9 @@ After running this command, you should see the new migration in the `db/version`
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`
```bash
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`.
@@ -105,9 +127,13 @@ Writing this code can be boring, since it's pretty much boilerplate with some cu
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`
```bash
m4g api generate crud Tag
```
`m4g api generate crud Todo`
```bash
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
@@ -121,15 +147,22 @@ yet. We need to be able to specify which `Tags` to add to a `Todo` when creating
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`.
Modify `CreateTodoRequest` in `api/requests/create_todo_request.py`.
```python
# Before
from pydantic import BaseModel
class CreateTodoRequest(BaseModel):
description: str = None
is_done: bool = None
description: str = None
is_done: bool = None
# After
from typing import List, Optional
from pydantic import BaseModel
class CreateTodoRequest(BaseModel):
description: str = None
is_done: bool = None
@@ -140,12 +173,19 @@ Modify `UpdateTodoRequest` in `api/requests/update_todo_request.py`, you might n
```python
# Before
from pydantic import BaseModel
from typing import Optional
class UpdateTodoRequest(BaseModel):
description: Optional[str] = None
is_done: Optional[bool] = None
tag_ids: Optional[List[int]] = []
description: Optional[str] = None
is_done: Optional[bool] = None
# After
from pydantic import BaseModel
from typing import List, Optional
class UpdateTodoRequest(BaseModel):
description: Optional[str] = None
is_done: Optional[bool] = None
@@ -167,16 +207,18 @@ Modify `TodoService.list`
```python
# Before
async with async_session() as session:
stmt = select(Todo)
results = (await session.scalars(stmt)).all()
return results
async def list(self) -> List[Todo]:
async with async_session() as session:
stmt = select(Todo)
results = (await session.scalars(stmt)).all()
return results
# After
async with async_session() as session:
stmt = select(Todo).options(selectinload(Todo.tags))
results = (await session.scalars(stmt)).all()
return results
async def list(self) -> List[Todo]:
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`
@@ -279,5 +321,11 @@ Modify `TodoService.update`
return True
```
At this point, the app should be ready to test.
TODO: Elaborate!
## Test the API!
Run the following command.
```bash
m4g api run
```
Go to http://localhost:5000/docs and test your Todo API!

View File

@@ -4,8 +4,8 @@ import type * as Preset from '@docusaurus/preset-classic';
const config: Config = {
title: 'MycroForge',
tagline: 'Your FastAPI & SQLAlchemy assistant!',
favicon: 'img/favicon.ico',
tagline: 'Your FastAPI & SQLAlchemy toolkit!',
// favicon: 'img/favicon.ico',
// Set the production url of your site here
url: 'https://git.devdisciples.com',
@@ -52,10 +52,10 @@ const config: Config = {
image: 'img/docusaurus-social-card.jpg',
navbar: {
title: 'MycroForge',
logo: {
alt: 'MycroForge Logo',
src: 'img/logo.svg',
},
// logo: {
// alt: 'MycroForge Logo',
// src: 'img/logo.svg',
// },
items: [
{
type: 'docSidebar',
@@ -77,8 +77,9 @@ const config: Config = {
copyright: `Copyright © ${new Date().getFullYear()} DevDisciples`,
},
prism: {
theme: prismThemes.github,
darkTheme: prismThemes.dracula,
theme: prismThemes.oneLight,
darkTheme: prismThemes.oneDark,
additionalLanguages: ["csharp"]
},
} satisfies Preset.ThemeConfig,
};

View File

@@ -4,7 +4,7 @@
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"start": "docusaurus start --host 0.0.0.0",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",

View File

@@ -1,35 +0,0 @@
asgiref==3.5.0
blinker==1.4
click==8.0.3
colorama==0.4.4
command-not-found==0.3
cryptography==3.4.8
dbus-python==1.2.18
distro==1.7.0
distro-info==1.1+ubuntu0.2
h11==0.13.0
httplib2==0.20.2
importlib-metadata==4.6.4
jeepney==0.7.1
keyring==23.5.0
launchpadlib==1.10.16
lazr.restfulclient==0.14.4
lazr.uri==1.0.6
more-itertools==8.10.0
netifaces==0.11.0
oauthlib==3.2.0
PyGObject==3.42.1
PyJWT==2.3.0
pyparsing==2.4.7
python-apt==2.4.0+ubuntu3
PyYAML==5.4.1
SecretStorage==3.3.1
six==1.16.0
systemd-python==234
ubuntu-pro-client==8001
ufw==0.36.1
unattended-upgrades==0.1
uvicorn==0.15.0
wadllib==1.3.6
wsproto==1.0.0
zipp==1.0.0

View File

@@ -4,47 +4,49 @@ import styles from './styles.module.css';
type FeatureItem = {
title: string;
Svg: React.ComponentType<React.ComponentProps<'svg'>>;
// Svg: React.ComponentType<React.ComponentProps<'svg'>>;
description: JSX.Element;
};
const FeatureList: FeatureItem[] = [
{
title: 'Initialize a skeleton project quickly',
Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
title: 'Initialize a Skeleton Project Quickly',
// Svg: require('@site/static/img/undraw_docusaurus_mountain.svg').default,
description: (
<>
Initialize a skeleton project that supports FastAPI and SQLAlchemy with a single command.
Here is an example. <code>m4g init todo-app</code>
Start a new FastAPI and SQLAlchemy project with a single command.
For example: <code>m4g init todo-app</code>
</>
),
},
{
title: 'Generate common items',
Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
title: 'Generate Common Items',
// Svg: require('@site/static/img/undraw_docusaurus_tree.svg').default,
description: (
<>
MycroForge allows you to generate boilerplate code for common items like entities, service & routers.
It can even generate a basic CRUD setup around an entity!
Use MycroForge to generate boilerplate code for entities, services, and routers.
It even supports basic CRUD setup!
</>
),
},
{
title: 'Extend MycroForge',
Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
// Svg: '',
// Svg: require('@site/static/img/undraw_docusaurus_react.svg').default,
description: (
<>
Extend MycroForge with your own commands by creating a plugin!
Create plugins to extend MycroForge with your own custom commands.
</>
),
},
];
function Feature({title, Svg, description}: FeatureItem) {
function Feature({ title, description }: FeatureItem) {
// function Feature({ title, Svg, description }: FeatureItem) {
return (
<div className={clsx('col col--4')}>
<div className={clsx('col col--4', styles.feature)}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
{/* <Svg className={styles.featureSvg} role="img" /> */}
</div>
<div className="text--center padding-horiz--md">
<Heading as="h3">{title}</Heading>

View File

@@ -20,7 +20,7 @@ function HomepageHeader() {
<Link
className="button button--secondary button--lg"
to="/docs/intro">
MycroForge Tutorial
Get started now!
</Link>
</div>
</div>
@@ -32,7 +32,7 @@ export default function Home(): JSX.Element {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
title={`Home page`}
description="Description will go into a meta tag in <head />">
<HomepageHeader />
<main>

8380
docs/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

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!