0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ipywidgetsで関数ブラウザを作る:大量ボタンをやめてフィルタ検索UIに改善

0
Posted at

user:

次の関数でWidgetに出てくるボタンの数が多くなりすぎると、ボタンがつぶれて見えにくい(スクロールは使えない)問題があります。改善した関数の完全コードを書いていただけますか?

import os
import importlib.util
import inspect
from IPython.display import display, HTML, clear_output
import ipywidgets as widgets


def browse_user_functions(module_path):
    """
    指定したモジュールまたはディレクトリからPythonモジュールを再帰的に探し、
    モジュール内の関数名リストをJupyter上に表示。
    関数名をクリックすると、その関数のdocstringを表示するインタラクティブウィジェット。

    Args:
        module_path (str): モジュールファイルのパス、またはモジュールディレクトリのパス。
    """

    # 指定パスがファイルなら、そのディレクトリをモジュール探索ディレクトリとして扱い、
    # ファイルは単体モジュールとして読み込み対象とする
    modules_info = []  # (module_name, module_object)

    def load_module_from_path(file_path, module_base_name=None):
        # module_base_name 省略時はファイル名から自動生成

        # module_nameはファイル名(.pyなし)などにする
        if module_base_name is None:
            module_base_name = os.path.splitext(os.path.basename(file_path))[0]

        spec = importlib.util.spec_from_file_location(module_base_name, file_path)
        if spec is None or spec.loader is None:
            print(f"ロード失敗: {file_path}")
            return None
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return module

    def recursive_find_modules(dir_path, base_module_name=""):
        """
        再帰でディレクトリから.pyファイルを見つけてモジュール名を
        ベースにしてモジュールとしてロードする。
        base_module_nameは空文字列なら単純モジュール名になる。
        """
        found = []
        for root, dirs, files in os.walk(dir_path):
            # __init__.pyがあればそこをモジュールとみなしてサブパッケージ判定可だが、
            # ここでは単純にファイル毎にロードするだけにする

            # rootからdir_pathの相対パスを取得
            rel_path = os.path.relpath(root, dir_path)
            if rel_path == '.':
                rel_module_prefix = ""
            else:
                # 相対パスをモジュールパスに変換(ディレクトリ名をドットでつなぐ)
                rel_module_prefix = rel_path.replace(os.sep, ".") + "."

            for f in files:
                if f.endswith(".py") and not f.startswith("__"):
                    module_name = base_module_name + rel_module_prefix + f[:-3]
                    file_path = os.path.join(root, f)
                    found.append( (module_name, file_path) )
        return found


    # --- モジュールの読み込み処理 ---

    modules_info = []

    if os.path.isdir(module_path):
        # ディレクトリ
        modules_list = recursive_find_modules(module_path)
        for mod_name, fpath in modules_list:
            try:
                mod_obj = load_module_from_path(fpath, mod_name)
                if mod_obj:
                    modules_info.append( (mod_name, mod_obj) )
            except Exception as e:
                print(f"モジュール読み込みエラー: {mod_name} ({fpath}): {e}")
    elif os.path.isfile(module_path) and module_path.endswith(".py"):
        # 単一ファイル
        mod_name = os.path.splitext(os.path.basename(module_path))[0]
        mod_obj = load_module_from_path(module_path, mod_name)
        if mod_obj:
            modules_info.append( (mod_name, mod_obj) )
    else:
        print("指定されたmodule_pathが.pyファイルかディレクトリではありません。")
        return


    # --- すべての関数を集めて、(モジュール名, 関数名, 関数オブジェクト) のリストを作る ---
    all_functions = []
    for mod_name, mod_obj in modules_info:
        funcs = inspect.getmembers(mod_obj, inspect.isfunction)
        for fname, fobj in funcs:
            all_functions.append( (mod_name, fname, fobj) )

    # --- ウィジェットの作成 ---

    output = widgets.Output()

    def on_function_clicked(mod_name, fname, fobj):
        with output:
            clear_output()
            doc = fobj.__doc__
            if doc is None or doc.strip() == "":
                doc = "(この関数にはドキュメンテーション文字列がありません)"
            display(HTML(f"<h3>{mod_name}.{fname}</h3><pre>{doc}</pre>"))

    # 関数名リストのボタン(またはリンク)を作成
    buttons = []
    for mod_name, fname, fobj in all_functions:
        btn = widgets.Button(description=f"{mod_name}.{fname}")
        btn.style.button_color = "#eee"
        btn.layout.width = "auto"  # 横幅自動調整

        # クロージャ対策でlambda内に引数明示的に渡す
        btn.on_click(lambda ev, mn=mod_name, fn=fname, fo=fobj: on_function_clicked(mn, fn, fo))
        buttons.append(btn)

    # 縦スクロールができるBoxにまとめる
    buttons_box = widgets.VBox(buttons,
                               layout=widgets.Layout(
                                   border="1px solid gray",
                                   height="300px",
                                   overflow_y="scroll",
                                   width="300px",
                                   padding="5px"
                               ))

    display(widgets.HBox([buttons_box, output]))


# 使い方例:
# browse_user_functions("../src")

assistant:

import os
import importlib.util
import inspect
from IPython.display import display, HTML, clear_output
import ipywidgets as widgets


def browse_user_functions(module_path, *, height="360px", left_width="420px"):
    """
    指定したモジュールまたはディレクトリからPythonモジュールを再帰的に探し、
    モジュール内の関数名をJupyter上に表示。
    関数名をクリックすると、その関数のdocstringを表示するインタラクティブウィジェット。

    改善点:
    - ボタン大量問題を解消するため「ボタン群」をやめ、検索可能なDropdown + 検索ボックスに変更
      (Jupyterのウィジェットでスクロール不可/潰れる問題を回避)
    - モジュール/関数でフィルタ可能
    - docstring表示は右側Outputに表示

    Args:
        module_path (str): モジュールファイル(.py)のパス、またはモジュールディレクトリのパス。
        height (str): 左パネルの高さ(例 "360px")
        left_width (str): 左パネルの幅(例 "420px""""

    # --------------------------
    # モジュール読み込み関連
    # --------------------------
    def load_module_from_path(file_path, module_base_name=None):
        if module_base_name is None:
            module_base_name = os.path.splitext(os.path.basename(file_path))[0]

        spec = importlib.util.spec_from_file_location(module_base_name, file_path)
        if spec is None or spec.loader is None:
            print(f"ロード失敗: {file_path}")
            return None
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return module

    def recursive_find_modules(dir_path, base_module_name=""):
        found = []
        for root, _, files in os.walk(dir_path):
            rel_path = os.path.relpath(root, dir_path)
            rel_module_prefix = "" if rel_path == "." else rel_path.replace(os.sep, ".") + "."
            for f in files:
                if f.endswith(".py") and not f.startswith("__"):
                    module_name = base_module_name + rel_module_prefix + f[:-3]
                    file_path = os.path.join(root, f)
                    found.append((module_name, file_path))
        return found

    modules_info = []

    if os.path.isdir(module_path):
        modules_list = recursive_find_modules(module_path)
        for mod_name, fpath in modules_list:
            try:
                mod_obj = load_module_from_path(fpath, mod_name)
                if mod_obj:
                    modules_info.append((mod_name, mod_obj))
            except Exception as e:
                print(f"モジュール読み込みエラー: {mod_name} ({fpath}): {e}")
    elif os.path.isfile(module_path) and module_path.endswith(".py"):
        mod_name = os.path.splitext(os.path.basename(module_path))[0]
        try:
            mod_obj = load_module_from_path(module_path, mod_name)
            if mod_obj:
                modules_info.append((mod_name, mod_obj))
        except Exception as e:
            print(f"モジュール読み込みエラー: {mod_name} ({module_path}): {e}")
    else:
        print("指定されたmodule_pathが.pyファイルかディレクトリではありません。")
        return

    # --------------------------
    # 関数収集
    # --------------------------
    # label: "module.func"
    items = []
    for mod_name, mod_obj in modules_info:
        for fname, fobj in inspect.getmembers(mod_obj, inspect.isfunction):
            label = f"{mod_name}.{fname}"
            items.append((label, mod_name, fname, fobj))

    # 表示用にソート
    items.sort(key=lambda x: x[0].lower())

    if not items:
        print("関数が見つかりませんでした。")
        return

    # モジュール一覧
    module_names = sorted(set(mn for _, mn, _, _ in items), key=str.lower)

    # --------------------------
    # ウィジェット
    # --------------------------
    output = widgets.Output(layout=widgets.Layout(border="1px solid #ddd", padding="8px"))

    module_dd = widgets.Dropdown(
        options=["(all)"] + module_names,
        value="(all)",
        description="module:",
        layout=widgets.Layout(width="100%")
    )

    search_box = widgets.Text(
        value="",
        placeholder="検索(関数名・モジュール名の一部)",
        description="search:",
        layout=widgets.Layout(width="100%")
    )

    # Dropdownは内部的にスクロールが効くので、ボタン大量より潰れにくい
    func_dd = widgets.Dropdown(
        options=[label for (label, _, _, _) in items],
        description="func:",
        layout=widgets.Layout(width="100%")
    )

    show_btn = widgets.Button(
        description="Show doc",
        button_style="",
        layout=widgets.Layout(width="140px")
    )

    clear_btn = widgets.Button(
        description="Clear",
        button_style="",
        layout=widgets.Layout(width="100px")
    )

    status = widgets.HTML(value="", layout=widgets.Layout(width="100%"))

    def filtered_items():
        mod_filter = module_dd.value
        q = (search_box.value or "").strip().lower()

        def ok(item):
            label, mn, fn, _ = item
            if mod_filter != "(all)" and mn != mod_filter:
                return False
            if q:
                # label内(module.func)に対して部分一致
                return q in label.lower()
            return True

        return [it for it in items if ok(it)]

    def refresh_func_dropdown(*_):
        its = filtered_items()
        if not its:
            func_dd.options = []
            func_dd.value = None
            status.value = "<span style='color:#b00'>該当する関数がありません。</span>"
            return

        labels = [it[0] for it in its]
        current = func_dd.value
        func_dd.options = labels

        # 可能なら元の選択を維持
        if current in labels:
            func_dd.value = current
        else:
            func_dd.value = labels[0]

        status.value = f"<span style='color:#555'>表示候補: {len(labels)} 件</span>"

    def find_by_label(label):
        for (lb, mn, fn, fo) in items:
            if lb == label:
                return mn, fn, fo
        return None

    def show_doc(*_):
        label = func_dd.value
        if not label:
            return
        found = find_by_label(label)
        if not found:
            return
        mn, fn, fo = found

        with output:
            clear_output()
            doc = fo.__doc__ or ""
            doc = doc if doc.strip() else "(この関数にはドキュメンテーション文字列がありません)"
            display(HTML(f"<h3>{mn}.{fn}</h3><pre style='white-space:pre-wrap'>{doc}</pre>"))

    def clear_doc(*_):
        with output:
            clear_output()

    module_dd.observe(refresh_func_dropdown, names="value")
    search_box.observe(refresh_func_dropdown, names="value")
    show_btn.on_click(show_doc)
    clear_btn.on_click(clear_doc)

    # 初期化
    refresh_func_dropdown()
    show_doc()

    # 左パネル(固定高さで、要素を縦に配置)
    left_panel = widgets.VBox(
        [
            module_dd,
            search_box,
            func_dd,
            widgets.HBox([show_btn, clear_btn]),
            status,
        ],
        layout=widgets.Layout(
            width=left_width,
            height=height,
            border="1px solid gray",
            padding="8px",
            justify_content="flex-start",
        ),
    )

    display(widgets.HBox([left_panel, output]))


# 使い方例:
# browse_user_functions("../src")
# browse_user_functions("../src", height="420px", left_width="520px")

この版は「大量のボタンを並べる」方式自体をやめているので、ボタンが潰れて見えにくくなる問題が起きません。必要なら、search_boxTextではなくComboboxにする版(入力補完あり)にもできます。

user:

ありがとうございます。上記のコードで「module」「search」「func」の使い分けを教えてください。

assistant:

  • module:対象モジュール(ファイル/パッケージ階層)で絞り込むためのフィルタです。

    • "(all)":全モジュールを対象
    • 例:utils.math を選ぶ → utils.math 内の関数だけが候補になります
    • 「どのファイル(モジュール)にある関数か分かっている」場合に最短です。
  • search:文字列の部分一致でさらに絞り込むための検索欄です(module.func 全体に対して検索します)。

    • 例:plot と入力 → visual.plot_lineplot_hist のように、名前に plot を含む関数だけが候補になります
    • 例:math.sin と入れる → math.sin を含むものだけ、といった使い方もできます
    • 「関数名があいまい」「モジュール名もうろ覚え」でも探しやすいのが利点です。
    • module"(all)" のまま search だけで探すこともできます。
  • func:絞り込まれた候補の中から、最終的に表示したい関数(docstringを見たい関数)を選ぶリストです。

    • modulesearch の結果で候補が更新され、その候補一覧がここに出ます。
    • Show doc を押す(または初期表示)と、選択中の func のdocstringが右側に表示されます。

使い分けの典型例:

  1. モジュールが分かっているmodule を選ぶ → func から選ぶ(必要なら search
  2. 関数名の一部だけ覚えているmodule=(all) のまま search に入力 → func から選ぶ
  3. モジュールも関数名もあいまい → まず search を広めに入れる → ヒットが多ければ module で絞る → func で決定

user:

ありがとうございました。以上の内容をブログにまとめたいので、タイトル案を考えてください。

assistant:

  • Jupyterで関数一覧が「ボタンで潰れる」問題を解決:検索+Dropdownで快適にdocstring閲覧
  • ipywidgetsで関数ブラウザを作る:大量ボタンをやめてフィルタ検索UIに改善
  • Pythonモジュール内の関数を探しやすく:module/search/funcで絞り込む関数一覧ウィジェット
  • Jupyter Notebook向け「関数ドキュメントブラウザ」自作メモ:ボタン地獄からの脱出
  • os.walk+inspect+ipywidgetsで作る関数一覧ツール:検索でスッキリ表示
  • 大量の関数でも見やすい:Jupyter用 関数リストUI(検索・モジュール絞り込み対応)
  • 「関数が多すぎて見つからない」を解消:Jupyterでの関数探索ウィジェット改善
  • Python関数のdocstringを一覧・検索:スクロール不要のJupyterウィジェット実装
  • クリックできる関数一覧の作り方(Jupyter/ipywidgets):ボタン表示の限界と改善策
  • Jupyterで自作ライブラリをブラウズする:関数一覧を検索してdocstring表示する方法

より寄せたい方向(初心者向け/実装メイン/UI改善メイン)や、ブログの読者層が分かれば、それに合わせてもう少し尖ったタイトルにもできます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?