Google の Magika を Python から C#に移植するまでの過程 (Day  6 / 7)


Google のファイル判定プログラム Magika を Python から C# に移植する過程を共有する記事の第6回目です。

前回までで、.NET クラスライブラリとしての Magika がひとまず完成しました。今回はそのクラスライブラリを使って、実際にファイル判定を行うコンソールアプリを作成していきます。

## 目次
- [Day 1 : まずは Magika の中身を見てみよう](https://qiita.com/mkht/items/64b02b8648266d40380a)
- [Day 2 : C# で 概念実証コードを書いてみる](https://qiita.com/mkht/items/fa76b0238ed976d20ca9)
- [Day 3 : C# クラスライブラリとして Magika を移植していく](https://qiita.com/mkht/items/a7fa36c5446b5fad5092)
- [Day 4 : GitHub Copilot を使って作業効率アップ](https://qiita.com/mkht/items/2c2b23ceb0bc1df66599)
- [Day 5 : クラスライブラリとしての Magika を完成させる](https://qiita.com/mkht/items/9d7a8a16ed1d60e353fa)
- [Day 6 : コンソールアプリを作成する](https://qiita.com/mkht/items/0b2d1546cf8a25ab4cce)
- [Day 7 : 移植したMagikaをビルドし、動作確認する](https://qiita.com/mkht/items/f36d87afe50eae2be5f1)

## コンソールアプリを作成する

クラスライブラリとしての Magika を呼び出すコンソールアプリ版 Magika を作成したいわけですが、こういう場合にどういうフォルダ構造であったりプロジェクトの構造を組むのが良いのか、定石のようなものがあるのかもしれませんが、よくわかりません。

とりあえず、オリジナルの Python版 Magika に倣い、クラスライブラリのプロジェクトフォルダのなかに`cli`フォルダを作成し、その中でコンソールアプリのプロジェクトを作成することにしました。

プロジェクト名は`magika`だとクラスライブラリのほうのプロジェクト名と同じだと怒られてしまったので`magika-cli`としました。

```cmd
mkdir .\cli
cd .\cli
dotnet new console -o magika-cli
```

### シングルバイナリアプリケーションとする

コンソールアプリを配布する際に、関連するライブラリファイルを一緒に配布しなければならないのは面倒なので、単一の`.exe`ファイルになっているとうれしいです。そこでコンソールアプリをシングルバイナリアプリケーションとしてビルドする設定を行います。

また、クラスライブラリ版 Magika を参照する設定なども行います。

`magika-cli.csproj`ファイルを開いて、以下のように修正します。

```xml
<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 では以下のようなオプションが指定できますので、なるべく同じオプションが同じ使い勝手で使えるようにしたいです。

```help
 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 の公式ライブラリなのでなんとなく安心感があります。

https://learn.microsoft.com/ja-jp/dotnet/standard/commandline/

ただ、このライブラリはまだプレビュー版なので、Microsoft の公式ドキュメント以外には情報があまりないようです。使い方も少し複雑そうに見えます。

`Option<T>`オブジェクトでオプションを定義して`RootCommand`に追加し、`InvokeAsync`で実行する流れでしょうか。オブジェクト指向の C＃ らしい書き方だとは思いますが、あまり直感的ではなく、やりたいことがシンプルに書けない感じがしました。

```csharp
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`です。

https://github.com/dotnet/command-line-api/blob/main/docs/DragonFruit-overview.md

なんとコンソールアプリのエントリポイントである`Main()`関数を拡張し、`Main()`関数の引数をそのままコマンドラインオプションとして使えるようになるというものです。

```csharp
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"}");
}
```

```cmd
> ./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`です。属性ベースでコマンドラインオプションを定義できるというのが特徴的です。

https://github.com/Cysharp/ConsoleAppFramework

```csharp
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);
    }
}
```

```cmd
> 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`というライブラリを見つけました。

https://github.com/commandlineparser/commandline

結構有名なライブラリのようで、使用例を説明した Qiita 記事もいくつか見つかります。

https://qiita.com/kawaidainf/items/0aeace9a49b9770e2545

https://qiita.com/skitoy4321/items/742d143b069569014769

オプション定義をまとめた`Options`クラスを定義して`Parser.Default.ParseArguments<Options>(args)`でパースするという流れです。`ConsoleAppFramework`と同様の属性ベースのオプション定義も使えるようです。

```csharp
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`メソッドを使うとワンラインナーでかけて便利なのですが...

```csharp
List<string> actualFilePaths = filePaths.Where(Directory.Exists).SelectMany(e => Directory.EnumerateFiles(e, "*")).Where(e => !Directory.Exists(e)).ToList();
```

Magika では`--no-dereference`オプションを指定することでシンボリックリンクをたどらないようにするオプションがあり、`Directory.EnumerateFiles`メソッドではこの動作を再現できなかったため、今回はこのメソッドをそのまま使うのではなく、独自に再帰的に検索するメソッドを作成しました。

```csharp
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 のコマンドプロンプトでは文字色を変更する部分がうまく動作していないことに気がつきます。

+ Windows 10 のコマンドプロンプトでの表示  
![day7-img-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177762/4cbe2323-3892-1af5-9e42-221c19b84566.png)

+ Windows 11 のコマンドプロンプトでの表示   
![day7-img-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/177762/46aabd47-a676-b33f-bc7c-2570a6f7ee10.png)

Python 版 Magika では VT エスケープシーケンスを使ってコンソールに表示される文字の色を変更しています。この方法は OS に依存せずに動作する利点がある一方で、Windows 10 以前のコマンドプロンプトではデフォルトで有効になっていないため機能しないようです。

なお、Windows 11 ではデフォルトだと Windows ターミナルでコマンドプロンプトが開くのでこの問題は発生しません。ただし従来のコマンドプロンプト（コンソールホスト）を使うよう設定変更されている場合は同じ問題が発生します。

さいわい、デフォルトで無効になっているだけで、Win32 API の`SetConsoleMode`関数を使って有効にすることができました。GitHub Copilot にコードを提案させたうえで少し調整して使用しています。

```csharp
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 の両方がついに完成しました。[次回](https://qiita.com/mkht/items/f36d87afe50eae2be5f1)は完成したコードをビルドして実際に動作を確認していきます。