jediでenvironmentを指定すると、1つの実行環境(venv)で複数の環境をサポートできる話

たしか以前jediのコードを読んだ時に特に環境(venv)毎にjediを入れ直さなくても済むはずという結論になった記憶。python-language-serverのコードを読んでいたタイミングでそういえば上手く考慮できていないのでは?と思うところがあったのでPRを出したりする前の確認のためにメモしておく。

jedi?

https://jedi.readthedocs.io/en/latest/

Jedi is a static analysis tool for Python that can be used in IDEs/editors. Its historic focus is autocompletion, but does static analysis for now as well. Jedi is fast and is very well tested. It understands Python on a deeper level than all other static analysis frameworks for Python.

例えば以下の様な操作を提供している

  • コードの補完
  • 定義位置を取得

上の引用先で重要なのは静的解析(static analysis)という点。コードを静的解析して諸々を決めるということならどの環境から実行しても問題ないはず(ここでの環境は処理系のバージョン違いを含んでいない。venvのこと)。

つまり実行する環境を意識せず、同じ環境で実行したjediによって、複数の環境のコードに対するコードの補完などの機能が使えるはず。

コードの補完

その前にjediの利用方法の確認。

例えば以下の様なコードはsys.exitの補完のコード。

import jedi

source = '''\
import sys
sys.exi'''

script = jedi.Script(source, 2, len('sys.exi'), 'example.py')
script.completions() # => [<Completion: exit>]

とりあえずScriptというオブジェクトの使いかたが分かれば良い。この例ではsys.exiという部分がsys.exitと補完されている。

environmentという引数

記憶が確かならenvironmentという引数が取れるようになっていたはず。ドキュメントでは説明がTODOになっているけれど。以下の様にして、対象の環境を選択して利用できるようになっている。

import jedi

source = "<source>"

venv_path = "<ここにvenvのpathを指定>"
env = jedi.create_environment(venv_path)

script = jedi.Script(source, 2, len('sys.exi'), 'example.py', environment=env)
script.completions() # => [<Completion: exit>]

この引数が期待通りに動くか確かめるのが今回の主題。

実験

実際に実験をしてみる。

  1. 環境A,環境Bをつくる
  2. 環境Aだけにjediをインストールする
  3. 環境Aだけにpackage x、環境Bだけにpackage yをインストールする

以上のような状況で環境A中のjediを使って上手くコードの補完が動けば良い。

まっさらな環境の作り方

まっさらな環境の作り方は以下で良い。

$ python -m venv <env name>

実際の環境作成

特に理由は無いけれど、どちらも時間に関するライブラリで揃えて、arrowpendulumをx,yとして使うことにする。

setup:
  rm -rf a b
  # 環境の作成
  python -m venv a
  python -m venv b
  # パッケージのインストール
  a/bin/pip install arrow jedi
  b/bin/pip install pendulum

実行後の環境を見てみる。

環境Aの方

$ a/bin/pip freeze
arrow==0.13.0
jedi==0.13.2
parso==0.3.1
python-dateutil==2.7.5
six==1.12.0

環境B

$ b/bin/pip freeze
pendulum==2.0.4
python-dateutil==2.7.5
pytzdata==2018.7
six==1.12.0

インストールされた状況の確認

期待通りにパッケージがインストールされているか確認する。以下のようなMakefileをつくる。

PYTHON ?= python

run:
   @echo "** which python >>" ${PYTHON} "**"
   @echo "** arrow installed" "**"
   @(${PYTHON} -c 'import arrow' &> /dev/null; if [ $$? -eq 0 ]; then echo ok; else echo ng; fi)
   @echo "** pendulum installed" "**"
   @(${PYTHON} -c 'import pendulum' &> /dev/null; if [ $$? -eq 0 ]; then echo ok; else echo ng; fi)

runA:
  $(MAKE) run PYTHON=a/bin/python
runB:
  $(MAKE) run PYTHON=a/bin/python

環境Aでの状況

$ make runA |& grep -v "^make" 
** which python >> a/bin/python **
** arrow installed **
ok
** pendulum installed **
ng

環境Bでの状況

$ make runB |& grep -v "^make" 
** which python >> b/bin/python **
** arrow installed **
ng
** pendulum installed **
ok

問題なさそう。

コードの補完

ようやく本題以下の様なコードを書いて試す。どちらもnowはあるはず。

import sys
import jedi
import textwrap


def run(module, venv_path=None):
    source = textwrap.dedent(f"""
    import {module}
    {module}.no
    """).strip()

    env = None
    if venv_path is not None:
        env = jedi.create_environment(venv_path)
    script = jedi.Script(source, 2, len(module + ".no") - 1, environment=env)
    print(script.completions())


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("python program.py <module name> [<venv-path>]", file=sys.stderr)
        sys.exit(1)
    run(*sys.argv[1:])

またmakefileを書いておく(再現性というやつ)。

PYTHON ?= python
VENVPATH ?=

comp:
   @echo "** which python >>" ${PYTHON} "**"
   @echo "** venv path is >>" ${VENVPATH} "**"
   @echo "** arrow.no -> arrow.now?" "**"
  ${PYTHON} 02usejedi.py arrow ${VENVPATH}
   @echo "** pendulum.no -> pendulum.now?" "**"
  ${PYTHON} 02usejedi.py pendulum ${VENVPATH}

compAforA:
  $(MAKE) comp PYTHON=a/bin/python VENVPATH=a
compAforB:
  $(MAKE) comp PYTHON=a/bin/python VENVPATH=b

どちらも入っている環境では、どちらも補完される。

$ make comp |& grep -v "^make"
** which python >> python **
** venv path is >> **
** arrow.no -> arrow.now? **
python 02usejedi.py arrow 
[<Completion: now>]
** pendulum.no -> pendulum.now? **
python 02usejedi.py pendulum 
[<Completion: naive>, <Completion: now>]

環境Aのpythonで、A用のenvironemtを指定して実行

Aでのみインストールされたarrowに対してだけが補完が有効。

$ make compAForA |& grep -v "^make"
** which python >> a/bin/python **
** venv path is >> a **
** arrow.no -> arrow.now? **
a/bin/python 02usejedi.py arrow a
[<Completion: now>]
** pendulum.no -> pendulum.now? **
a/bin/python 02usejedi.py pendulum a
[]

環境Aのpythonで、B用のenvironemtを指定して実行

Bでのみインストールされたpendulumに対してだけが補完が有効(jediがインストールされているのも環境Aだけ)。

make compAforB |& grep -v "^make"
** which python >> a/bin/python **
** venv path is >> b **
** arrow.no -> arrow.now? **
a/bin/python 02usejedi.py arrow b
[]
** pendulum.no -> pendulum.now? **
a/bin/python 02usejedi.py pendulum b
[<Completion: naive>, <Completion: now>]

まとめ

まとめると以下が分かれば良い。

  • jediの環境は1つで良い
  • environmentを指定してScriptを作成すれば別の環境を対象にできる
  • environment作成時のpathはpython -m venvで指定したディレクト

試したgistはこちら

Makefile上の個々の操作の実行時間をファイルに出力したかったところからの諸々

Makefile上の操作に対して、実行時間を計測したい。ただしコンソール上に出力するのではなくファイルに出力したい(というところから始まった)。

ファイルを分けたいので呼び出し側でリダイレクトでは無理

例えば以下の様なタスクがある。それぞれの操作の実行時間を個別にファイルに出力したい。

default:
    # 本来sleep 1の部分は何らかのコマンドの記述になっている
    time sleep 1  # aaaに出力したい
    time sleep 1  # bbbに出力したい

タスク内の操作で出力するファイルを分けたいので以下のように呼び出す側でリダイレクトでは無理。これでは全ての出力結果が1つのファイルに出力されてしまう。

# ちなみに make > xxx 2>&1 と同じ意味
$ make &> xxx

ただmakeのタスク内にリダイレクトを書けば良いわけでもない

試しにそれっぽい感じのものを書いてみる。気持ちとしてはaaa,bbbにそれぞれの出力結果が含まれて欲しい

default: clean
  time sleep 1 &> aaa
  time sleep 1 &> bbb

clean:
  rm -f aaa bbb

実行結果

$ make
rm -f aaa bbb
time sleep 1 &> aaa

real    0m1.003s
user    0m0.003s
sys 0m0.000s
time sleep 1 &> bbb

real    0m1.002s
user    0m0.001s
sys 0m0.000s

上手く動いてくれない。これは通常の環境ではtimeはコマンドではなくshellの組み込みの操作として実装されているため。

$ which: no time in ( ....)
# /usr/bin/time にgnuのtimeコマンドが入っている環境もあるかもしれない

内部でshellが動いてくれれば良さそう

内部でshellが動いてくれれば良さそう。()で囲む。ただしこれはbashでだけの話かもしれない(他のshellでは試していない)

default: clean
  (time sleep 1) &> aaa
  (time sleep 1) &> bbb

clean:
  rm -f aaa bbb

実行結果

$ make
rm -f aaa bbb
(time sleep 1) &> aaa
(time sleep 1) &> bbb

$ cat aaa
real    0m1.003s
user    0m0.002s
sys 0m0.000s

上手くいっていそう。

本来の目的は達成されたけれど、もう少しバリエーションを試してみる。

追記したい場合

&>&>> にすれば良い。

default:
  (time sleep 1) &>> aaa
  (time sleep 1) &>> bbb

clean:
  rm -f aaa bbb

コンソールにも出力したい場合

teeを使うように書き換えれば、ファイル出力とコンソール出力を兼ねられる。ただし&>の代わりに|&を使う。具体的には以下のように。

default: clean
  (time sleep 1) |& tee aaa
  (time sleep 1) |& tee bbb

clean:
  rm -f aaa bbb

とはいえ、全部書き換えて回るのはめんどくさい。間に結合する演算子的なものを定義しておくとオプションで切り替えられるようにはなる。

TRACE ?=
ifeq ($(TRACE),)
    outOP := &>
else
    outOP := |& tee
endif

default: clean
  (time sleep 1) ${outOP} aaa
  (time sleep 1) ${outOP} bbb

clean:
  rm -f aaa bbb

ただしちょっとMakefile上でのプログラミングに足を踏み入れてしまっている感じがある。

一応期待通りに動きはする。

$ make -n
rm -f aaa bbb
(time sleep 1) &> aaa
(time sleep 1) &> bbb
$ make -n TRACE=1
rm -f aaa bbb
(time sleep 1) |& tee aaa
(time sleep 1) |& tee bbb

並行して

makeに任せればさっきの2つを並行して呼び出すことも出来はする。

TRACE ?=
ifeq ($(TRACE),)
    outOP := &>
else
    outOP := |& tee
endif

default: clean
  $(MAKE) aaa bbb

aaa:
  (time sleep 1) ${outOP} aaa
bbb:
  (time sleep 1) ${outOP} bbb

clean:
  rm -f aaa bbb
.PHONY: aaa bbb

(この例だと気にする必要がないことではあるけれど)cleanが終了しきった後にそれぞれのタスクが実行されて欲しいのでdefaultタスクの依存にaaa,bbbを書いていない。

$ time make >/dev/null

real    0m2.044s
user    0m0.033s
sys 0m0.010s
$ time make -j >/dev/null

real    0m1.031s
user    0m0.031s
sys 0m0.011s

2つのタスクが同時に動いているので時間は半分になっている。とはいえこのあたりになってくるともう色々頑張り過ぎなような気がする。