Cleaned up project and added constraints for SQLAlchemy types in generate entity

This commit is contained in:
mdnapo 2024-07-24 07:18:42 +02:00
parent 91431fd996
commit 32b7a3c01c
16 changed files with 186 additions and 146 deletions

View File

@ -96,14 +96,18 @@ public partial class EntityLinker
var left = await LoadEntity(_left); var left = await LoadEntity(_left);
var right = await LoadEntity(_right); var right = await LoadEntity(_right);
var associationTable = string.Join('\n', AssociationTable); var associationTable = string.Join('\n', AssociationTable)
associationTable = associationTable
.Replace("%left_entity%", left.ClassName.Underscore().ToLower()) .Replace("%left_entity%", left.ClassName.Underscore().ToLower())
.Replace("%right_entity%", right.ClassName.Underscore().ToLower()) .Replace("%right_entity%", right.ClassName.Underscore().ToLower())
.Replace("%left_table%", left.TableName) .Replace("%left_table%", left.TableName)
.Replace("%right_table%", right.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); await _context.CreateFile(associationTablePath, associationTable);
@ -136,21 +140,25 @@ public partial class EntityLinker
var env = await _context.ReadFile($"{Features.Db.FeatureName}/env.py"); var env = await _context.ReadFile($"{Features.Db.FeatureName}/env.py");
env = new DbEnvModifier(env, associationTableImportPath, associationTableImportName).Rewrite(); env = new DbEnvModifier(env, associationTableImportPath, associationTableImportName).Rewrite();
await _context.WriteFile($"{Features.Db.FeatureName}/env.py", env); await _context.WriteFile($"{Features.Db.FeatureName}/env.py", env);
var main = await _context.ReadFile("main.py"); 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); await _context.WriteFile("main.py", main);
} }
private async Task<EntityModel> LoadEntity(string name) private async Task<EntityModel> LoadEntity(string name)
{ {
var fqn = new FullyQualifiedName(name); var fqn = new FullyQualifiedName(name);
var path = $"{Features.Db.FeatureName}/entities"; var path = Path.Join(Features.Db.FeatureName, "entities");
if (fqn.HasNamespace) if (fqn.HasNamespace)
path = Path.Combine(path, fqn.Namespace); 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)); var entity = new EntityModel(fqn.PascalizedName, path, await _context.ReadFile(path));
entity.Initialize(); entity.Initialize();
return entity; return entity;

View File

@ -7,14 +7,16 @@ namespace MycroForge.CLI.CodeGen;
public class RequestClassGenerator 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 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 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 public enum Type
{ {
@ -47,7 +49,7 @@ public class RequestClassGenerator
var entitySource = await _context.ReadFile(entityFilePath); var entitySource = await _context.ReadFile(entityFilePath);
var fieldInfo = ReadFields(entitySource); var fieldInfo = ReadFields(entitySource);
var fields = string.Join('\n', fieldInfo.Select(x => ToFieldString(x, type))); var fields = string.Join('\n', fieldInfo.Select(x => ToFieldString(x, type)));
var requestFilePath = Path.Join( var requestFilePath = Path.Join(
Features.Api.FeatureName, Features.Api.FeatureName,
"requests", "requests",
@ -100,10 +102,11 @@ public class RequestClassGenerator
.Replace(" ", ""); .Replace(" ", "");
Console.WriteLine(str); // = "List,Dict,str,Any" Console.WriteLine(str); // = "List,Dict,str,Any"
*/ */
var dissectedTypes = field.Type.Replace("[", ",") var dissectedTypes = field.Type
.Replace("[", ",")
.Replace("]", "") .Replace("]", "")
.Replace(" ", "") .Replace(" ", "")
.Split(); .Split(',');
foreach (var dissectedType in dissectedTypes) foreach (var dissectedType in dissectedTypes)
{ {
@ -164,16 +167,16 @@ public class RequestClassGenerator
.Split(',') .Split(',')
.Select(s => s.Trim()) .Select(s => s.Trim())
.ToArray(); .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) if (imports.FirstOrDefault(i => i.Name == "typing") is Import typingImport)
{ {
typingImport.Types.AddRange(["Any", "Dict", "List", "Optional"]); typingImport.Types.AddRange(PythonTypingImports);
} }
else else
{ {
imports.Add(new("typing", ["Any", "Dict", "List", "Optional"])); imports.Add(new Import("typing", PythonTypingImports));
} }
return imports; return imports;

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

@ -43,16 +43,16 @@ public partial class MycroForge
private async Task ExecuteAsync(string name) private async Task ExecuteAsync(string name)
{ {
var fqn = new FullyQualifiedName(name); var fqn = new FullyQualifiedName(name);
var routersFolderPath = $"{Features.Api.FeatureName}/routers"; var routersFolderPath = Path.Join(Features.Api.FeatureName, "routers");
if (fqn.HasNamespace) if (fqn.HasNamespace)
routersFolderPath = Path.Combine(routersFolderPath, fqn.Namespace); routersFolderPath = Path.Join(routersFolderPath, fqn.Namespace);
var fileName = $"{fqn.SnakeCasedName}.py"; var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Combine(routersFolderPath, fileName); var filePath = Path.Join(routersFolderPath, fileName);
await _context.CreateFile(filePath, Template); await _context.CreateFile(filePath, Template);
var moduleImportPath = routersFolderPath var moduleImportPath = routersFolderPath
.Replace('\\', '.') .Replace('\\', '.')
.Replace('/', '.'); .Replace('/', '.');

View File

@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using System.Text.RegularExpressions;
using Humanizer; using Humanizer;
using MycroForge.CLI.CodeGen; using MycroForge.CLI.CodeGen;
using MycroForge.Core.Contract; using MycroForge.Core.Contract;
@ -14,11 +15,65 @@ public partial class MycroForge
{ {
public class Entity : Command, ISubCommandOf<Generate> 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 = private static readonly string[] Template =
[ [
"from sqlalchemy import %type_imports%", "from sqlalchemy import %sqlalchemy_imports%",
"from sqlalchemy.orm import Mapped, mapped_column", "from sqlalchemy.orm import Mapped, mapped_column",
$"from {Features.Db.FeatureName}.entities.entity_base import EntityBase", $"from {Features.Db.FeatureName}.entities.entity_base import EntityBase",
"", "",
@ -55,6 +110,10 @@ public partial class MycroForge
"\tfirst_name:str:String(255)", "\tfirst_name:str:String(255)",
])) { AllowMultipleArgumentsPerToken = true }; ])) { AllowMultipleArgumentsPerToken = true };
#endregion
private record ColumnDefinition(string Name, string NativeType, string SqlAlchemyType);
private readonly ProjectContext _context; private readonly ProjectContext _context;
public Entity(ProjectContext context) : base("entity", "Generate and database entity") public Entity(ProjectContext context) : base("entity", "Generate and database entity")
@ -69,25 +128,27 @@ public partial class MycroForge
private async Task ExecuteAsync(string name, IEnumerable<string> columns) private async Task ExecuteAsync(string name, IEnumerable<string> columns)
{ {
var fqn = new FullyQualifiedName(name); 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) if (fqn.HasNamespace)
folderPath = Path.Combine(folderPath, fqn.Namespace); folderPath = Path.Join(folderPath, fqn.Namespace);
var _columns = GetColumnDefinitions(columns.ToArray()); var sqlAlchemyColumn = GetColumnDefinitions(columns.ToArray());
var typeImports = string.Join(", ", _columns.Select(c => c.OrmType.Split('(').First()).Distinct()); var distinctSqlAlchemyColumnTypes = sqlAlchemyColumn
var columnDefinitions = string.Join("\n\t", _columns.Select(ColumnToString)); .Select(c => c.SqlAlchemyType.Split('(').First())
.Distinct();
var sqlAlchemyImport = string.Join(", ", distinctSqlAlchemyColumnTypes);
var columnDefinitions = string.Join("\n ", sqlAlchemyColumn.Select(ColumnToString));
var code = string.Join('\n', Template); 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("%class_name%", fqn.PascalizedName);
code = code.Replace("%table_name%", fqn.SnakeCasedName.Pluralize()); code = code.Replace("%table_name%", fqn.SnakeCasedName.Pluralize());
code = code.Replace("%column_definitions%", columnDefinitions); code = code.Replace("%column_definitions%", columnDefinitions);
var fileName = $"{fqn.SnakeCasedName}.py"; var fileName = $"{fqn.SnakeCasedName}.py";
var filePath = Path.Combine(folderPath, fileName); var filePath = Path.Join(folderPath, fileName);
await _context.CreateFile(filePath, code); await _context.CreateFile(filePath, code);
var importPathParts = new[] { folderPath, fileName.Replace(".py", "") } var importPathParts = new[] { folderPath, fileName.Replace(".py", "") }
@ -108,25 +169,58 @@ public partial class MycroForge
await _context.WriteFile("main.py", main); await _context.WriteFile("main.py", main);
} }
private List<ColumnDefinition> GetColumnDefinitions(string[] fields) private List<ColumnDefinition> GetColumnDefinitions(string[] columns)
{ {
var definitions = new List<ColumnDefinition>(); var definitions = new List<ColumnDefinition>();
foreach (var field in fields) foreach (var column in columns)
{ {
if (field.Split(':') is not { Length: 3 } definition) if (column.Split(':') is not { Length: 3 } definition)
throw new Exception($"Field definition {field} is invalid."); throw new Exception($"Column definition {column} is invalid.");
definitions.Add(new ColumnDefinition(definition[0], definition[1], definition[2])); definitions.Add(new ColumnDefinition(definition[0], definition[1], definition[2]));
} }
ValidateSqlAlchemyColumnTypes(definitions);
return 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

@ -59,9 +59,9 @@ public partial class MycroForge
var folderPath = string.Empty; var folderPath = string.Empty;
if (fqn.HasNamespace) if (fqn.HasNamespace)
folderPath = Path.Combine(folderPath, fqn.Namespace); 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 template = withSession ? WithSessionTemplate : DefaultTemplate;
var code = string.Join('\n', template) var code = string.Join('\n', template)
.Replace("%class_name%", fqn.PascalizedName); .Replace("%class_name%", fqn.PascalizedName);

View File

@ -81,7 +81,7 @@ public partial class MycroForge
await _context.CreateFile("main.py"); await _context.CreateFile("main.py");
// Create the venv // 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 // Pass feature arguments to the ArgsContainer
_optionsContainer.Set(options.ApiOptions); _optionsContainer.Set(options.ApiOptions);
@ -106,7 +106,7 @@ public partial class MycroForge
private async Task<string> CreateDirectory(string name) 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)) if (Directory.Exists(directory))
throw new Exception($"Directory {directory} already exists."); throw new Exception($"Directory {directory} already exists.");

View File

@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core; using MycroForge.Core;
using MycroForge.Core.Contract; using MycroForge.Core.Contract;
@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge public partial class MycroForge
{ {
[RequiresVenv]
public class Install : Command, ISubCommandOf<MycroForge> public class Install : Command, ISubCommandOf<MycroForge>
{ {
private static readonly Argument<IEnumerable<string>> PackagesArgument = private static readonly Argument<IEnumerable<string>> PackagesArgument =

View File

@ -1,4 +1,5 @@
using System.CommandLine; using System.CommandLine;
using MycroForge.CLI.Commands.Attributes;
using MycroForge.Core; using MycroForge.Core;
using MycroForge.Core.Contract; using MycroForge.Core.Contract;
@ -6,6 +7,7 @@ namespace MycroForge.CLI.Commands;
public partial class MycroForge public partial class MycroForge
{ {
[RequiresVenv]
public class Uninstall : Command, ISubCommandOf<MycroForge> public class Uninstall : Command, ISubCommandOf<MycroForge>
{ {
private static readonly Argument<IEnumerable<string>> PackagesArgument = private static readonly Argument<IEnumerable<string>> PackagesArgument =

View File

@ -48,6 +48,9 @@ public static class CommandExtensions
else if (_command.GetRequiresPluginAttribute() is RequiresPluginAttribute requiresPluginAttribute) else if (_command.GetRequiresPluginAttribute() is RequiresPluginAttribute requiresPluginAttribute)
requiresPluginAttribute.RequirePluginProject(commandText); requiresPluginAttribute.RequirePluginProject(commandText);
else if (_command.GetRequiresVenvAttribute() is RequiresVenvAttribute requiresVenvAttribute)
requiresVenvAttribute.RequireVenv(commandText);
} }
await next(context); await next(context);
@ -56,15 +59,6 @@ public static class CommandExtensions
return builder; return builder;
} }
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 List<Command> GetCommandChain(this InvocationContext context) private static List<Command> GetCommandChain(this InvocationContext context)
{ {
var chain = new List<Command>(); var chain = new List<Command>();
@ -87,4 +81,16 @@ public static class CommandExtensions
return chain; 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

@ -1,36 +0,0 @@
using System.CommandLine;
using MycroForge.Core;
using MycroForge.Core.Contract;
using RootCommand = MycroForge.Core.RootCommand;
namespace My.Plugin;
public class HelloWorldCommand : 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 HelloWorldCommand(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!"
);
}
}

View File

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

View File

@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<Content Include=".template.config\template.json" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MycroForge.Core" Version="1.0.0" />
</ItemGroup>
</Project>

View File

@ -1,6 +0,0 @@
namespace MycroForge.Core.Attributes;
public class DockerPlatformAttribute : Attribute
{
public string Platform { get; set; } = string.Empty;
}

View File

@ -9,7 +9,7 @@ public class ProjectContext
{ {
public string RootDirectory { get; private set; } = Environment.CurrentDirectory; public string RootDirectory { get; private set; } = Environment.CurrentDirectory;
public string AppName => Path.GetFileNameWithoutExtension(RootDirectory).Underscore().ToLower(); 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) public async Task<ProjectConfig> LoadConfig(bool create = false)
@ -37,21 +37,9 @@ public class ProjectContext
RootDirectory = path; 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) 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); var fileInfo = new FileInfo(fullPath);
if (fileInfo.Exists) return; if (fileInfo.Exists) return;
@ -64,7 +52,7 @@ public class ProjectContext
public async Task<string> ReadFile(string 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); var fileInfo = new FileInfo(fullPath);
if (!fileInfo.Exists) if (!fileInfo.Exists)
@ -75,7 +63,7 @@ public class ProjectContext
public async Task WriteFile(string path, params string[] content) 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); var fileInfo = new FileInfo(fullPath);
Directory.CreateDirectory(fileInfo.Directory!.FullName); Directory.CreateDirectory(fileInfo.Directory!.FullName);
await File.WriteAllTextAsync(fullPath, string.Join("\n", content)); await File.WriteAllTextAsync(fullPath, string.Join("\n", content));
@ -123,7 +111,6 @@ public class ProjectContext
input.Close(); input.Close();
await process.WaitForExitAsync(); await process.WaitForExitAsync();
Environment.ExitCode = process.ExitCode; Environment.ExitCode = process.ExitCode;
} }

View File

@ -30,8 +30,8 @@ dotnet nuget add source --name devdisciples --username username --password passw
### TODO ### TODO
- Fix `-c` option for `m4g db generate entity`
- Add a CLI UI library - Add a CLI UI library
- Clean up README files - Clean up README files
- Theme the site with a custom color scheme and icon/logos - Theme the site with a custom color scheme and icon/logos
- - Mention assumed knowledge of both FastAPI & SQLAlchemy
- Elaborate on terminology and best practices, like meaningful service names (also emphasize common sense?)