外部依存のある VS Code Extension を公開する

# 外部依存のある VS Code Extension を公開する

Qiita 初投稿です。

この記事に必要な範囲で自己紹介すると、

* ふだん VS Code を使う
* ノートは Markdown で書く

といった感じです。

RedMine を使う機会があって、Markdown で書いたテキストを Textile に変換したいな[^1]、と思い、VS Code Extension を自作してみました。Extension の名前は [Markdown2Textile](https://github.com/irisTa56/markdown2textile) です。

Extension をつくってみる、公開する、という話は [公式の Getting Started](https://code.visualstudio.com/api/get-started/your-first-extension) にとても分かりやすく書かれています。この記事では、マーケットプレイスで公開するにあたって、インストールから使用までのハードルを下げるために工夫したことを書きます。

## Markdown2Textile について

### 機能

1. Markdown ファイルでテキストを選択する。
2. コンテキストメニューで「Copy as Textile」を選択する。
3. クリップボードに Textile に変換されたテキストがコピーされている！

### メモ

* Markdown から Textile への変換は [Pandoc](https://pandoc.org) が使える。
* Pandoc まわりのライブラリは Python が充実している（主観）。
  * 例えば、変換のロジックをカスタマイズしたいなら [pandocfilters](https://github.com/jgm/pandocfilters) というパッケージがある。
* VS Code Extension (Node.js) から Python を使いたいなら [python-shell](https://www.npmjs.com/package/python-shell) というパッケージがある。

いつか上の項目に関する記事も書けたらいいなと思っています。

**ここから本題です。**

VS Code Extension, TypeScript, Python の基本知識を前提とした内容になっていると思います。また、コードは元々のソースから切り取ったものなので、単独で動作する保証が無いことをご了承ください。

[まとめへジャンプする。](#まとめ)

## 外部依存のある Extension を導入しやすくする

困ったことに Markdown2Textile は VS Code の外にある Pandoc や Python に依存しています[^2]。ドキュメントに dependencies は自分でインストールしてください、と書いておいても良いのですが、もう少し優しい方法はないかな？と思い、下記の点を調べてみることにしました。

* Pandoc をインストールする方法
* Python の Interpreter を選ぶ方法
* Python のパッケージをインストールする方法

### Pandoc をインストールする方法

公式の [Install Pandoc](https://pandoc.org/installing.html) に OS ごとのインストール方法が載っていますが、パッケージマネージャの有無とかにも注意する必要があるので、自動化スクリプトを書くのは面倒な気がします。

そこで、Python が既にインストールされていると仮定して、`pip` でインストールできないかな？と考えてみます。`pip search pandoc` で調べてみると、[py-pandoc](https://github.com/kiwi0fruit/py-pandoc) というパッケージを見つけました。

[pyenv](https://github.com/pyenv/pyenv) で新しい Python 環境をつくって、py-pandoc をインストールしてみます[^3]。

```bash
$ pyenv install 3.7.3
$ pyenv global 3.7.3
$ pip install py-pandoc
$ ls ~/.pyenv/versions/3.7.3/bin
2to3              easy_install-3.7  idle3.7           pip               pydoc             python            python3-config    python3.7-gdb.py  pyvenv
2to3-3.7          idle              pandoc            pip3              pydoc3            python-config     python3.7         python3.7m        pyvenv-3.7
easy_install      idle3             pandoc-citeproc   pip3.7            pydoc3.7          python3           python3.7-config  python3.7m-config
```

py-pandoc を使えば、Interpreter のあるディレクトリに `pandoc` がインストールされるようです。Pandoc が見つからなかった場合、Python のパッケージとしてインストール可能であることが分かりました[^4]。

### Python の Interpreter を選ぶ方法

Microsoft の [Python Extension](https://marketplace.visualstudio.com/items?itemName=ms-python.python) では、`Python: Select Interpreter` というコマンドで Python の Interpreter が選択できるようになっています。これを利用して、Python が version 2 だったときに version 3 の Interpreter を選んでもらう、というようなことを実現したいと思います[^5]。

まず、`package.json` に Python Extension に依存することを明示しておきます。

```json
  "extensionDependencies": [
    "ms-python.python"
  ],
```

Python Extension がインストールされていれば、その config にアクセスして Interpreter のパスを取得できるようになります。

```ts
function getPythonPath(): string {
  const pyConfiguration = vscode.workspace.getConfiguration("python", null);
  // fallback to System Python
  return pyConfiguration.get<string>("pythonPath", "/usr/bin/python");
}
```

この config には Python のバージョン情報が含まれていなかったので、自分で調べる必要がありました[^6]。バージョンチェックは TypeScript 側ではなく、[python-shell](https://www.npmjs.com/package/python-shell) を利用して Python にやってもらいます。

```py
# check_version.py
import sys

if sys.version_info[0] != 3:
  print("Python must be version 3")
```

```ts
type PyShellReturn = Thenable<string | Error | undefined>;

function checkPythonVersion(): PyShellReturn {
  const pyshell = new PythonShell(
    "/path/to/check_version.py", { pythonPath: getPythonPath() });
  return communicateWithPython(pyshell)
    .then(undefined, (error: string | Error) => {
      if (typeof error === "string") {
        return vscode.window.showWarningMessage(error);
      } else {
        throw error;
      }
    });
}

function communicateWithPython(pyshell: PythonShell): PyShellReturn {
  return new Promise((resolve, reject) => {
    pyshell.on("message", (message: string) => { reject(message); });
    pyshell.end((error: Error) => {
      if (error) { reject(error); }
      else { resolve(); }
    });
  });
}
```

上のコードでは、Python のバージョンチェックに引っ掛かると、`vscode.window.showWarningMessage` でメッセージが表示されます。このタイミングで Python Extension の `Python: Select Interpreter` を実行すれば、ユーザに Interpreter を選択してもらえます。

[Python Extension のソースにある `package.json`](https://github.com/microsoft/vscode-python/blob/master/package.json) の `commands` という項目を調べると、`python.setInterpreter` が `Python: Select Interpreter` コマンドの実体であることが分かります。[`vscode.commands.executeCommand`](https://code.visualstudio.com/api/extension-guides/command#programmatically-executing-a-command) の引数を `"python.setInterpreter"` にすれば、プログラム内から `Python: Select Interpreter` を実行することができます。

上のコードの `vscode.window.showWarningMessage` を下記の関数に置き換えることにより、エラーパネルの "Select Python Interpreter" ボタンを押してから Interpreter を変更できるようになりました。`vscode.window.showWarningMessage` と `vscode.commands.executeCommand` が Thenable なことがポイントだと思います。

```ts
function selectCompatiblePython(message: string): PyShellReturn {
  return vscode.window.showWarningMessage(message, "Select Python Interpreter")
    .then((item: string | undefined) => {
      if (item === "Select Python Interpreter") {
        return vscode.commands.executeCommand("python.setInterpreter");
      } else {
        throw new Error("Failed to change incompatible Python");
      }
    })
    .then(() => {
      // Python 3 を選択していれば、下記の関数は正常に resolve する。
      return checkPythonVersion();
    });
}
```

### Python のパッケージをインストールする方法

Python モジュールのチェックとインストールは以下のように実装しました。

Python 側で Pandoc の存在チェックに `pypandoc` を使用しています。そのため、`pypandoc` インストールされていなかった場合、2 回目のチェックが必要です（ダサい）。Pandoc が見つからなかった場合、`py-pandoc` をインストールします。ちなみに、[モジュールのインポート失敗時に `ModuleNotFoundError` になるのは Python 3.6 以降](https://airbrake.io/blog/python-exception-handling/importerror-and-modulenotfounderror)らしいです。

```py
# check_modules.py
missing_dependencies = []

try:
  import pandocfilters
except ModuleNotFoundError:
  missing_dependencies.append("pandocfilters")

try:
  import pypandoc
  try:
    pypandoc.get_pandoc_version()
  except OSError:
    missing_dependencies.append("py-pandoc")
except ModuleNotFoundError:
  missing_dependencies.append("pypandoc")

try:
  import pyperclip
except ModuleNotFoundError:
  missing_dependencies.append("pyperclip")

if len(missing_dependencies) > 0:
  print(", ".join(missing_dependencies))
```

今回は標準出力無しを正常終了の証としているので、`capture_output=True` とする必要があります。

```py
# install_via_pip.py
import sys
import subprocess

subprocess.run(
  [sys.executable, "-m", "pip", "install"] + sys.argv[1:],
  capture_output=True)
```

TypeScript 側では、`check_modules.py` から見つからなかったパッケージの名前を文字列で受け取り、それを分割して `install_via_pip.py` の引数として渡しています。

```ts
function checkPythonModules(): PyShellReturn {
  const pyshell = new PythonShell(
    "/path/to/check_modules.py", { pythonPath: getPythonPath() });
  return communicateWithPython(pyshell)
    .then(undefined, (error: string | Error) => {
      if (typeof error === "string") {
        return installPythonModules(error);
      } else {
        throw error;
      }
    });
}

function installPythonModules(message: string): PyShellReturn {
  return vscode.window.showWarningMessage(
    `Missing Python modules: ${message}`, "Install Missing Dependencies")
    .then((item: string | undefined) => {
      if (item === "Install Missing Dependencies") {
        const pyshell = new PythonShell(
          "/path/to/install_via_pip.py", {
            pythonPath: getPythonPath(),
            args: message.split(", "),
          });
        return communicateWithPython(pyshell);
      } else {
        throw new Error("Failed to install missing Python modules");
      }
    })
    .then(() => {
      // Pandoc のチェックに pypandoc を使っている
      // はじめに pypandoc が無かった場合を考えて再チェックする
      return checkPythonModules();
    });
}
```

## テストを書く

[公式の Getting Started](https://code.visualstudio.com/api/get-started/your-first-extension) に従って Extension をつくると、すでに Mocha というテストフレームワークが準備されています。Node.js 側では PythonShell を mock して、、、Python 側は別にテストを準備して、、、とかやりたかったのですが、今回は時間が無かったので、[vscode-arduino](https://github.com/microsoft/vscode-arduino/blob/master/test/extension.test.ts) を参考にして、Extension とそのコマンドの存在確認だけ実装してみます。

```ts
const EXTENSION_ID = "irisTa56.markdown2textile";
const EXTENSION_COMMANDS = [
  "md2tt.convertText",
  "md2tt.selectPythonInterpreter",
];

const activateExtension = (): Thenable<undefined> => {
  return new Promise((resolve, reject) => {
    const extension = vscode.extensions.getExtension(EXTENSION_ID);
    if (typeof extension === "undefined") { reject(); }
    else {
      if (extension.isActive === false) {
        extension.activate().then(() => { resolve(); });
      } else {
        resolve();
      }
    }
  });
};

suite("Markdown2Textile: Extension Tests", () => {

  test("should be present", () => {
    assert.ok(vscode.extensions.getExtension(EXTENSION_ID));
  });

  test("should be able to register md2tt commands", () => {
    activateExtension().then(() => {
      vscode.commands.getCommands(true).then((commands) => {
        const foundCommands = commands.filter((value) =>
          EXTENSION_COMMANDS.indexOf(value) >= 0 || value.startsWith("md2tt.")
        );
        assert.equal(foundCommands.length, EXTENSION_COMMANDS.length);
      });
    });
  });
});
```

ハマりポイントは、Extension を activate してから `then` で繋がないとコマンドが見つからなかったところです。このテストで activate に時間を要していることが分かり、改善点が一つ明らかになりました。また、activate の最中に外部の Python を使っているため、このテストは外部依存しています。PythonShell を mock して改善してみたいです（いつか）。

またの機会にちゃんとしたテストの記事を書けたらいいなと思います。

## マーケットプレイスに公開する

[公式なチュートリアル](https://code.visualstudio.com/api/working-with-extensions/publishing-extension#publishing-extensions)があります。

まずは [Azure DevOps](https://go.microsoft.com/fwlink/?LinkId=307137) にログインします。GitHub のアカウントが使えます。

あとはチュートリアル通りに進めていけば大丈夫！

## まとめ

* [Markdown2Textile](https://github.com/irisTa56/markdown2textile)。
* Python を使った VS Code Extension をつくれる。
* VS Code から出ずに Python のパッケージをインストールする仕組みをつくれる。
* 時間があれば [microsoft/vscode-python](https://github.com/microsoft/vscode-python) = Python Extension をしっかり読みたい。もっと良い方法があるはず。


[^1]: RedMine を Markdown 記法に切り替えることもできますが、[Textile で書かれた過去データを変換してくれる訳ではない](https://www.xenos.jp/~zen/blog2/index.php/2019/03/16/post-1827/)ようなので、RedMine 側の設定変更は非現実的だと考えています。
[^2]: Python への依存を無くし、代わりに Node.js のパッケージを使うほうが正しいかもしれません。しかし、Pandoc まわりは Python のほうが人気みたいなので、今回は Python を使いました。
[^3]: pyenv で Python をインストールするときに[この記事](https://qiita.com/zreactor/items/c3fd04417e0d61af0afe)に助けられました。
[^4]: System の Python を使った場合、権限が無いとか言われそうですが、今回は気にしないことにします。
[^5]: Markdown2Textile は version 3 の Python のみサポートしています。
[^6]: Python Extension 自体は Python のバージョン情報を持っているみたいですが、取得方法が分かりませんでした。