diff --git a/examples/Cli/Logging/Commands/HelloCommand.cs b/examples/Cli/Logging/Commands/HelloCommand.cs new file mode 100644 index 0000000..710f136 --- /dev/null +++ b/examples/Cli/Logging/Commands/HelloCommand.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.Logging; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Logging.Commands +{ + public class HelloCommand : Command + { + private ILogger _logger; + private IAnsiConsole _console; + + public HelloCommand(IAnsiConsole console, ILogger logger) + { + _console = console; + _logger = logger; + _logger.LogDebug("{0} initialized", nameof(HelloCommand)); + } + + public class Settings : LogCommandSettings + { + [CommandArgument(0, "[Name]")] + public string Name { get; set; } + } + + + public override int Execute(CommandContext context, Settings settings) + { + _logger.LogInformation("Starting my command"); + AnsiConsole.MarkupLine($"Hello, [blue]{settings.Name}[/]"); + _logger.LogInformation("Completed my command"); + + return 0; + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Commands/LogCommandSettings.cs b/examples/Cli/Logging/Commands/LogCommandSettings.cs new file mode 100644 index 0000000..4160e9d --- /dev/null +++ b/examples/Cli/Logging/Commands/LogCommandSettings.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Globalization; +using Serilog.Events; +using Spectre.Console.Cli; + +namespace Logging.Commands +{ + public class LogCommandSettings : CommandSettings + { + [CommandOption("--logFile")] + [Description("Path and file name for logging")] + public string LogFile { get; set; } + + [CommandOption("--logLevel")] + [Description("Minimum level for logging")] + [TypeConverter(typeof(VerbosityConverter))] + [DefaultValue(LogEventLevel.Information)] + public LogEventLevel LogLevel { get; set; } + } + + public sealed class VerbosityConverter : TypeConverter + { + private readonly Dictionary _lookup; + + public VerbosityConverter() + { + _lookup = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + {"d", LogEventLevel.Debug}, + {"v", LogEventLevel.Verbose}, + {"i", LogEventLevel.Information}, + {"w", LogEventLevel.Warning}, + {"e", LogEventLevel.Error}, + {"f", LogEventLevel.Fatal} + }; + } + + public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value) + { + if (value is string stringValue) + { + var result = _lookup.TryGetValue(stringValue, out var verbosity); + if (!result) + { + const string format = "The value '{0}' is not a valid verbosity."; + var message = string.Format(CultureInfo.InvariantCulture, format, value); + throw new InvalidOperationException(message); + } + return verbosity; + } + throw new NotSupportedException("Can't convert value to verbosity."); + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Infrastructure/LogInterceptor.cs b/examples/Cli/Logging/Infrastructure/LogInterceptor.cs new file mode 100644 index 0000000..423a640 --- /dev/null +++ b/examples/Cli/Logging/Infrastructure/LogInterceptor.cs @@ -0,0 +1,20 @@ +using Logging.Commands; +using Serilog.Core; +using Spectre.Console.Cli; + +namespace Logging +{ + public class LogInterceptor : ICommandInterceptor + { + public static readonly LoggingLevelSwitch LogLevel = new(); + + public void Intercept(CommandContext context, CommandSettings settings) + { + if (settings is LogCommandSettings logSettings) + { + LoggingEnricher.Path = logSettings.LogFile ?? "application.log"; + LogLevel.MinimumLevel = logSettings.LogLevel; + } + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Infrastructure/LoggingEnricher.cs b/examples/Cli/Logging/Infrastructure/LoggingEnricher.cs new file mode 100644 index 0000000..86007a7 --- /dev/null +++ b/examples/Cli/Logging/Infrastructure/LoggingEnricher.cs @@ -0,0 +1,38 @@ +using Serilog.Core; +using Serilog.Events; + +namespace Logging +{ + internal class LoggingEnricher : ILogEventEnricher + { + private string _cachedLogFilePath; + private LogEventProperty _cachedLogFilePathProperty; + + // this path and level will be set by the LogInterceptor.cs after parsing the settings + public static string Path = string.Empty; + + public const string LogFilePathPropertyName = "LogFilePath"; + + public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) + { + // the settings might not have a path or we might not be within a command in which case + // we won't have the setting so a default value for the log file will be required + LogEventProperty logFilePathProperty; + + if (_cachedLogFilePathProperty != null && Path.Equals(_cachedLogFilePath)) + { + // Path hasn't changed, so let's use the cached property + logFilePathProperty = _cachedLogFilePathProperty; + } + else + { + // We've got a new path for the log. Let's create a new property + // and cache it for future log events to use + _cachedLogFilePath = Path; + _cachedLogFilePathProperty = logFilePathProperty = propertyFactory.CreateProperty(LogFilePathPropertyName, Path); + } + + logEvent.AddPropertyIfAbsent(logFilePathProperty); + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Infrastructure/TypeRegistrar.cs b/examples/Cli/Logging/Infrastructure/TypeRegistrar.cs new file mode 100644 index 0000000..7975df4 --- /dev/null +++ b/examples/Cli/Logging/Infrastructure/TypeRegistrar.cs @@ -0,0 +1,41 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Logging +{ + public sealed class TypeRegistrar : ITypeRegistrar + { + private readonly IServiceCollection _builder; + + public TypeRegistrar(IServiceCollection builder) + { + _builder = builder; + } + + public ITypeResolver Build() + { + return new TypeResolver(_builder.BuildServiceProvider()); + } + + public void Register(Type service, Type implementation) + { + _builder.AddSingleton(service, implementation); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.AddSingleton(service, implementation); + } + + public void RegisterLazy(Type service, Func func) + { + if (func is null) + { + throw new ArgumentNullException(nameof(func)); + } + + _builder.AddSingleton(service, _ => func()); + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Infrastructure/TypeResolver.cs b/examples/Cli/Logging/Infrastructure/TypeResolver.cs new file mode 100644 index 0000000..6e95cab --- /dev/null +++ b/examples/Cli/Logging/Infrastructure/TypeResolver.cs @@ -0,0 +1,21 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using Spectre.Console.Cli; + +namespace Logging +{ + public sealed class TypeResolver : ITypeResolver + { + private readonly IServiceProvider _provider; + + public TypeResolver(IServiceProvider provider) + { + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + public object Resolve(Type type) + { + return _provider.GetRequiredService(type); + } + } +} \ No newline at end of file diff --git a/examples/Cli/Logging/Logging.csproj b/examples/Cli/Logging/Logging.csproj new file mode 100644 index 0000000..6cc4960 --- /dev/null +++ b/examples/Cli/Logging/Logging.csproj @@ -0,0 +1,26 @@ + + + + Exe + net5.0 + false + Logging + Demonstrates how to dynamically configure Serilog for logging using parameters from a command. + Cli + false + disable + + + + + + + + + + + + + + + diff --git a/examples/Cli/Logging/Program.cs b/examples/Cli/Logging/Program.cs new file mode 100644 index 0000000..5ed226a --- /dev/null +++ b/examples/Cli/Logging/Program.cs @@ -0,0 +1,54 @@ +using Logging.Commands; +using Microsoft.Extensions.DependencyInjection; +using Serilog; +using Spectre.Console.Cli; + +/* + * Dynamically control serilog configuration via command line parameters + * + * This works around the chicken and egg situation with configuring serilog via the command line. + * The logger needs to be configured prior to executing the parser, but the logger needs the parsed values + * to be configured. By using serilog.sinks.map we can defer configuration. We use a LogLevelSwitch to control the + * logging levels dynamically, and then we use a serilog enricher that has it's state populated via a + * Spectre.Console CommandInterceptor + */ + +namespace Logging +{ + public class Program + { + static int Main(string[] args) + { + // to retrieve the log file name, we must first parse the command settings + // this will require us to delay setting the file path for the file writer. + // With serilog we can use an enricher and Serilog.Sinks.Map to dynamically + // pull this setting. + var serviceCollection = new ServiceCollection() + .AddLogging(configure => + configure.AddSerilog(new LoggerConfiguration() + // log level will be dynamically be controlled by our log interceptor upon running + .MinimumLevel.ControlledBy(LogInterceptor.LogLevel) + // the log enricher will add a new property with the log file path from the settings + // that we can use to set the path dynamically + .Enrich.With() + // serilog.sinks.map will defer the configuration of the sink to be ondemand + // allowing us to look at the properties set by the enricher to set the path appropriately + .WriteTo.Map(LoggingEnricher.LogFilePathPropertyName, + (logFilePath, wt) => wt.File($"{logFilePath}"), 1) + .CreateLogger() + ) + ); + + var registrar = new TypeRegistrar(serviceCollection); + var app = new CommandApp(registrar); + + app.Configure(config => + { + config.SetInterceptor(new LogInterceptor()); // add the interceptor + config.AddCommand("hello"); + }); + + return app.Run(args); + } + } +} \ No newline at end of file diff --git a/src/Spectre.Console.sln b/src/Spectre.Console.sln index 4dac018..a38cbf9 100644 --- a/src/Spectre.Console.sln +++ b/src/Spectre.Console.sln @@ -80,6 +80,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", "{E0E4 EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Trees", "..\examples\Console\Trees\Trees.csproj", "{CA7AF967-3FA5-4CB1-9564-740CF4527895}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Logging", "..\examples\Cli\Logging\Logging.csproj", "{33C7075A-DF97-44FC-8AB3-0CCFBA03341A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -426,6 +428,18 @@ Global {CA7AF967-3FA5-4CB1-9564-740CF4527895}.Release|x64.Build.0 = Release|Any CPU {CA7AF967-3FA5-4CB1-9564-740CF4527895}.Release|x86.ActiveCfg = Release|Any CPU {CA7AF967-3FA5-4CB1-9564-740CF4527895}.Release|x86.Build.0 = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|x64.ActiveCfg = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|x64.Build.0 = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|x86.ActiveCfg = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Debug|x86.Build.0 = Debug|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|Any CPU.Build.0 = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|x64.ActiveCfg = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|x64.Build.0 = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|x86.ActiveCfg = Release|Any CPU + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -458,6 +472,7 @@ Global {E9C02C5A-710C-4A57-A008-E3EAC89305CC} = {42792D7F-0BB6-4EE1-A314-8889305A4C48} {F83CB4F1-95B8-45A4-A415-6DB5F8CA1E12} = {42792D7F-0BB6-4EE1-A314-8889305A4C48} {CA7AF967-3FA5-4CB1-9564-740CF4527895} = {F0575243-121F-4DEE-9F6B-246E26DC0844} + {33C7075A-DF97-44FC-8AB3-0CCFBA03341A} = {42792D7F-0BB6-4EE1-A314-8889305A4C48} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5729B071-67A0-48FB-8B1B-275E6822086C}