prisma client pythonが実行しているNodeはどこにあるのか

にあえん

August 15, 2023

前回、prisma-client-pythonに入門しました。

しかし、どうやってNodeが動いているのかちょっとわからなかったので調べてみました。

Nodeの実態は?

現状、グローバルにprismaが使える状態ではないため、普通にprismaコマンドを実行しても実行できません。

% prisma
zsh: command not found: prisma

しかし、前回作ったパッケージ上であればprismaコマンドは実行できます。

% poetry run prisma           
This command is only intended to be invoked internally. Please run the following instead:
prisma <command>
e.g.
prisma generate

説明を見るかぎり、ここで実行されているprismaは本家のCLIのラッパーとして実装されているようです。

Prisma Client Python exposes a CLI interface which wraps the Prisma CLI. This works by downloading a Node binary, if you don’t already have Node installed on your machine, installing the CLI with npm and running the CLI using Node.

和訳:Prisma Client Python は、Prisma CLI をラップする CLI インターフェイスを公開します。これは、マシンにまだ Node がインストールされていない場合は、Node バイナリをダウンロードし、npm を使用して CLI をインストールし、Node を使用して CLI を実行することで機能します。

Prisma Client Python

既にNodeはインストールされているため、Nodeバイナリのインストールはされていないようですが、 グローバルにprisma CLIが実行できないので、どこからこのprismaが呼び出されているのかがよく分かりません。

とりあえず実態がどこにあるのか探しましょう。

% poetry run which prisma
/home/username/Project/study/prisma-client-python-playground/.venv/bin/prisma

どうやら仮想パッケージの依存関係の中にいるようです。

このバイナリはコードのどこから呼び出されているかは、リポジトリのsetup.pyを見たら分かりました。

prisma-client-py/setup.py at 0bc9f95361d035a5e4307a7f84f8fbc3c93fb8f3 · RobertCraigie/prisma-client-py · GitHub

    #...
    entry_points={
        'console_scripts': [
            'prisma=prisma.cli:main', # Prisma CLIの呼び出しはこれ
            'prisma-client-py=prisma.cli:main',
        ],
        'prisma': [],
    },
    #...

じゃあこのprisma.cli:mainはどこか調べたら、ここにありました。

prisma-client-py/src/prisma/cli/cli.py at main · RobertCraigie/prisma-client-py · GitHub

def main(
    args: Optional[List[str]] = None,
    use_handler: bool = True,
    do_cleanup: bool = True,
) -> NoReturn:
    if args is None:
        args = sys.argv

    with setup_logging(use_handler), cleanup(do_cleanup):
        if len(args) > 1:
            if args[1] == 'py':
                cli.main(args[2:], prog_name='prisma py')
            else:
                sys.exit(prisma.run(args[1:]))
        else:
            if not os.environ.get('PRISMA_GENERATOR_INVOCATION'):
                error(
                    'This command is only intended to be invoked internally. '
                    'Please run the following instead:',
                    exit_=False,
                )
                click.echo('prisma <command>')
                click.echo('e.g.')
                click.echo('prisma generate')
                sys.exit(1)
            Generator.invoke()

    # mypy does not recognise sys.exit as a NoReturn for some reason
    raise SystemExit(0)

elseの中には、先程サブコマンドを打たずに実行した際の出力内容がそのまま書かれていますね。

% poetry run prisma           
This command is only intended to be invoked internally. Please run the following instead:
prisma <command>
e.g.
prisma generate

最初の分岐の次も分岐していて、サブコマンドでpyコマンドが使われているかどうかで分岐していますね。

pyコマンドではない場合、prismaモジュールを実行しています。

prisma-client-python特有のコマンド

ここでサラッと出てきたpyサブコマンドについても見ていきましょう。

Command Line - Prisma Client Python

2023-08-15現時点で以下の3つのコマンドがあります。

py fecthだけ分かりづらいんですが、これは依存して使用しているNodeとRustのバイナリを指定する際に利用するコマンドです。(この辺は自分もふわっと理解しているくらいです。。)

Binaries - Prisma Client Python

prismaモジュールの実態

少し話が逸れましたが、pyサブコマンド以外はprismaモジュールから実行しているというところまで確認しました。

prismaモジュールの中身を確認しましょう。

(気になる部分にコメントを入れています。)

from __future__ import annotations

import os
import sys
import json
import logging
import subprocess
from pathlib import Path
from typing import Any, List, Optional, Dict, NamedTuple

import click

from ._node import node, npm # nodeの実態?
from .. import config
from ..errors import PrismaError


log: logging.Logger = logging.getLogger(__name__)

DEFAULT_PACKAGE_JSON: dict[str, Any] = {
    'name': 'prisma-binaries',
    'version': '1.0.0',
    'private': True,
    'description': 'Cache directory created by Prisma Client Python to store Prisma Engines',
    'main': 'node_modules/prisma/build/index.js',
    'author': 'RobertCraigie',
    'license': 'Apache-2.0',
}

def run(
    args: List[str],
    check: bool = False,
    env: Optional[Dict[str, str]] = None,
) -> int:
    log.debug('Running prisma command with args: %s', args)

    # 環境変数を設定
    default_env = {
        **os.environ,
        'PRISMA_HIDE_UPDATE_MESSAGE': 'true',
        'PRISMA_CLI_QUERY_ENGINE_TYPE': 'binary',
    }
    env = {**default_env, **env} if env is not None else default_env

    # TODO: ensure graceful termination
    # nodeのエントリーポイントとして使用するjsを生成
    entrypoint = ensure_cached().entrypoint
    # nodeによるコマンドの実行
    process = node.run(
        str(entrypoint),
        *args,
        env=env,
        check=check,
        stdout=sys.stdout,
        stderr=sys.stderr,
    )

    if args and args[0] in {'--help', '-h'}:
        click.echo(click.style('Python Commands\n', bold=True))
        click.echo(
            '  '
            + 'For Prisma Client Python commands run '
            + click.style('prisma py --help', bold=True)
        )

    return process.returncode

class CLICache(NamedTuple):
    cache_dir: Path
    entrypoint: Path

# キャッシュディレクトリ内のpackage.jsonを定義
DEFAULT_PACKAGE_JSON: dict[str, Any] = {
    'name': 'prisma-binaries',
    'version': '1.0.0',
    'private': True,
    'description': 'Cache directory created by Prisma Client Python to store Prisma Engines',
    'main': 'node_modules/prisma/build/index.js',
    'author': 'RobertCraigie',
    'license': 'Apache-2.0',
}

def ensure_cached() -> CLICache:
    # 設定からキャッシュディレクトリを取得(pathlib.Path型)
    cache_dir = config.binary_cache_dir
    # キャッシュディレクトリ内のprismaからエントリーポイントとなるjsファイルを指定
    entrypoint = cache_dir / 'node_modules' / 'prisma' / 'build' / 'index.js'

    if not cache_dir.exists():
        cache_dir.mkdir(parents=True)

    # npm`が他の場所を探さないように、ダミーの `package.json` ファイルを作成する必要がある。
    #
    # もし別の `package.json` ファイルが見つかったら、キャッシュディレクトリではなく、そこに `prisma` パッケージがインストールされる。
    package = cache_dir / 'package.json'
    if not package.exists():
        package.write_text(json.dumps(DEFAULT_PACKAGE_JSON))

    # エントリーポイントが存在しない場合、Prisma CLIをbinary_cache_dirにインストールする
    if not entrypoint.exists():
        click.echo('Installing Prisma CLI')

        try:
            proc = npm.run(
                'install',
                f'prisma@{config.prisma_version}',
                cwd=config.binary_cache_dir,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
            )
            if proc.returncode != 0:
                click.echo(
                    f'An error ocurred while installing the Prisma CLI; npm install log: {proc.stdout.decode("utf-8")}'
                )
                proc.check_returncode()
        except Exception:
            # npm install` を実行すべきかどうかをチェックするために、既存のエントリーポイントを使っているので、もし `npm install` の実行が失敗したら、それが存在しないことを確認する必要がある。
            # そうしないと壊れてしまう。
            # https://github.com/RobertCraigie/prisma-client-py/issues/705
            if entrypoint.exists():
                try:
                    entrypoint.unlink()
                except Exception:
                    pass
            raise

    # 上記の処理を行った上でなおエントリーポイントが存在しない場合のエラー
    if not entrypoint.exists():
        raise PrismaError(
            f'CLI installation appeared to complete but the expected entrypoint ({entrypoint}) could not be found.'
        )

    return CLICache(cache_dir=cache_dir, entrypoint=entrypoint)

つまり、キャッシュディレクトリにprisma-client-pythonで使用する依存関係があり、そこのprismaのエントリーポイントjsを使用してnodeを実行しているということになります。

補足:pathlib.Pathを使うと/記号を特殊な使い方ができます。

import pathlib
a = pathlib.Path("a")
a / "b" / "c" # PosixPath('a/b/c')

キャッシュディレクトリは、実装を見るとデフォルトでホームディレクトリから決まった位置にあるようです。

class Config(DefaultConfig):
    binary_cache_dir: Path = Field(env='PRISMA_BINARY_CACHE_DIR')

    @classmethod
    def from_base(cls, config: DefaultConfig) -> Config:
        if config.binary_cache_dir is None:
            config.binary_cache_dir = (
                config.home_dir
                / '.cache'
                / 'prisma-python'
                / 'binaries'
                / config.prisma_version
                / config.expected_engine_version
            )

        return cls.parse_obj(config.dict())

見てみると、確かにありました。

構成は以下のようになっています。

% tree -a -L 2
.
├── node_modules
│   ├── .bin
│   ├── .package-lock.json
│   ├── @prisma
│   └── prisma
├── package-lock.json
└── package.json

prisma-client-jsとprisma-client-pythonの併用はどうする?

前回のように、prisma-dbml-generatorを使用したいケースに対応するには、キャッシュディレクトリ内にある依存関係に追加する必要があります。

ただデフォルト設定のままだと、隠しディレクトリに作成するようになっており、Dockerなどで持ち回るときに不便です。

そこで、独自のgeneratorなどを追加する際はキャッシュディレクトリ設定を変更しましょう。

前回から引き続きprisma-client-python-playgroundを使用します。

キャッシュディレクトリを指定するには、pyproject.tomlに以下の設定を追加します。

[tool.prisma]
binary_cache_dir = '.'

今回はpyproject.tomlを配置したプロジェクトのルートディレクトリを指定しています。

package.jsonはこのようになっています。

prisma-dbml-generatorの依存性は既に追加済みです。

{
  "name": "my-prisma-project",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "prisma": "^4.15.0",
    "prisma-dbml-generator": "^0.10.0"
  },
  "dependencies": {
    "@prisma/client": "^4.15.0"
  }
}

schema.prismaもprisma-dbml-generatorが走るようにしましょう。

generator dbml {
  provider = "prisma-dbml-generator"
}

この状態でprisma-client-pythonからprismaコマンドを実行します。

% poetry run prisma generate  
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Error: Generator "prisma-dbml-generator" failed:

/bin/sh: 1: prisma-dbml-ge
                / '.cache'
                / 'prisma-python'
                / 'binaries'nerator: not found

くそー!!エラーか!!!

直接環境変数を指定してもだめでした。

% PRISMA_BINARY_CACHE_DIR=$(pwd) poetry run prisma generate
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Error: Generator "prisma-dbml-generator" failed:

/bin/sh: 1: prisma-dbml-generator: not found

デバッグモードで試してみると、キャッシュディレクトリとしてはちゃんとこのプロジェクトのディレクトリが認識されているみたいです。

% PRISMA_BINARY_CACHE_DIR=$(pwd) PRISMA_PY_DEBUG=1 poetry run prisma generate 
[DEBUG  ] prisma.cli.prisma: Running prisma command with args: ['generate']
[DEBUG  ] prisma.cli._node: Checking if nodejs-bin is installed
[DEBUG  ] prisma.cli._node: Checking for global target binary: node
[DEBUG  ] prisma.cli._node: Found global binary at: /home/username/.anyenv/envs/nodenv/shims/node
[DEBUG  ] prisma.cli._node: node version check exited with code 0
[DEBUG  ] prisma.cli._node: node version check output: v17.9.0
[DEBUG  ] prisma.cli._node: node version check returning (17, 9)
[DEBUG  ] prisma.cli._node: Using global node binary at /home/username/.anyenv/envs/nodenv/shims/node
[DEBUG  ] prisma.cli._node: Attempting to preprend /home/username/.anyenv/envs/nodenv/shims to the PATH
[DEBUG  ] prisma.cli._node: Using PATH environment variable: /home/username/.anyenv/envs/nodenv/shims:/home/username/Project/study/prisma-client-python-playground/.venv/bin:/home/username/.anyenv/envs/pyenv/versions/3.8.12/bin:/home/username/.anyenv/envs/pyenv/libexec:/home/username/.anyenv/envs/pyenv/plugins/python-build/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/username/.gcloud/google-cloud-sdk/bin:/home/username/.odrive-agent/bin:/home/username/.poetry/bin:/home/username/.anyenv/envs/rbenv/shims:/home/username/.anyenv/envs/rbenv/bin:/home/username/.anyenv/envs/rbenv/bin:/home/username/.anyenv/envs/pyenv/shims:/home/username/.anyenv/envs/pyenv/bin:/home/username/.anyenv/envs/nodenv/shims:/home/username/.anyenv/envs/nodenv/bin:/home/username/.anyenv/envs/goenv/bin:/home/username/.anyenv/bin:/home/linuxbrew/.linuxbrew/bin:/home/linuxbrew/.linuxbrew/sbin:/home/username/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/snap/bin:/home/username/.anyenv/envs/goenv/shims
[DEBUG  ] prisma.cli._node: Executing binary at /home/username/.anyenv/envs/nodenv/shims/node with args: ('/home/username/Project/study/prisma-client-python-playground/node_modules/prisma/build/index.js', 'generate')
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
  prisma:GeneratorProcess  /bin/sh: 1: prisma-dbml-generator: not found +0ms
  prisma:GeneratorProcess  child exited with code 127 on signal null +2ms
Error: Generator "prisma-dbml-generator" failed:

/bin/sh: 1: prisma-dbml-generator: not found

…まぁ、あくまでprsma-dbml-generatorなんかはサードパーティのジェネレーターなわけで、公式にサポートしてるものじゃないですからね。。

ここで生成できなくても仕方ないか…

This page was helpful.

まとめ

prisma-client-pythonがだいぶ理解できました。

ただ、一部のジェネレーターが含まれているとビルドできなくなると言った点が残念でした。。

しかしまぁ一応、やり方次第で如何様にもできるのでこの辺はそう日間的にならなくてもいいかもしれませんね。