見出し画像

自動化とショートカットでsmart_import.pyを効率よく使う


はじめに

こちらこちらで紹介した smart_import.py は TradingView の関数などを再利用するツールで、コマンド1つで複数のファイルから必要な定義を集めることができます。ただ、TradingView の統合環境の外なので、Pine のコードを書いた後、smart_import.py で変換し、TradingView に持っていって実行することを繰り返すことになる点で面倒です。この繰り返し作業を効率よく行うことが今回のテーマです。概略としては、次の Step0 の準備を行ったあと、Step1〜5 を自動化とショートカットを使って進めていきます。マウスを使ったりウィンドウを探したりは不要です。

Step0. TradingViewのエディタ画面を新規ウィンドウか新規タブで開いておく
Step1. 開発用エディタで保存後、自動的にsmart_import.pyを走らせる
Step2. TradingViewのエディタに飛ぶ
Step3. TradingViewのエディタにソースを貼ってコンパイルする
Step4. TradingViewのチャートに飛んで動作確認する
Step5. 開発用エディタに飛んで編集してStep1に戻る

実は去年はじめて Mac を購入して使っています。環境の違いにより載せたソースが直接使えないことがあると思いますが、同様なことをやりたいとAIに聞いてみてください。 

Step0 TradingViewのエディタ画面を準備する

TradingView でエディタを出すと次のような画面になります。

エディタが右側にあるこの状態や、エディタが下側にある状態では、Step3で行うすべてのソースコードの選択がうまくできません。(いい方法がありましたら教えてください。)そこでエディタの右上から次のメニューを出して、「新規ウィンドウ」か「新規タブ」を選んでください。どちらを選んでも大きく変わりませんが、新規ウィンドウのほうがソースを表示する領域が少し広く、新規タブならウィンドウの数が増えません。

出てきたウィンドウの右下に次のように「リンクされています」とあれば準備完了です。このエディタ画面で保存・コンパイルした結果が元のウィンドウのチャートに表示されます。

参考までに、新規ウィンドウと新規タブの公式解説はこちらです。

Step1 はスクリプトを呼ぶことで実現

ここでは Step1 の開発用エディタで保存後、自動的に smart_import.py を走らせる方法について説明します。

適切な対象ファイルを処理するスクリプトを作る

Pine で書いたファイルの保存をトリガーに smart_import.py を走らせたいです。ただ、定義ファイルに対して smart_import.py を走らせても意味がないです。定義ファイルを保存したときには何もしないという選択もありますが、最近保存した定義ファイルでない Pine のファイルを処理させることにしました。そのファイルで必要になった変更を、定義ファイルに加えていた可能性が高いからです。

Pine のファイルの拡張子は、先人にあわせ .pine にしました。定義ファイルは、ファイル名かディレクトリ名が def ではじまるファイルとしました。(def ではじまる名前には default も含まれるので、def でなく defs からはじまるとして運用するのもありだと思います。)

この仕様を zsh で作ったものが次のスクリプトです。他の言語で使いたい場合には、〜で書き直してとAIに言って対応してもらってください。AIの得意分野のはずです。

#!/usr/bin/env zsh

# エラー発生時(-e)や未定義変数参照時(-u)に即時終了する
set -eu

# smart_import.py のパスとオプションの設定
SMART_IMPORT="$HOME/bin/smart_import.py"
OPTIONS=(--output-script='' --save-dep-graph=on-error)

# 実行中のスクリプト情報と引数の取得
script="$0"
argument="${1:-}" # 第1引数(保存されたpineファイル)。未指定時は空文字列

# 処理対象(ターゲット)のパスを保存するステートファイル名を作成
# ~/bin/conv_latest_pine.zsh なら ~/bin/conv_latest_pine.target
state_file="${script:r}.target"

# 引数チェック:ファイル名が渡されていない場合は使い方を表示して終了
if [[ -z "$argument" ]]; then
    echo "usage: $0 filename.pine" >&2
    exit 1
fi

# ファイル名と親ディレクトリ名を取得
file="${argument:t}"
dir="${argument:h:t}"

# 自動生成ファイル(sout.pine や slib*.pine)を無視する処理(必要に応じ有効化)
#if [[ "$file" == slib* || "$file" == "sout.pine" ]]; then
#    echo -n "${argument:t}: skipped" >&2
#    exit 0
#fi

# ファイル名やフォルダ名が "def" で始まらない場合、
# 今回保存されたファイルをターゲットとしてステートファイルに登録
if [[ "$file" != def* && "$dir" != def* ]]; then
    echo "${argument:a}" > "$state_file"
fi

# ステートファイルがない場合は処理を終了
if [[ ! -f "$state_file" ]]; then
    echo -n "no target" >&2
    exit 0
fi

# ステートファイルからターゲットのパスを読み込み
target=$(<"$state_file")

# ターゲットが存在するか確認(削除されている場合などの対策)
if [[ ! -f "$target" ]]; then
    echo -n "$target: not found" >&2
    exit 1
fi

# ターゲットがあるディレクトリに移動して smart_import.py を実行
cd "${target:h}"
"$SMART_IMPORT" "${OPTIONS[@]}" "${target:t}"

スクリプトのコメントアウトした部分には、sout.pine と slib*.pine に対しては何もしないという記述があります。これらのファイルは smart_import.py が生成したもので、編集・保存することはあまりないし、smart_import.py で再処理しても smart_import() の記述がないので実質的には処理されません。それでも、続いて定義ファイルを保存したときの処理対象が変わる点で効果があります。お好みで復活させてください。

スクリプトの起動設定(Emacs と Visual Studio Code の場合)

拡張子 .pine のファイルが保存されたときに上のスクリプト(名前は conv_latest_pine.zsh)を起動するため、開発に使っているエディタの Emacs 30.2 では次の設定をしています。Emacs での保存は普通に ctrl+X ctrl+S を使っています。

(defun my/conv-latest-pine ()
  "保存したファイルが .pine なら変換する"
  (let ((filename (buffer-file-name)))
    (when (and filename
               (string-equal (file-name-extension filename) "pine"))
      (shell-command
       (format "~/bin/conv_latest_pine.zsh %s" (shell-quote-argument filename))))))

;; ファイル保存時に Pine スクリプトの自動変換関数を実行する
(add-hook 'after-save-hook #'my/conv-latest-pine)

Visual Studio Code の場合についてAIに聞いたところ、拡張機能の Run on Save (emeraldwalk.runonsave) をインストールし、settings.json に次の設定をするそうです。

"emeraldwalk.runonsave": {
  "commands": [
    {
      "match": "\\.pine$",
      "cmd": "~/bin/conv_latest_pine.zsh \"${file}\""
    }
  ]
}

Step3 は OS や TradingView のショートカットで実現

この Step3 では TradingView のエディタにソースを貼ってコンパイルします。OS や TradingView のショートカットの範囲内で可能で、次の3段階で行います。

Step3-1: エディタ上のソース全体を選択
  OS で全選択するショートカットの cmd+A (ctrl+A) を使います。
  cmd は command キー、ctrl は control キーのことです。
  cmd+A は Mac のショートカット、括弧の中に書いた ctrl+A は Windows のショートカットです。

Step3-2: クリップボードにあるソースを貼り付け
  OS でペーストするショートカットの cmd+V (ctrl+V) を使います。
  smart_import.py がクリップボードにコピーしているので cmd+C (ctrl+C) は不要です。

Step3-3: ソースを保存・コンパイル
  TradingView で保存・コンパイルするショートカットの cmd+S (ctrl+S) を使います。

他の Step は Hammerspoon を使ったショートカットで実現

他の Step については、Google Chrome のタブ名を指定して飛ぶショートカットアプリ名を指定して飛ぶショートカットを使って実現します。

ショートカットの飛び先を決める

各 Step で使うショートカットの飛び先は次のとおりにしました。

Step2: TradingViewのエディタ画面に飛ぶ
  Google Chromeのタブ名 "tradingview.com/pine/" を指定して飛びます。

Step4: TradingViewのチャート画面に飛ぶ
  Google Chromeのタブ名 "tradingview.com/chart/" を指定して飛びます。

Step5: エディタの画面に飛ぶ
  アプリ名 Emacs (またはお使いのエディタ)を指定して飛びます。

タブ名、アプリ名などは部分一致で判定します。タブ名と書いていますが、正確には訪れている URL です。

チャート画面の URL 全体は https://jp.tradingview.com/chart/xxxxxxxx/ という形をしています。末尾の記号文字列なども指定すれば特定のチャートレイアウトの画面に飛ぶことや、なければ開くこともできます。

エディタ画面の URL 全体は https://jp.tradingview.com/pine/?id=USER%3Bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx という形をしています。末尾の記号文字列なども指定すれば特定のソースコードのエディタ画面に飛ぶことや、なければ開くこともできます。ただ、チャート画面とリンクする方法が分からないので使っていません。いい方法がありましたら教えてください。

ショートカットの実現方法

上記のショートカットは、Hammerspoon (version 1.1.1) というツールで次の設定をして実現しました。割り当てたキーは次のものです。setAppWindowHK() と setBrowserTabHK() の定義は末尾につけました。
Step2: TradingViewのエディタに飛ぶ cmd+ctrl+M
Step4: TradingViewのチャート画面に飛ぶ cmd+ctrl+V
Step5: 開発用エディタ画面に飛ぶ cmd+ctrl+E

Windowsの場合についてAIに聞いたところ、AutoHotkey v2 を勧められました。Chromeの拡張を使う方法もあるようです。

-- モディファイアの略記
local mod_ccas = {"cmd", "ctrl", "alt", "shift"}
local mod_cc__ = {"cmd", "ctrl"                } -- 今回はこれだけ使った
local mod_c___ = {"cmd"                        }
local mod__c__ = {       "ctrl"                }
local mod_____ = {                             }

-- キーバインド定義
setAppWindowHK(mod_cc__, "e", "Emacs", "", false)
setBrowserTabHK(mod_cc__, "v", "tradingview.com/chart/", "https://jp.tradingview.com/chart/xxxxxxxx")
setBrowserTabHK(mod_cc__, "m", "tradingview.com/pine/", false)

TradingView のチャートへ飛ぶ操作を該当タブが複数ある状態で行うと、既にチャートがフォーカスされている場合には次のチャートが、チャート以外がフォーカスされている場合には直近でフォーカスしていたチャートが表示されるようにしてあります。

おまけ(Step1の別の実装)

Emacs の設定を次のものにすると、上で使った zsh スクリプトが不要になります。zshスクリプトを呼び出すオーバーヘッドがないため、こちらのほうが実行速度で有利です。ただ、差は10%くらいでしたので、書き方が単純な zsh のものを使っています。

(defvar my/pine-target-file nil
  "直近保存された、無視・定義ファイルを除く.pineファイル")

(defun my/conv-latest-pine ()
  (let ((filename (buffer-file-name)))
    (when (and filename (string-equal (file-name-extension filename) "pine"))
      (let* ((base-name (file-name-nondirectory filename))
             (parent-dir (file-name-nondirectory (directory-file-name (file-name-directory filename))))
	     ;; ファイル名が sout.pine と一致するか、slib ではじまるか
             (ignored (or (string-equal "sout.pine" base-name)
			  (string-prefix-p "slib" base-name)))
             ;; ファイル名または親フォルダ名が def ではじまるか
             (defs (or (string-prefix-p "def" base-name)
                       (string-prefix-p "def" parent-dir))))

	;; slib*, sout.pine でないときに処理
        ;;(unless ignored
	;; def* でなければターゲットとして保持
        (unless defs
          (setq my/pine-target-file filename))
	;; ターゲットが存在する場合は変換する
        (when my/pine-target-file
          (let ((default-directory (file-name-directory my/pine-target-file)))
            (shell-command
	     (format "~/bin/smart_import.py --output-script='' --save-dep-graph=on-error --wait-after-lib-output %s"
                     (shell-quote-argument my/pine-target-file))))))))) ;;) 

;; ファイル保存時に Pine スクリプトの自動変換関数を実行する
(add-hook 'after-save-hook #'my/conv-latest-pine)

おわりに

一度開いた TradingView のエディタ画面は使い回しが可能です。例えば、次のようにエディタ画面左上からメニューを出して新規インジケータを作成することができます。また、新規インジケータを作らなくても、smart_import.py で生成したいろいろなソースでどんどん上書きして動作確認するという使い方もできます。その場合、TradingView 上の変更履歴が混沌としますが、履歴管理は TradingView の外で行ったほうが楽なので問題ないです。

開発のループで使うショートカットをまとめると以下のとおりです。

Step1: 開発用エディタで保存後、自動的にsmart_import.pyを走らせる ctrl+X ctrl+S
Step2: TradingViewのエディタに飛ぶ cmd+ctrl+M
Step3: TradingViewのエディタにソースを貼ってコンパイルする
Step3-1: エディタ上のソース全体を選択 cmd+A
Step3-2: クリップボードにあるソースを貼り付け cmd+V
Step3-3: ソースを保存・コンパイル cmd+S
Step4: TradingViewのチャート画面に飛ぶ cmd+ctrl+V
Step5: 開発用エディタ画面に飛ぶ cmd+ctrl+E

setAppWindowHK() と setBrowserTabHK() の定義は次のとおりです。

-- 直近で表示した対象をキーとして保存
local lastKey = ""

-- 直近で表示した対象のid(ウィンドウidまたはタブid)をキーごとに保存
local lastId = {}

-- 今回フォーカスするindexを返す
-- 直近で表示したidに対応するindexを基本とし、キーが同じならその次のindexを返す
local function getFocusIndex(key, matches, idExtractor)
    local id = lastId[key]

    -- idに対応するindexを探す
    local index
    if id then
        for i, match in ipairs(matches) do
            if idExtractor(match) == id then
                index = i
                break
            end
        end
    end

    -- 削除などでidが見つからない場合は1
    if index == nil then
	return 1
    end

    -- 同じキーなら巡回する
    if key == lastKey then
        return (index % #matches) + 1
    end

    return index
end

-- AppleScript用の簡単なエスケープ
local function asEscape(s)
    return (s or ""):gsub('\\', '\\\\'):gsub('"', '\\"')
end

-- アプリ名にappNameが、タイトルにwinTitleが、ともに含まれるウィンドウを巡回する
--
-- 対象ウィンドウがない場合、openCommandの値により次が行われる
--   nil(未指定): appName を起動
--   文字列     : その名前でアプリを起動
--   false      : 何もしない
local function cycleAppWindows(appName, winTitle, openCommand)
    winTitle = winTitle or ""

    -- アプリ名、タイトルが部分一致するウィンドウを集める
    local matches = {}
    for _, w in ipairs(hs.window.allWindows()) do
        local app = w:application()
        if app then
	    if app:name():find(appName, 1, true) and w:title():find(winTitle, 1, true) then
		table.insert(matches, w)
	    end
	end
    end

    -- 該当するウィンドウがなければ、引数に従ってアプリ起動かアラート表示
    if #matches == 0 then
	if openCommand == false then
            hs.alert(string.format("no match: %s %s", appName, winTitle))
            return
        end
	hs.alert("opening " .. appName)
        hs.application.launchOrFocus(openCommand or appName)
	return
    end

    -- 巡回順に並べる(ウィンドウid順)
    table.sort(matches, function(a, b) return a:id() < b:id() end)

    -- 対象ウィンドウを特定して表示
    local key = appName .. " > title = " .. winTitle
    local index = getFocusIndex(key, matches, function(w) return w:id() end)
    local win = matches[index]
    win:unminimize()
    win:focus()

    -- 巡回のときは、何番目か、全部でいくつかを表示
    if key == lastKey then
        hs.alert(index .. " of " .. #matches, 0.5)
    end

    -- 次回のためウィンドウidを保存
    lastKey = key
    lastId[key] = win:id()
end

-- URLにsearchUrlを含むブラウザタブを巡回する。
--
-- 対象タブがない場合、openUrlの値により次が行われる
--   nil(未指定): searchUrl を開く
--   文字列     : openUrl を開く
--   false      : 何もしない
--
-- browser の値により次のブラウザが使われる。
--   nil(未指定): Google Chrome
--   "beta"     : Google Chrome Beta
local function cycleBrowserTabs(searchUrl, openUrl, browser)
    browser = browser or "Google Chrome"
    if browser == "beta" then
        browser = "Google Chrome Beta"
    end

    -- URLが部分一致するタブの {ウィンドウid, タブid, タブ番号} を集める
    local asGetTabs = string.format([[
        tell application "%s"
            set matches to {}
            set winIdList to id of every window
            set tabIdLists to id of every tab of every window
            set urlLists to URL of every tab of every window
            repeat with w from 1 to count of urlLists
                set wid to item w of winIdList
                set tids to item w of tabIdLists
                set urls to item w of urlLists
                repeat with t from 1 to count of urls
                    if item t of urls contains "%s" then
                        copy {wid, t, item t of tids} to end of matches
                    end if
                end repeat
            end repeat
            return matches
        end tell
    ]], browser, asEscape(searchUrl))
    local ok, matches = hs.osascript.applescript(asGetTabs)

    -- 該当するタブがなければ、引数に従ってurlを開くかアラート表示
    if not ok or #matches == 0 then
        if openUrl == false then
            hs.alert("no match: " .. searchUrl)
	    return
        end
	hs.alert("opening url")
	hs.execute(string.format('open -a "%s" "%s"', browser, openUrl or searchUrl))
        return
    end

    -- 巡回順に並べる(ウィンドウidとタブ番号で比較)
    table.sort(matches, function(a, b)
	if a[1] ~= b[1] then
	    return a[1] < b[1]
	end
	return a[2] < b[2]
    end)

    -- 対象タブとそれを含むウィンドウを特定
    local key = browser .. " > tab = " .. searchUrl
    local index = getFocusIndex(key, matches, function(m) return m[3] end)
    local wid, t, tid = table.unpack(matches[index])

    -- 対象タブとそれを含むウィンドウを選択
    local asFocus = string.format([[
        tell application "%s"
            set minimized of window id %d to false
            set active tab index of window id %d to %d
            set index of window id %d to 1
        end tell
    ]], browser, wid, wid, t, wid)
    hs.osascript.applescript(asFocus)

    -- 対象アプリを表示
    local app = hs.application.get(browser)
    if app then app:activate() end

    -- 巡回のときは、何番目か、全部でいくつかを表示
    if key == lastKey then
        hs.alert(index .. " of " .. #matches, 0.5)
    end

    -- 次回のためにタブidを保存
    lastKey = key
    lastId[key] = tid
end

-- アプリウィンドウ巡回ホットキーの登録
-- cycleAppWindows()の説明を参照
local function setAppWindowHK(mods, key, appName, winTitle, openCommand)
    hs.hotkey.bind(mods, key, function()
        cycleAppWindows(appName, winTitle, openCommand)
    end)
end

-- ブラウザタブ巡回ホットキーの登録
-- cycleBrowserTabs()の説明を参照
local function setBrowserTabHK(mods, key, searchUrl, openUrl, browser)
    hs.hotkey.bind(mods, key, function()
        cycleBrowserTabs(searchUrl, openUrl, browser)
    end)
end

ここから先は

0字

メンバー特典記事、メンバー限定掲示板が、他のnoteメーンバーシップと同様にあります。 このメンバー…

スタンダードプラン

¥500 / 月
1ヶ月無料

プレミアムプラン

¥1,500 / 月

この記事が気に入ったらチップで応援してみませんか?