Google のファイル判定プログラム Magika を Python から C# に移植する過程を共有する記事の第6回目です。
前回までで、.NET クラスライブラリとしての Magika がひとまず完成しました。今回はそのクラスライブラリを使って、実際にファイル判定を行うコンソールアプリを作成していきます。
目次
- Day 1 : まずは Magika の中身を見てみよう
- Day 2 : C# で 概念実証コードを書いてみる
- Day 3 : C# クラスライブラリとして Magika を移植していく
- Day 4 : GitHub Copilot を使って作業効率アップ
- Day 5 : クラスライブラリとしての Magika を完成させる
- Day 6 : コンソールアプリを作成する
- Day 7 : 移植したMagikaをビルドし、動作確認する
コンソールアプリを作成する
クラスライブラリとしての Magika を呼び出すコンソールアプリ版 Magika を作成したいわけですが、こういう場合にどういうフォルダ構造であったりプロジェクトの構造を組むのが良いのか、定石のようなものがあるのかもしれませんが、よくわかりません。
とりあえず、オリジナルの Python版 Magika に倣い、クラスライブラリのプロジェクトフォルダのなかにcli
フォルダを作成し、その中でコンソールアプリのプロジェクトを作成することにしました。
プロジェクト名はmagika
だとクラスライブラリのほうのプロジェクト名と同じだと怒られてしまったのでmagika-cli
としました。
mkdir .\cli
cd .\cli
dotnet new console -o magika-cli
シングルバイナリアプリケーションとする
コンソールアプリを配布する際に、関連するライブラリファイルを一緒に配布しなければならないのは面倒なので、単一の.exe
ファイルになっているとうれしいです。そこでコンソールアプリをシングルバイナリアプリケーションとしてビルドする設定を行います。
また、クラスライブラリ版 Magika を参照する設定なども行います。
magika-cli.csproj
ファイルを開いて、以下のように修正します。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>magika</AssemblyName>
<!-- 単一バイナリとしてビルドする -->
<PublishSingleFile>true</PublishSingleFile>
<!-- .NET Runtime もバイナリに含める -->
<SelfContained>true</SelfContained>
<!-- ネイティブライブラリ(ONNX Runtime)もバイナリに含める -->
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<!-- バイナリサイズが大きくなってしまうので圧縮する(ただし動作は少し遅くなる) -->
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
</PropertyGroup>
<ItemGroup>
<!-- クラスライブラリのmagikaを参照に追加 -->
<ProjectReference Include="../magika.csproj" />
</ItemGroup>
</Project>
コマンドラインオプションのパーサを選択する
コマンドラインから呼び出すアプリケーションなので、コマンドの引数で様々なオプションを設定できるようにします。オリジナルの Python 版 Magika では以下のようなオプションが指定できますので、なるべく同じオプションが同じ使い勝手で使えるようにしたいです。
magika --help
Usage: magika [OPTIONS] [FILE]...
Magika - Determine type of FILEs with deep-learning.
Options:
-r, --recursive When passing this option, magika scans every
file within directories, instead of
outputting "directory"
--json Output in JSON format.
--jsonl Output in JSONL format.
-i, --mime-type Output the MIME type instead of a verbose
content type description.
-l, --label Output a simple label instead of a verbose
content type description. Use --list-output-
content-types for the list of supported
output.
-c, --compatibility-mode Compatibility mode: output is as close as
possible to `file` and colors are disabled.
-s, --output-score Output the prediction's score in addition to
the content type.
-m, --prediction-mode [best-guess|medium-confidence|high-confidence]
--batch-size INTEGER How many files to process in one batch.
--no-dereference This option causes symlinks not to be
followed. By default, symlinks are
dereferenced.
--colors / --no-colors Enable/disable use of colors.
-v, --verbose Enable more verbose output.
-vv, --debug Enable debug logging.
--generate-report Generate report useful when reporting
feedback.
--version Print the version and exit.
--list-output-content-types Show a list of supported content types.
--model-dir DIRECTORY Use a custom model.
-h, --help Show this message and exit.
Send any feedback to magika-dev@google.com or via GitHub issues.
コマンドラインで与えられた引数を適切にパースして、それぞれのオプションに応じた処理を書く必要があるのですが、すべてを自前で書くのはかなり骨ですので、外部のコマンドラインパーサライブラリを使うことにしました。
C# で定番のコマンドラインパーサライブラリはどんなものがあるのか、調べていきます。
1. System.CommandLine
まず調べてすぐに出てきたのはSystem.CommandLine
です。Microsoft の公式ライブラリなのでなんとなく安心感があります。
ただ、このライブラリはまだプレビュー版なので、Microsoft の公式ドキュメント以外には情報があまりないようです。使い方も少し複雑そうに見えます。
Option<T>
オブジェクトでオプションを定義してRootCommand
に追加し、InvokeAsync
で実行する流れでしょうか。オブジェクト指向の C# らしい書き方だとは思いますが、あまり直感的ではなく、やりたいことがシンプルに書けない感じがしました。
static async Task<int> Main(string[] args)
{
var fileOption = new Option<FileInfo?>(
name: "--file",
description: "The file to read and display on the console.");
var rootCommand = new RootCommand("Sample app for System.CommandLine");
rootCommand.AddOption(fileOption);
rootCommand.SetHandler((file) =>
{
ReadFile(file!);
},
fileOption);
return await rootCommand.InvokeAsync(args);
}
2. System.CommandLine.DragonFruit
上記のSystem.CommandLine
の派生版?で、機能を絞って大幅に書きやすくしたものがSystem.CommandLine.DragonFruit
です。
なんとコンソールアプリのエントリポイントであるMain()
関数を拡張し、Main()
関数の引数をそのままコマンドラインオプションとして使えるようになるというものです。
static void Main(int intOption = 42, bool boolOption = false, FileInfo fileOption = null)
{
Console.WriteLine($"The value of intOption is: {intOption}");
Console.WriteLine($"The value of boolOption is: {boolOption}");
Console.WriteLine($"The value of fileOption is: {fileOption?.FullName ?? "null"}");
}
> ./myapp -h # or: dotnet run -- -h
Usage:
myapp [options]
Options:
--int-option intOption
--bool-option boolOption
--file-option fileOption
Main()
関数の引数にint intOption
と書くだけでコマンドラインオプションに--int-option
が増えています。これはかなり直感的で使いやすそうです。
ただ、機能面については大幅に絞られているので、オプションにエイリアスを設定できないなどの制約があります。今回はオリジナルの Magika のコマンドラインオプションをなるべくそのまま再現したかったので、このライブラリでは要求を満たせないと判断しました。
完全に独自のコマンドラインアプリケーションを作成したい場合などはSystem.CommandLine.DragonFruit
をパーサの第一候補にしてもいいのではないかと感じます。
3. ConsoleAppFramework
次に見つけたのがConsoleAppFramework
です。属性ベースでコマンドラインオプションを定義できるというのが特徴的です。
public class Program : ConsoleAppBase
{
static async Task Main(string[] args)
{
await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
}
public void Hello(
[Option("m", "Message to display.")]
string message
)
{
Console.WriteLine("Hello " + message);
}
}
> SampleApp.exe help
Usage: SampleApp [options...]
Options:
-m, -message <String> Message to display. (Required)
Commands:
help Display help.
version Display version.
属性ベースでパラメータオプションを定義するというのは私が慣れている PowerShell と同様の仕組みなので、使いやすそうです。
ただ、ConsoleAppFramework
は単純なコマンドラインパーサというよりはもっと包括的なフレームワークのようで、シンプルなパーサとして使うだけにしてはいささか過剰、学習コストが重いかなと感じました。
4. CommandLineParser
なかなかよさげなパーサが見つからず、もう自前で書くしかないかもと思っていたところCommandLineParser
というライブラリを見つけました。
結構有名なライブラリのようで、使用例を説明した Qiita 記事もいくつか見つかります。
オプション定義をまとめたOptions
クラスを定義してParser.Default.ParseArguments<Options>(args)
でパースするという流れです。ConsoleAppFramework
と同様の属性ベースのオプション定義も使えるようです。
namespace QuickStart
{
class Program
{
public class Options
{
[Option('v', "verbose", Required = false, HelpText = "Set output to verbose messages.")]
public bool Verbose { get; set; }
}
static void Main(string[] args)
{
Parser.Default.ParseArguments<Options>(args)
.WithParsed<Options>(o =>
{
if (o.Verbose)
{
Console.WriteLine($"Verbose output enabled. Current Arguments: -v {o.Verbose}");
Console.WriteLine("Quick Start Example! App is in Verbose mode!");
}
else
{
Console.WriteLine($"Current Arguments: -v {o.Verbose}");
Console.WriteLine("Quick Start Example!");
}
});
}
}
}
コードはシンプルでありながら、今回やりたいことはほとんどカバーできそうです。CommandLineParser
を使うことにしました。
オリジナル版 Magika のコマンドラインオプションのうち
-vv
オプション(--debug
のエイリアス)だけはCommandLineParser
ではそのまま再現できません。CommandLineParser
では-vv
は-v -v
と同等になってしまい、同じオプションを二回指定しているとしてパースエラーになってしまいます。今回はいったんこのオプションの再現は見送りました。
ディレクトリ内のファイルを再帰的に検索する
Magika コマンドライン版では-r
オプションを指定すると、ディレクトリ内のファイルを再帰的に検索する動作になります。C# でこの処理はDirectory.EnumerateFiles
メソッドを使うとワンラインナーでかけて便利なのですが...
List<string> actualFilePaths = filePaths.Where(Directory.Exists).SelectMany(e => Directory.EnumerateFiles(e, "*")).Where(e => !Directory.Exists(e)).ToList();
Magika では--no-dereference
オプションを指定することでシンボリックリンクをたどらないようにするオプションがあり、Directory.EnumerateFiles
メソッドではこの動作を再現できなかったため、今回はこのメソッドをそのまま使うのではなく、独自に再帰的に検索するメソッドを作成しました。
static readonly EnumerationOptions enumerationOptions = new()
{
RecurseSubdirectories = false,
IgnoreInaccessible = true,
ReturnSpecialDirectories = false,
AttributesToSkip = FileAttributes.Offline | FileAttributes.Device
};
static List<string> GetFilesFromDirectory(string directory, bool noDereference)
{
if (!Directory.Exists(directory))
{
return [];
}
var output = new List<string>();
if (noDereference && File.GetAttributes(directory).HasFlag(FileAttributes.ReparsePoint))
{
output.Add(directory);
}
else
{
var files = Directory.EnumerateFileSystemEntries(directory, "*", enumerationOptions);
foreach (var file in files)
{
if (File.Exists(file))
{
output.Add(file);
continue;
}
if (Directory.Exists(file))
{
if (noDereference && File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint))
{
output.Add(file);
continue;
}
else
{
output.AddRange(GetFilesFromDirectory(file, noDereference));
}
}
}
}
return output;
}
Windowsコマンドプロンプトでの文字色変更
一通り Python 版 Magika の CLI 版を C# に移植が完了し、動作を確認してみたところ、Windows 10 のコマンドプロンプトでは文字色を変更する部分がうまく動作していないことに気がつきます。
Python 版 Magika では VT エスケープシーケンスを使ってコンソールに表示される文字の色を変更しています。この方法は OS に依存せずに動作する利点がある一方で、Windows 10 以前のコマンドプロンプトではデフォルトで有効になっていないため機能しないようです。
なお、Windows 11 ではデフォルトだと Windows ターミナルでコマンドプロンプトが開くのでこの問題は発生しません。ただし従来のコマンドプロンプト(コンソールホスト)を使うよう設定変更されている場合は同じ問題が発生します。
さいわい、デフォルトで無効になっているだけで、Win32 API のSetConsoleMode
関数を使って有効にすることができました。GitHub Copilot にコードを提案させたうえで少し調整して使用しています。
const int STD_OUTPUT_HANDLE = -11;
const uint ENABLE_VIRTUAL_TERMINAL_PROCESSING = 4;
[DllImport("kernel32.dll", SetLastError = true)]
static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll")]
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
[DllImport("kernel32.dll")]
static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
static void EnableVirtualTerminalProcessing()
{
if (!OperatingSystem.IsWindows())
{
return;
}
IntPtr handle = GetStdHandle(STD_OUTPUT_HANDLE);
GetConsoleMode(handle, out uint mode);
if ((mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0)
{
mode |= ENABLE_VIRTUAL_TERMINAL_PROCESSING;
SetConsoleMode(handle, mode);
}
}
続く
クラスライブラリ版の Magika とコマンドライン版 Magika の両方がついに完成しました。次回は完成したコードをビルドして実際に動作を確認していきます。