IntelliJ Platform Plugin Templateを使って、「エディタのコンテキストメニューからダイアログを表示する」だけのJetBrains系IDEプラグインを作ってみた

JetBrains系IDEを使って日常的にコードを書いていますが、ふとJetBrains系IDEプラグインを作ってみたくなりました。

とはいえ、JetBrains系IDEプラグインの作り方がよく分からなかったので調べてみたところ、 IntelliJ Platform Plugin Template というリポジトリを使うと簡単に作成できそうだと分かりました。
https://github.com/JetBrains/intellij-platform-plugin-template

そこで、IntelliJ Platform Plugin Template を使って、「コンテキストメニューからダイアログを表示する」だけのHello world的なJetBrains系IDEプラグインを作ってみたことから、メモを残します。

 
目次

 

環境

  • Windows11
    • WSL2上ではなく、Windows11上で開発します
  • IntelliJ IDEA 2023.3.4 Ultimate Edition

なお、ローカルではJava/Kotlinを使った開発をしていないことから、Javaまわりは何もインストールしていない状態でした。

 

IntelliJ Platform Plugin Templateを元にした環境構築

まずは、READMEのGetting startedの内容から始めます。
https://github.com/JetBrains/intellij-platform-plugin-template?tab=readme-ov-file#getting-started

最初にリポジトリUse this template ボタンをクリックし、プラグイン名を hello_jetbrains_plugin とするなど、必要な事項を入力して自分のリポジトリへとcloneしました。

できあがったリポジトリは以下です。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

 
続いて、 IntelliJ Platform Plugin Template のREADMEに従い、 Get from VCS 機能でソースコードをローカルに clone & 開きます。

今までこの機能を使ったことがなかったのですが、簡単にcloneとセットアップができました。

 
引き続きREADMEに従い、WindowsJavaSDKをインストールします。

バージョンとベンダーを選べますが、ひとまず

  • Java17
  • ベンダーは JetBrains Runtime version 17.0.9
    • 深い意味はなく、とりあえずJetBrainsが提供しているものにしてみました

を選んでおきました。

 
この時点でプラグインを起動できるため、 Run Plugin を実行してみます。

すると、プラグインがインストールされた状態でIntelliJ IDEAが起動しました。

 

コンテキストメニューからダイアログを開く」機能を追加する

IntelliJ Platform Plugin TemplateのREADMEにはプラグインの作り方は記載されていなかったので、別のドキュメントを探すことにしました。

まず、 IntelliJ Platform SDK のドキュメントを見に行きましたが、量に圧倒されてしまいました。
IntelliJ Platform SDK | IntelliJ Platform Plugin SDK

 
そこで、まずは簡単なプラグインを作るところから始めようということで、チュートリアル的に書かれている以下のブログを読みました。

 
上記のブログを読んだところ、「エディタコンテキストメニューからダイアログを表示する」プラグインを作るのが最初の一歩として良さそうに感じましたので、作っていきます。

 
まずは、公式SDKドキュメントに

The action implementation determines the contexts in which an action is available, and its functionality when selected in the UI.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

とあるような、アクションと呼ばれる機能を作成してみます。

そこで、 src/main/kotlin/com/github/thinkami/hellojetbrainsplugin/actions/HelloAction.kt ファイルを作成し、 AnAction を継承したActionのクラスを実装します。

ダイアログの表示は Messages.showMessageDialog を使うことで実現できます。

package com.github.thinkami.hellojetbrainsplugin.actions

import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.ui.Messages

class HelloAction: AnAction() {
    override fun actionPerformed(e: AnActionEvent) {
        Messages.showMessageDialog(buildString {
            append("ハロー")
        }, "ワールド", null)
    }
}

 
続いて、

Registration determines where an action appears in the IDE UI. Once implemented and registered, an action receives callbacks from the IntelliJ Platform in response to user gestures.

 
https://plugins.jetbrains.com/docs/intellij/basic-action-system.html

のように、アクションを呼び出す方法を定義します。

今回は「エディタのコンテキストメニューから上記のActionを起動する」設定を定義します。

そこで、 src/main/resources/META-INF/plugin.xml を開き、末尾にActionの呼び出しを追加します。

<idea-plugin>
    ...
    </applicationListeners>

    <actions>
        <action id="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                class="com.github.thinkami.hellojetbrainsplugin.actions.HelloAction"
                text="Hello Action"
                description="hello world action">
            <add-to-group group-id="EditorPopupMenu" anchor="first" />
        </action>
    </actions>
</idea-plugin>

 

動作確認

先ほどと同様、 Run Plugin を実行し、IntelliJ IDEAを起動します。

今回の機能はエディタでコンテキストメニューを開く必要があるため、適当なKotlinプロジェクトを探してみます。

すると、 Kotlin Programming Tutorial for Beginners と書かれているリポジトリがありました。
https://github.com/smartherd/KotlinTutorial

今回はこれを使うことにして、 Get from VCS 機能にてローカルにclone・プロジェクトを開きます。

 
適当なkotlinのファイルを開き、エディタ上で右クリックしてコンテキストメニューを表示したところ、 Hello Action というメニューがありました。

 
このメニューをクリックしたところ、Hello world的なダイアログが表示されました。

 
以上より、今回やりたかったことは実現できました。

 

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/hello_jetbrains_plugin/pull/9

 

Github Actionsの様子

ちなみに、プルリクを作ったところ、IntelliJ Platform Plugin Template に設定されていたようで、Github ActionsによるCIが自動実行されました。

 
最終的にはこんな感じになりました。

Railsにて、1つのテーブルの複数列で同じテーブルを参照し、参照先を主キーもしくは主キー以外の属性としたい場合に、モデルやマイグレーションの定義方法を調べてみた

Railsを使っている中で、記事のタイトルのようなことがしたくなりました。

例えば、以下のようなことがしたくなりました。

  • applesとcolorsという2テーブルがある
  • applesテーブルには以下の3列があり、いずれもcolorsと関連を持たせたい
    • 列について
      • 果実の色 (fruit_color)
      • 花の色 (flower_color)
      • 葉の色 (leaf_color)
    • 関連について
      • fruit_colorflower_color は、どちらも colors.id に対して外部キー制約をつけたい
      • leaf_color について、列名は leaf_color_name 、参照先は colors.name にしたい
        • 無理に外部キー制約を付けなくても良い

 
そこで、マイグレーション・モデルにどのような設定をすればよいか調べたため、メモを残します。

 
目次

 

環境

 

複数の列の外部キー制約を、同じテーブルに対して行う

前述の例で言えば、「applesテーブルの fruit_colorflower_color の各列から、 colors.id に対して外部キー制約を付けたい」を行いたいときのマイグレーションとモデルの定義を確認します。

 

マイグレーションの定義

今回は一度に定義するのではなく、各ステップごとにマイグレーションを用意します。

まずは apples テーブルがあるとします。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
次に、 colors テーブルを用意します。

class CreateColors < ActiveRecord::Migration[7.1]
  def change
    create_table :colors do |t|
      t.string :name

      t.timestamps
    end
  end
end

 
続いて、 fruit_color から apples テーブルへの外部キー制約を追加します。

今回は add_reference を使って

な感じで定義します。

class AddColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :fruit_color, foreign_key: { to_table: :colors }
  end
end

 
もう一つの列も同様に定義します。

class AddFlowerColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_reference :apples, :flower_color, foreign_key: { to_table: :colors }
  end
end

 

モデルの定義

続いて、モデルに関連付けを定義します。

今回は、関連付け名はデフォルトではなく、 fruit_colorflower_color にしたいことから、 belongs_to のオプション class_nameforeign_key を使って定義します。

class Apple < ApplicationRecord
  # 各リレーションを分かりやすくするため、belongs_to で別名を付けて、 class_name で関連先のモデル名を指定している
  belongs_to :fruit_color, class_name: 'Color', foreign_key: 'fruit_color_id'
  belongs_to :flower_color, class_name: 'Color', foreign_key: 'flower_color_id'
end

 

動作確認

以上でマイグレーションとモデルの定義ができました。

そこで、テストコード (model spec + factory_bot) を書いて動作確認します。

まずはテストデータです。

RSpec.describe Apple, type: :model do
  let!(:yellow_color) { create(:color, :yellow_color) }
  let!(:white_color) { create(:color, :white_color) }

  let!(:shinano_gold) { create(:apple,
                               name: 'シナノゴールド',
                               fruit_color: yellow_color,
                               flower_color: white_color)}
end

 
次に、 apples から colors をたどれるか確認します。今回は、 apples から colors.name を取得できるか確認します。

また、取得方法も

  • ドット(関連付け)で取得
  • eager_load で取得
  • joins + select で取得

のパターンをためせるテストコードを書きます。

describe 'fruit_color' do
  it 'ドットで取得できること' do
    p Apple.find_by(name: 'シナノゴールド').fruit_color.name
    actual = Apple.find_by(name: 'シナノゴールド').fruit_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).first.fruit_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

describe 'flower_color' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').flower_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:flower_color).where(name: 'シナノゴールド' ).first.flower_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:flower_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end
end

 
テストコードを実行してみると、いずれのテストもパスしました。

 

外部キー制約の参照先を主キー以外の列にも指定できるか

ここまでで、複数の列で同じテーブルを参照するときの外部キー制約をためしてみました。

ただ、データベースによっては外部キー制約を主キー以外にも設定できます。

そこで、Railsの場合にはどのように設定するかを調べてみました。

 

調査

マイグレーションでは「生SQLを書く」以外の方法が分からず

Rails APIのドキュメントを見ながら、主キー以外の列を参照する外部キー制約が付けられるか試してみました。

 
しかし、主キーを参照する前提のようだったため、Rails API を使って記述することはできませんでした。

SQLを書けばいけるかもしれませんが、レールを外れそうだったのと、データベースによってはうまく動作しないようでした。

例えばMySQLの場合、

UNIQUE でないキーを参照する FOREIGN KEY 制約は、標準 SQL ではなく InnoDB拡張機能です。 一方、NDB ストレージエンジンでは、外部キーとして参照される任意のカラムに明示的な一意キー (または主キー) が必要です。

 

一意でないキーまたは NULL 値を含むキーへの外部キー参照の処理は、UPDATE や DELETE CASCADE などの操作に対して適切に定義されていません。 UNIQUE (PRIMARY を含む) および NOT NULL キーのみを参照する外部キーを使用することをお勧めします。

 
https://dev.mysql.com/doc/refman/8.0/ja/ansi-diff-foreign-keys.html

との記載があります。

 
そのため、今回マイグレーションで外部キー制約を付与する、つまりデータベースレイヤでデータを保護するのは諦めました。

 

モデルで belongs_to を使って、外部キー制約なしの関連付けする

前述の通りデータベースレイヤでは諦めましたが、Railsレイヤで行えることがあるかもしれないと思い、調べてみました。

すると、モデルで belongs_to + class_name + foreign_key + primary_key を使えば関連付けができそうでした。

 
気になるのは、「primary_key に主キー以外の項目を設定してもよいのか」ですが、以下の記事やソースコードを見るとうまく動きそうな気がします。

 

実装

では実際にためしてみます。

まずはマイグレーションで、 apples テーブルに string 型の leaf_color_name 列を追加します。

class AddLeafColorColumnToApple < ActiveRecord::Migration[7.1]
  def change
    add_column :apples, :leaf_color_name, :string
  end
end

 
続いて、モデル Apple で、 belongs_to

  • foreign_key に、Appleの属性である leaf_color_name を指定
  • primary_key に、参照先のColorの属性である name を指定

の各オプションを渡して関連付けを定義します。

class Apple < ApplicationRecord
  # name列同士の関連付けをもたせる
  belongs_to :leaf_color, class_name: 'Color', foreign_key: 'leaf_color_name', primary_key: 'name'
end

 

動作確認

では、先ほどの外部キー制約があるときと同様、テストコードを書いて動作を確認してみます。

以下のテストコードを書いて実行したところ、いずれもテストがパスしました。

describe 'leaf_color_name' do
  it 'ドットで取得できること' do
    actual = Apple.find_by(name: 'シナノゴールド').leaf_color.name
    expect(actual).to eq('')
  end

  it 'eager_load + ドットで取得できること' do
    actual = Apple.eager_load(:leaf_color).where(name: 'シナノゴールド' ).first.leaf_color.name
    expect(actual).to eq('')
  end

  it 'joins + select で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').select('colors.name').first
    expect(actual.name).to eq('')
  end

  it 'joins + pluck で取得できること' do
    actual = Apple.joins(:leaf_color).where(name: 'シナノゴールド').pluck('colors.name').first
    expect(actual).to eq('')
  end
end

 

実際に発行されるSQLを確認

テストはパスしたものの、実際に発行されるSQLのJOINの条件が気になりました。

そこで、JOINが発生する

  • eager_load
  • joins

の各メソッドにて、実際に発行されるSQLを確認してみます。

 

eager_loadのときのSQL

LEFT OUTER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "apples"."id" AS t0_r0,
  "apples"."name" AS t0_r1,
  "apples"."created_at" AS t0_r2,
  "apples"."updated_at" AS t0_r3,
  "apples"."fruit_color_id" AS t0_r4,
  "apples"."flower_color_id" AS t0_r5,
  "apples"."leaf_color_name" AS t0_r6,
  "colors"."id" AS t1_r0,
  "colors"."name" AS t1_r1,
  "colors"."created_at" AS t1_r2,
  "colors"."updated_at" AS t1_r3
FROM
  "apples"
  LEFT OUTER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 

joinsのときのSQL

こちらも、INNER JOIN の ON 句で "colors"."name" = "apples"."leaf_color_name" となっていました。

SELECT
  "colors"."name"
FROM
  "apples"
  INNER JOIN "colors" ON "colors"."name" = "apples"."leaf_color_name"
WHERE
  "apples"."name" IN ("name", "シナノゴールド")
ORDER BY
  "apples"."id" ASC
LIMIT
  1

 
以上より、主キー以外の属性でも関連付けができました。

 

SQLのJOINが発生するメソッド + select ('*') したときの挙動を確認

ところで本題とはズレるのですが、join系メソッドを調べていた時に以下の記事とGithubへのリンクを見つけました。

 
そのissueやプルリクはまだcloseしていなかったため、Rails 7.1系ではどのような結果になるか試してみたくなりました。

そこでテストコードを書いてみたところ、いずれもパスしました。

describe "applesとcolorsでname列が重複しているときの join系 + select('*')の挙動" do
  context 'eager_loadの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.eager_load(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'left_joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.left_joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end

  context 'joinsの時' do
    it 'apple.nameを期待したいが、color.nameになっていること' do
      actual = Apple.joins(:fruit_color).where(name: 'シナノゴールド' ).select('*').first

      expect(actual.name).not_to eq('シナノゴールド')
      expect(actual.name).to eq('')
    end
  end
end

 
参照した記事にもある通り、 SQLのJOINが発生するメソッド + select('*') を使うことは無いと思いますが、覚えておいたほうが良いかもしれません。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app/pull/2

Railsにて、トランザクションの中だとモデルのid列はいつ設定されるのか確認してみた

Railsを使っている中で、「トランザクションの中だとモデルのid列はいつ設定されるのか」が気になったことから、確認してみたときのメモを残します。

目次

 

環境

  • Rails 7.1.3
  • DBはデフォルトのSQLite3

 
また、今回は以下のモデルとマイグレーションを用意します。

apple.rb

class Apple < ApplicationRecord
end

 
モデルに対するマイグレーションファイルはこちら。

class CreateApples < ActiveRecord::Migration[7.1]
  def change
    create_table :apples do |t|
      t.string :name

      t.timestamps
    end
  end
end

 

動作確認

上記のAppleモデルに対して、

の各タイミングで、モデルオブジェクトの id がどのように設定されるかを確認します。

まずは以下の rake task を作成します。

namespace :print_model_id_with_transaction do
  desc 'トランザクション利用時のmodelのidを確認'

  task run: :environment do
    # rake taskだと、`config.active_record.verbose_query_logs = true` であってもSQLログが出ないので、設定しておく
    # ActiveRecord::Base.logger = Logger.new(STDOUT)
    # Rails.logger.level = Logger::DEBUG

    ActiveRecord::Base.transaction do
      apple = Apple.build(name: 'シナノゴールド')
      puts "step1: plan.id => #{apple.id}"
      apple.save
      puts "step2: plan.id => #{apple.id}"

      raise ActiveRecord::Rollback
    end

    apple_without_transaction = Apple.build(name: '奥州ロマン')
    puts "step3: plan.id => #{apple_without_transaction.id}"
    apple_without_transaction.save
    puts "step4: plan.id => #{apple_without_transaction.id}"
  end
end

 
続いて rake task を実行したところ

$ bin/rails print_model_id_with_transaction:run
step1: plan.id => 
step2: plan.id => 1
step3: plan.id => 
step4: plan.id => 1

となりました。

これより、

という挙動と分かりました。トランザクションの中と外で設定されるタイミングは同じなようです。

 
次に、実際に発行されるSQLも見てみます。

今回は rake task で動作確認をしているため、task の先頭に以下を追加します。
rakeタスクでクエリのログを標準出力に出す | このコードわからん

task run: :environment do
  # 追加
  ActiveRecord::Base.logger = Logger.new(STDOUT)
  Rails.logger.level = Logger::DEBUG

  # 以下同じ
  ActiveRecord::Base.transaction do
  ...

 
再度 rake task を実行すると、以下のような感じで表示されました(横長になったので、タイムスタンプ部分を削除しています)。

トランザクションの中では、 commit transaction がなくてもモデルの id 列に値が設定されるようです。

$ bin/rails print_model_id_with_transaction:run
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:10:in `block (3 levels) in <top (required)>'
step1: plan.id => 
D, :   Apple Create (0.2ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "シナノゴールド"], ["creat "2024-02-17 11:22:40.829966"], ["updated_at", "2024-02-17 11:22:40.829966"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:12:in `block (3 levels) in <top (required)>'
step2: plan.id => 2
D, :   TRANSACTION (0.1ms)  rollback transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:9:in `block (2 levels) in <top (required)>'
step3: plan.id => 
D, :   TRANSACTION (0.1ms)  begin transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   Apple Create (1.0ms)  INSERT INTO "apples" ("name", "created_at", "updated_at") VALUES (?, ?, ?) RETURNING "id"  [["name", "奥州ロマン"], ["created_a2024-02-17 11:49:59.591712"], ["updated_at", "2024-02-17 11:49:59.591712"]]
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
D, :   TRANSACTION (7.3ms)  commit transaction
D, :   ↳ lib/tasks/print_model_id_with_transaction.rake:20:in `block (2 levels) in <top (required)>'
step4: plan.id => 2

 

余談:Railsのバージョンにより、トランザクション内での return 等の挙動が変わる

本題とは関係ない内容です。

Railsトランザクションまわりを調べていたところ、Railsのバージョンによりトランザクションの挙動が変わると知りました。

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_7_1_minimal_app/pull/1

書籍「Solving Identity Management in Modern Applications - 2nd edition」を読みました

前回、SAML2のSP-initiated フローをためしてみました。
Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた - メモ的な思考的な

書籍「SAML入門 - かなめりぜ」を読んだり手を動かしたりして、なんとなくSAML2への理解が進んだような気がしました。

 
ただ、もう少し理解を勧めたいと考えて書籍を探したところ、「Solving Identity Management in Modern Applications - 2nd edition」がありました。
Solving Identity Management in Modern Applications: Demystifying OAuth 2, OpenID Connect, and SAML 2 | SpringerLink

1st editionは2019年に出版され、今回読んだ 2nd edition は2022年に出版されたようでした。

SAML2について書かれた本は貴重なため、読んでみました。

 

書籍の概要

SAML2に特化した内容というよりは、目次にある通り

  • 認証認可まわりフレームワークO
    • Auth2やOIDC、SMAL2
  • 権限管理
  • セッション管理
  • ログイン
  • ログアウト
  • アカウント管理
  • アカウントの失効

など、IDまわりの開発~運用まで一通りふれられていました。

また、OAuth2.1 draft などについても書かれており、最新の状況に追随している印象を受けました。

 
一方、ID管理まわりの技術に対するサンプルコードやライブラリ・製品の紹介などがほとんどありませんでした。

そのため、もっと実装寄りのことを学ぶには

  1. この書籍でID管理まわりのキーワードを拾う
  2. 別の資料を読んだり、自分で実装を試してみる

という流れが必要そうと感じました。

 

SAML2 の記載について

この書籍を読む目的である、SAML2に関する情報については

  • 「7. SAML2」
  • 「Back Matter」の中の APPENDIX C SAML2 AUTHENTICATION REQUEST AND RESPONSE

に記載されていました。

以降では、それぞれの内容を軽くメモしておきます。

 

7. SAML2 について

SAML入門同様、SAML認証の各フローやシングルサインオンについて記載されていました。

また、SAMLに関係する内容として、

  • Identity Federation
  • Authentication Brokers

についてもふれられていました。

 
個人的には、 Authentication Brokers について知らなかったので、ためになりました。

ちなみに、読書後に Authentication Brokers についてWebで調べてみたところ、Keycloakの Identity Broker 機能のような印象を受けています。
KeycloakでSAMLログインをテストしたい #テスト - Qiita

 

APPENDIX Cについて

SAML入門と比較すると以下のような感じでした。

  • C1. SAML2 AUTHENTICATION REQUEST
    • SAML入門の「5章 やりとりする内容」あたり
  • C2. SAML2 AUTHENTICATION RESPONSE
    • SAML入門の「5章 やりとりする内容」あたり
  • C3. VALIDATION
    • SAML入門 p55 の 「署名以外にチェックする項目」あたり

 
そのため、「SAML入門でも十分だけど、補助的な資料としてここを読む」ような感じで良さそうでした。

 

おわりに

サンプルコードが無いこともあり、この本だけで各ID管理技術を実装するのは難しく感じる一方で、ID管理の全体像を知ることができて有意義でした。

今回はSAML2を知るために読みましたが、他のID管理技術の内容をざっくりつかみたい時は、また読んでみようと思いました。

Rubyのオブジェクト配列にて、各要素の同一属性で同じ値が何個あるかを数える

Rubyのオブジェクト配列にて、各要素の同一属性で同じ値が何個あるかを数えたくなる機会がありました。

例えば

class Apple
  attr_reader :name, :grower

  def initialize(name, grower)
    @name = name
    @grower = grower
  end
end

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

というオブジェクト配列があったときに、りんごの名前 (name) ごとの生産者(grower) の人数の取得方法を知りたくなったのでした。

そこで、調べたときのメモを残します。

 
目次

 

環境

 

gropu_by + transform_values を使う

調べてみたところ、以下の記事を参考になりました。ありがとうございました。
配列に同じ要素が何個あるかを数える - patorashのブログ

 
そこで、記事に記載のあった通り、 gropu_by 後に transform_values を使って実装してみました。

 
すると、欲しい結果が得られました。

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

p apples.map(&:name).group_by(&:itself).transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

 
ただ、これだけだとメソッドチェーンの途中でどのような形になっているか、まだ理解できていませんでした。

そこで、途中結果を表示してみた上で、自分向けのメモも残してみます。

 

途中経過のメモ

まずは map を使い、オブジェクトの name 属性の配列にします。

r1 = apples.map(&:name)
p r1
# => ["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]

 
続いて、group_by(&:itself) にて

  • キー
    • ブロックの中で、オブジェクトの itself メソッドを使うことで得られた、 シナノゴールド秋映奥州ロマン
    • itself の結果を、キーごとに配列化

という形のハッシュにします。

r2 = r1.group_by(&:itself)
p r2
# => {"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}

 
最後に、 transform_values(&:size) で、ハッシュの値をブロックの結果 (&:size による配列の要素数) へと差し替えます。

p r2.transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

 

ソースコード全体

以下のソースコードmain.rb で保存し、ruby main.rb と実行することで、同じ結果が得られます。

class Apple
  attr_reader :name, :grower

  def initialize(name, grower)
    @name = name
    @grower = grower
  end
end

apples = [
  Apple.new('シナノゴールド', 'Aさん'),
  Apple.new('秋映', 'Aさん'),
  Apple.new('シナノゴールド', 'Bさん'),
  Apple.new('秋映', 'Cさん'),
  Apple.new('奥州ロマン', 'Cさん'),
  Apple.new('シナノゴールド', 'Dさん'),
]

# 途中経過版
r1 = apples.map(&:name)
p r1
# => ["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]

r2 = r1.group_by(&:itself)
p r2
# => {"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}

p r2.transform_values(&:size)
# => {"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

# チェーン版
puts '=' * 30
p apples.map(&:name).group_by(&:itself).transform_values(&:size)

 
実行結果は以下です。

% ruby --version   
ruby 3.3.0 (2023-12-25 revision 5124f9ac75) [x86_64-darwin21]

% ruby main.rb     
["シナノゴールド", "秋映", "シナノゴールド", "秋映", "奥州ロマン", "シナノゴールド"]
{"シナノゴールド"=>["シナノゴールド", "シナノゴールド", "シナノゴールド"], "秋映"=>["秋映", "秋映"], "奥州ロマン"=>["奥州ロマン"]}
{"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}
==============================
{"シナノゴールド"=>3, "秋映"=>2, "奥州ロマン"=>1}

Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみた

以前、OpenID Connect によるシングルサインオン環境を構築しました。
Railsとdoorkeeper-openid_connectやOmniAuth を使って、OpenID Connectの OpenID Provider と Relying Party を作ってみた - メモ的な思考的な

OpenID Connect以外でシングルサインオン環境を構築する方法として SAML がありますが、今までさわってきませんでした。

 
そんな中、書籍「SAML入門」を読む機会がありました。

書籍では

  • SAMLの認証フロー
  • 認証フローのリクエスト・レスポンスの中身を掲載
  • Dockerを使って実際にSAML認証を試す
  • SAMLの仕様へのリンクやお役立ちツール

などが分かりやすく記載されており、とてもためになりました。ありがとうございました。

 
本を読んでみて気持ちが盛り上がり、自分でもSPとIdPの環境を構築してみたくなりました。

そこで、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証してみたので、メモを残します。

 
目次

 

環境

  • WSL2
  • SP
    • Python 3.11.7
    • Flask 3.0.2
    • pysaml2 7.5.0
  • IdP
    • Keycloak 23.0.6

 
なお、ソースコードは必要に応じて記載しているものの、全部は記載していません。

詳細はGithubリポジトリを確認してください。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

 

やらないこと

あくまで「SAMLの認証フローを体験する」がメインなので、以下は行いません。

  • 本番運用に即した、Keycloakやpysaml2の設定
  • セキュリティまわりを真剣に考えること

 
また、SPとIdPの両方を自作すると完成が遅くなりそうでした。

そのため、SAML入門同様、今回はIdPにKeycloakを使うことにして、IdPの自作は行いません。

 

SP向けのライブラリについて検討

今回はPythonで書いてみようと考え、SAML2関連のライブラリを探したところ、以下の2つがありました。

両方とも同じくらいのstarだったため、どんな違いがあるのか調べたところ、2016年のstackoverflowに情報がありました。
single sign on - Python SSO: pysaml2 and python3-saml - Stack Overflow

python3-saml のauthorのコメントだったものの、python3-saml が良さそうに感じました。

ただ、

ということから、今回は pysaml2 でSPを作ることにしました。

 
ライブラリは決まったものの、 pysaml2 を使ってゼロから作るのは大変そうです。

サンプルコードを探したところ、oktaにてサンプルコードが公開されていました。

そこで、これをベースに作っていくことにしました。

 
なお、上記oktaのサンプルだと Flask-Login を使うことでログインまわりをきちんと作っています。

ただ、今回は必要最低限の実装にするので、ログインまわりについては

  • Flask-Login は使用しない
  • その代わり、SAML認証成功時にセッションへデータをセットする
    • セッションにデータがあればログイン成功とみなす

とします。

 

SAML用のChrome拡張について調査

SAMLのリクエスト・レスポンスをChromeで確認できると便利です。

調べた見たところ、SAML-tracer がありました。
SAML-tracer

この拡張ですが、

ということから、今回使ってみることにしました。

この拡張を使うことで、SAMLのパラメータを見たり、

 
実際のリクエストで使われるSAMLを見れたりと、開発をする上で便利になりました。

 

Keycloakのセットアップ

Keycloakは、公式のDockerイメージが quay.io で提供されています。
https://quay.io/repository/keycloak/keycloak?tab=tags

今回は、最新バージョンの docker compose で Keycloak をたてることにしました。

そこで、以下の compose.yaml を用意しました。

ちなみに、ポート 8080 はよく見かけるため、 18080 へと変更しています。

services:
  keycloak:
    image: quay.io/keycloak/keycloak:23.0.6
    # dockerコマンドのitオプションと同様にするため、 ttyとstdin_openを付けておく
    tty: true
    stdin_open: true
    ports:
        - 18080:8080
    environment:
      KEYCLOAK_ADMIN: admin
      KEYCLOAK_ADMIN_PASSWORD: admin
    command:
      - start-dev

 
準備ができたので、起動します。

$ docker compose up -d

 
続いて、公式ドキュメントの手順に従い、Keycloakの設定を行います。
Docker - Keycloak

  • ポートを変更しているので、以下のURLにアクセス
  • realmを作る
    • nameを myrealm とする
    • それ以外はデフォルト
  • ユーザー作る
    • myrealm に切り替える
    • 以下を入力
      • Username: myuser
      • First Name: Foo
      • Last Name: Bar
  • ユーザーにパスワードを設定する
    • Credentials タブを開く
    • 以下を入力
      • Password: baz
      • Password confirmation: baz
      • Temporary: Off
  • 作成したユーザー myuser でKeycloakへログインしてみる
  • Realm settings の Endpoints をクリックし、エンドポイント情報を確認しておく

 

pysaml2を使って、SPを作る

各ライブラリのインストール

今回、WSL2上にSPをたてます。

はじめに、pysaml2のREADMEにある通り、 xmlsec1 をインストールします。

$ sudo apt install xmlsec1

続いて、Flaskとpysaml2をインストールします。

$ pip install pysaml2 flask

 

Flaskアプリの作成

ミニマムな saml_client_for メソッドへと変更

oktaのサンプルコードを一部改変し、ミニマムな実装にします。

まず、今回はHTTP通信だけ使うので、変数 asc_urlSAML Requestのみの

acs_url = url_for(
    'saml_request',
    _external=True)

とします。

変数 settings については、

  • endpoint は以下の2つ分を定義
    • SAML Requestのときの HTTP Redirect Binding
    • SAML Responseのときの HTTP POST Binding
  • 各種 signed は False
  • allow_unsolicitedTrue
    • 未設定だと saml2.response.UnsolicitedResponse: Unsolicited response: id-*** エラーが発生する
      • Keycloak側の設定不足かもしれない
  • metadata には remote を追加
    • これがないと、 saml2.client_base.SignOnError: {'message': 'No supported bindings available for authentication', 'bindings_to_try': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect'], 'unsupported_bindings': ['urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect']} エラーが発生する

とします。

関数全体は以下の通りです。

def saml_client_for():
    acs_url = url_for(
        'saml_request',
        _external=True)
    
    settings = {
        'entityid': 'flask',
        'metadata': {
            'remote': [
                {'url': 'http://localhost:18080/realms/myrealm/protocol/saml/descriptor'},
            ],
        },
        'service': {
            'sp': {
                'endpoint': {
                    'assertion_consumer_service': [
                        (acs_url, BINDING_HTTP_REDIRECT),
                        (acs_url, BINDING_HTTP_POST),
                    ]
                },
                'allow_unsolicited': True,
                'authn_requests_signed': False,
                'want_assertions_signed': False,
                'want_response_signed': False,

            }
        }
    }

    spConfig = Saml2Config()
    spConfig.load(settings)
    spConfig.allow_unknown_attributes = True
    saml_client = Saml2Client(config=spConfig)
    return saml_client

 

SAML Requestを送信する関数を作成

今回のSAML Request は HTTP Redirect Binding とするため、oktaのサンプルコードとほぼ同じです。

なお、今回は SP-initiated フローでの認証のみ動作確認することから、メソッド名を sp_initiated から saml_request へと変更しています。

def saml_request():
    # SAMLクライアントを生成する
    saml_client = saml_client_for()
    
    # 認証準備をする
    _reqid, info = saml_client.prepare_for_authenticate()

    # HTTP Redirect Binding のリダイレクト先はLocationヘッダに保存されているため、
    # その値を redirect 関数に渡す
    redirect_url = None
    # Select the IdP URL to send the AuthN request to
    for key, value in info['headers']:
        if key == 'Location':
            redirect_url = value
    response = redirect(redirect_url, code=302)
    # NOTE:
    #   I realize I _technically_ don't need to set Cache-Control or Pragma:
    #     http://stackoverflow.com/a/5494469
    #   However, Section 3.2.3.2 of the SAML spec suggests they are set:
    #     http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf
    #   We set those headers here as a 'belt and suspenders' approach,
    #   since enterprise environments don't always conform to RFCs
    response.headers['Cache-Control'] = 'no-cache, no-store'
    response.headers['Pragma'] = 'no-cache'
    return response

 

SAML ResponseをPOSTで受け付ける関数を作成

元々のサンプルコードでは idp_initiated 関数でしたが、 IdP-initiated フロー向けと誤解しそうなので、関数名を saml_response へと変更しました。

また、 authn_response.parse_assertion() をしないと、 get_identity()get_subject() で値が取得できなかったことから、修正を加えています。

他に、セッションの中にSAML入門で確認していた各値を設定し、ブラウザ上で表示できるようにしておきます。

なお、セッションの各値についてはpysaml2のドキュメントでは示されていなかったため、デバッガを使って一つ一つどこにあるかを確認しました。

@app.route('/saml/response/keycloak', methods=['POST'])
def saml_response():
    saml_client = saml_client_for()
    authn_response = saml_client.parse_authn_request_response(
        request.form['SAMLResponse'],
        entity.BINDING_HTTP_POST)

    # parse_assertion()してからでないと、get_identity()やget_subject()で値が取れない
    authn_response.parse_assertion()
    user_info = authn_response.get_subject()

    session['saml_attributes'] = {
        'name_id': user_info.text,
        'name_id_format': user_info.format,
        'name_id_name_qualifier': user_info.name_qualifier,
        'name_id_sp_name_qualifier': user_info.sp_name_qualifier,
        'session_index': authn_response.assertion.authn_statement[0].session_index,
        'session_expiration': authn_response.assertion.authn_statement[0].session_not_on_or_after,
        'message_id': authn_response.response.id,
        'message_issue_instant': authn_response.response.issue_instant,
        'assertion_id': authn_response.assertion.id,
        'assertion_not_on_or_after': authn_response.assertion.issue_instant,
        'relay_status': 'NOT_USED',
        'identity': authn_response.get_identity()
    }

    return redirect('/')

 

SAML Requestを送信するためのリンクやセッションの中身を表示するindexを用意

テンプレートを描画するだけです。

@app.route('/')
def index():
    return render_template('index.html')

 
テンプレートはこんな感じで、セッションの値の有無により表示を分岐しています。

{% if session['saml_attributes'] %}
    {% set s = session['saml_attributes'] %}

    <h1>KeyCloak Status</h1>
    <table>
        <thead>
            <tr>
                <th>Attribute</th>
                <th>Value</th>
            </tr>
        </thead>
        <tbody>
            <tr>
                <td>Name ID</td>
                <td>{{ s['name_id'] }}</td>
            </tr>
            <tr>
                <td>Name ID Format</td>
                <td>{{ s['name_id_format'] }}</td>
            </tr>
            <tr>
                <td>Name ID Name Qualifier</td>
                <td>{{ s['name_id_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Name ID SP Name Qualifier</td>
                <td>{{ s['name_id_sp_name_qualifier'] }}</td>
            </tr>
            <tr>
                <td>Session Index</td>
                <td>{{ s['session_index'] }}</td>
            </tr>
            <tr>
                <td>Session Expiration</td>
                <td>{{ s['session_expiration'] }}</td>
            </tr>
            <tr>
                <td>Message ID</td>
                <td>{{ s['message_id'] }}</td>
            </tr>
            <tr>
                <td>Message Issue Instant</td>
                <td>{{ s['message_issue_instant'] }}</td>
            </tr>
            <tr>
                <td>Assertion ID</td>
                <td>{{ s['assertion_id'] }}</td>
            </tr>
            <tr>
                <td>Assertion NotOnOrAfter</td>
                <td>{{ s['assertion_not_on_or_after'] }}</td>
            </tr>
            <tr>
                <td>Relay Status</td>
                <td>{{ s['relay_status'] }}</td>
            </tr>
            <tr>
                <td>Identity</td>
                <td>{{ s['identity'] }}</td>
            </tr>
        </tbody>
    </table>

{% else %}
    <h1>Login</h1>
    <ul>
      <li><a href="/saml/login/keycloak">KeyCloak</a></li>
    </ul>
{% endif %}

 
以上で、SP側の実装は完了です。

 

Keycloakへ設定を追加

続いて、公式ドキュメントを参考にしつつ、SPの情報をKeycloakへ設定します。
Creating a SAML client | Server Administration Guide

  • Create Client でクライアントを作成
    • Client Typeは SAML
    • Client IDは任意の値
      • 今回は flask
      • ただし、本番運用の場合は重複しないような値のほうが良さそう
    • Valid Redirect URIsは http://localhost:15000/saml/response/keycloak
    • NameID Formatは username
    • Force POST bindingは On
  • clientから flask を選択
  • Keyタブを選択
    • Client signature requiredOff にする
  • Client scopes タブから、 flask-dedicated を選択
    • デフォルトで作成されている
  • Scopeタブを選択
    • Full scope allowed を Off にする
  • Mappersタブを選択
    • Configure a new mapper をクリック
    • Nameで User Attribute をクリック
      • Name, User Attribute, Friendly Name, SAML Attribute Name のいずれも username
      • SAML Attribute NameFormatは Basic (デフォルト)
      • Aggregate attribute valuesは Off (デフォルト)
  • Advancedタブを選択
    • Assertion Consumer Service POST Binding URL に http://localhost:15000/saml/response/keycloak を設定
      • SAML Response の送信先
      • これがないと、Keycloak上で Invalid Request エラーが表示されてしまう

 

動作確認

ここまでで環境構築が完了したので、実際に動作を確認してみます。

http://localhost:15000/ にアクセスすると、Keycloakでログインするためのリンクが表示されます。

 
リンクをクリックすると、Keycloak上のログイン画面が表示されるので、ログインユーザーとパスワードを入力します。

 
ログインに成功するとSPに戻り、SAML Responseの内容が表示されます。

 
SAML-tracerの状態も確認します。

SAML Request の時はこんな感じでした。

 
2回目のSAML Response の場合はこんな感じです。

 
以上で、Python + Flask + pysaml2 でSP、KeycloakでIdPを構築し、SAML2 の SP-initiated フローでSAML認証ができるようになりました。

 

その他の参考資料

ソースコード

Githubにあげました。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example

今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/flask_with_pysaml2_and_keycloak-example/pull/1

Python + playsound の動作環境を整備して、macからmp3ファイルの音を鳴らしてみた

mac + Pythonな環境で、mp3ファイルの音を鳴らしたいことがありました。

調べてみたところ、いくつかパッケージがあるようでした。

 
今回の場合は単に鳴らせればよかったので、一番手軽に扱えそうな playsound を使うことにしました。
TaylorSMarks/playsound: Pure Python, cross platform, single function module with no dependencies for playing sounds.

 
ただ、Githubリポジトリを見たところ、最新バージョン 1.3.0 のリリースが3年くらい前でした。

そこで試してみたところ、最近のPythonバージョンでは動作しないと分かったので、メモを残します。

 
目次

 

環境

  • mac
  • playsound 1.3.0
  • Python 3.10.13
    • Python 3.11系ではインストールできず
  • 追加で必要なパッケージ

 

用意したPythonスクリプト

playsoundのREADMEを参考に、Pythonスクリプトを書きました。

また、Pythonスクリプトと同じ階層に、 mysound.mp3 というmp3ファイルを置きました。

from playsound import playsound


def main():
  playsound('mysound.mp3')

  print('終了します')


if __name__ == "__main__":
  main()

 

動作するPythonバージョンの確認

上記スクリプトが動くかどうか、新しいバージョンから試していきます。

 

Python 3.12系でインストールできない

Python 3.12系へインストールしようとしたところ、エラーになりました。

% python --version
Python 3.12.1

% pip install playsound
Collecting playsound
  Using cached playsound-1.3.0.tar.gz (7.7 kB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... error
  error: subprocess-exited-with-error
  
  × Getting requirements to build wheel did not run successfully.
  │ exit code: 1
  ╰─> [28 lines of output]
      Traceback (most recent call last):
        File "/path/to/project/env/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
          main()
        File "/path/to/project/env/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
          json_out['return_val'] = hook(**hook_input['kwargs'])
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/path/to/project/env/lib/python3.12/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 118, in get_requires_for_build_wheel
          return hook(config_settings)
                 ^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/pip-build-env-qmcer5n9/overlay/lib/python3.12/site-packages/setuptools/build_meta.py", line 325, in get_requires_for_build_wheel
          return self._get_build_requires(config_settings, requirements=['wheel'])
                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        File "/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/pip-build-env-qmcer5n9/overlay/lib/python3.12/site-packages/setuptools/build_meta.py", line 295, in _get_build_requires
          self.run_setup()
        File "/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/pip-build-env-qmcer5n9/overlay/lib/python3.12/site-packages/setuptools/build_meta.py", line 480, in run_setup
          super(_BuildMetaLegacyBackend, self).run_setup(setup_script=setup_script)
        File "/private/var/folders/9j/rhvj56hj4zl6kbkc0zxswj400000gn/T/pip-build-env-qmcer5n9/overlay/lib/python3.12/site-packages/setuptools/build_meta.py", line 311, in run_setup
          exec(code, locals())
        File "<string>", line 6, in <module>
        File "/path/to/.anyenv/envs/pyenv/versions/3.12.1/lib/python3.12/inspect.py", line 1282, in getsource
          lines, lnum = getsourcelines(object)
                        ^^^^^^^^^^^^^^^^^^^^^^
        File "/path/to/.anyenv/envs/pyenv/versions/3.12.1/lib/python3.12/inspect.py", line 1264, in getsourcelines
          lines, lnum = findsource(object)
                        ^^^^^^^^^^^^^^^^^^
        File "/path/to/.anyenv/envs/pyenv/versions/3.12.1/lib/python3.12/inspect.py", line 1093, in findsource
          raise OSError('could not get source code')
      OSError: could not get source code
      [end of output]
  
  note: This error originates from a subprocess, and is likely not a problem with pip.
error: subprocess-exited-with-error

× Getting requirements to build wheel did not run successfully.
│ exit code: 1
╰─> See above for output.

note: This error originates from a subprocess, and is likely not a problem with pip.

 

Python 3.11系でもインストールできない

続いて、Python 3.11系に切り替えてインストールしようとしましたが、同じエラーになりました。

% python --version
Python 3.11.7

% pip install playsound
Collecting playsound
  Using cached playsound-1.3.0.tar.gz (7.7 kB)
  Installing build dependencies ... done
  Getting requirements to build wheel ... error
  error: subprocess-exited-with-error
  
  × Getting requirements to build wheel did not run successfully.
  │ exit code: 1
  ╰─> [28 lines of output]
      Traceback (most recent call last):
        File "/path/to/project/env/lib/python3.11/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 353, in <module>
          main()
        File "/path/to/project/env/lib/python3.11/site-packages/pip/_vendor/pyproject_hooks/_in_process/_in_process.py", line 335, in main
          json_out['return_val'] = hook(**hook_input['kwargs'])
                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
...
        File "/path/to/.anyenv/envs/pyenv/versions/3.11.7/lib/python3.11/inspect.py", line 1081, in findsource
          raise OSError('could not get source code')
      OSError: could not get source code
      [end of output]
  
  note: This error originates from a subprocess, and is likely not a problem with pip.
error: subprocess-exited-with-error

× Getting requirements to build wheel did not run successfully.
│ exit code: 1
╰─> See above for output.

note: This error originates from a subprocess, and is likely not a problem with pip.

 

Python 3.10系ではインストールできたが、PyObjC が必要

続いて、Python3.10系で試してみたところ、インストールはできました。

% python --version
Python 3.10.13

% pip install playsound
Collecting playsound
  Using cached playsound-1.3.0.tar.gz (7.7 kB)
  Preparing metadata (setup.py) ... done
Installing collected packages: playsound
  DEPRECATION: playsound is being installed using the legacy 'setup.py install' method, because it does not have a 'pyproject.toml' and the 'wheel' package is not installed. pip 23.1 will enforce this behaviour change. A possible replacement is to enable the '--use-pep517' option. Discussion can be found at https://github.com/pypa/pip/issues/8559
  Running setup.py install for playsound ... done
Successfully installed playsound-1.3.0

 
ただ、Pythonスクリプトを動かしてみたところ、エラーになりました。

% python main.py
playsound is relying on a python 2 subprocess. Please use `pip3 install PyObjC` if you want playsound to run more efficiently.
Traceback (most recent call last):
  File "/path/to/playsound_pyobj/main.py", line 9, in <module>
    main()
  File "/path/to/playsound_pyobj/main.py", line 5, in main
    playsound('clock_in.mp3')
  File "/path/to/playsound_pyobj/env/lib/python3.10/site-packages/playsound.py", line 244, in <lambda>
    playsound = lambda sound, block = True: _playsoundAnotherPython('/System/Library/Frameworks/Python.framework/Versions/2.7/bin/python', sound, block, macOS = True)
  File "/path/to/playsound_pyobj/env/lib/python3.10/site-packages/playsound.py", line 229, in _playsoundAnotherPython
    t.join()
  File "/path/to/playsound_pyobj/env/lib/python3.10/site-packages/playsound.py", line 218, in join
    raise self.exc
  File "/path/to/playsound_pyobj/env/lib/python3.10/site-packages/playsound.py", line 211, in run
    self.ret = self._target(*self._args, **self._kwargs)
  File "/path/to/playsound_pyobj/env/lib/python3.10/site-packages/playsound.py", line 226, in <lambda>
    t = PropogatingThread(target = lambda: check_call([otherPython, playsoundPath, _handlePathOSX(sound) if macOS else sound]))
  File "/path/to/.anyenv/envs/pyenv/versions/3.10.13/lib/python3.10/subprocess.py", line 364, in check_call
    retcode = call(*popenargs, **kwargs)
  File "/path/to/.anyenv/envs/pyenv/versions/3.10.13/lib/python3.10/subprocess.py", line 345, in call
    with Popen(*popenargs, **kwargs) as p:
  File "/path/to/.anyenv/envs/pyenv/versions/3.10.13/lib/python3.10/subprocess.py", line 971, in __init__
    self._execute_child(args, executable, preexec_fn, close_fds,
  File "/path/to/.anyenv/envs/pyenv/versions/3.10.13/lib/python3.10/subprocess.py", line 1863, in _execute_child
    raise child_exception_type(errno_num, err_msg, err_filename)

 
エラーメッセージの中で、気になった内容は

_playsoundAnotherPython('/System/Library/Frameworks/Python.framework/Versions/2.7/bin/python', sound, block, macOS = True)

playsound is relying on a python 2 subprocess. Please use pip3 install PyObjC if you want playsound to run more efficiently.

でした。

また、Githubのissueにも似たような記載がありました。
https://github.com/TaylorSMarks/playsound/issues/132#issuecomment-1820177142

 
そこで、エラーメッセージに従い、 PyObjC をインストールしてみました。
https://github.com/ronaldoussoren/pyobjc

すると、PyObjCに関するパッケージが大量にインストールされましたが、エラーにはなりませんでした。

% pip install -U PyObjC 
Collecting PyObjC
  Using cached pyobjc-10.1-py3-none-any.whl (4.0 kB)
...
Installing collected packages: pyobjc-core,
...
pyobjc-framework-libxpc-10.1

 
再度、Pythonスクリプトを実行すると、macでmp3の音声が再生できました。