SyntaxHighlighter

2022年5月5日木曜日

SQLAlchemy を用いた Snowflake へのアクセス

SQLAlchemy を用いた Snowflake へのアクセス

今回は、Snowflake SQLAlchemy ツールキットおよびPythonコネクターの使用にあるような、 SQLAlchemy を用いた Snowflake データベースへのアクセスについてまとめようと思います。

また、 Snowflake の JSON データへアクセスするのを容易にするために開発したライブラリである snowflake-sqlalchemy-json についても触れようと思います。

Snowflake と半構造化データ

Snowflake は、いわゆるクラウドでビッグデータを処理するためのデータプラットフォームです。
細かく最適化を考えなくても、それなりによいパフォーマンスでデータの保存/読み出しができ、 SQL でのアクセスもサポートされています。

2022年5月時点では、30日の無料トライアルを受け付けており、どんな雰囲気かは無料で試すことができます。

Snowflake は、一般的なデータ型をサポートしており、他のデータベースを使っていた人であれば、違和感なく使えるように思います。
なお、ビッグデータ向けのデータベースでよくあるように、 Unique 制約などは強制されないので、そこには注意が必要です。

少し特徴的な型として、半構造化データ型というものがあります。
これは、 VARIANT などの型をカラムに指定することで、カラム内に JSON, Avro などの半構造化データを入れておき、 SQL から直接アクセスできるようにするものです。

例えば SELECT column_name:key1 FROM table; のように記述することで、 table テーブルの column_name カラムにある {"key1": "value1", "key2": "value2"} の中の "value1" にアクセスすることができます。
WHERE の条件部分で指定することも可能で、通常のカラムと同様にアクセスすることができます。

SQLAlchemy による SQL の生成

話は変わりますが、 Python のライブラリで SQLAlchemy というものがあります。
このライブラリは ORM として使うこともできますが、生の SQL をラップし Python から扱いやすくするという、 core と呼ばれる使い方もあります。

これは、以下のように Python のコードとして SQL を組み立てられるものです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from sqlalchemy import Table, Column, Integer, String, MetaData
from sqlalchemy import select
 
metadata_obj = MetaData()
User = Table('users', metadata_obj,
    Column('id', Integer, primary_key=True),
    Column('name', String),
)
 
query = select(
    User.c.id,
    User.c.name,
).select_from(
    User,
).where(
    User.c.name.startswith("A"),
).order_by(
    User.c.name.desc(),
).limit(10)

SQLite の場合、上記の query は、以下のような SQL に変換されます。

1
2
3
4
SELECT users.id, users.name
FROM users
WHERE (users.name LIKE 'A' || '%') ORDER BY users.name DESC
 LIMIT 10 OFFSET 0

このように SQLAlchemy を用いると、 Python のコードとして SQL を作ることができます。

他にも JOIN や CTE (Common Table Expression) を用いた例を書いておくと以下のようになります。
この例では、テーブルの定義に declarative_base を使っています。この場合は、 Table を使う場合と異なり、 .c の部分が不要になり、補完も効きやすくなるので、書きやすいかもしれません。
なお、例自体には特に意味はありません。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy import select, or_
from sqlalchemy.ext.declarative import declarative_base
 
Base = declarative_base()
 
 
class User(Base):
    __tablename__ = "users"
 
    id = Column(Integer, primary_key=True)
    name = Column(String)
 
 
class Book(Base):
    __tablename__ = "books"
 
    id = Column(Integer, primary_key=True)
    title = Column(String)
    author_id = Column(None, ForeignKey('users.id'))
 
 
books_with_title_a = select(
    Book.title,
    Book.author_id,
).select_from(
    Book,
).where(
    or_(
        Book.title.startswith("A"),
        Book.title.startswith("a"),
    ),
).cte()
 
query = select(
    books_with_title_a.c.title,
    User.name,
).select_from(
    books_with_title_a,
).join(
    User,
    User.id == books_with_title_a.c.author_id,
).order_by(
    books_with_title_a.c.title,
)

Python で記述することで、 CTE 部分を変数として保持でき、それなりに分かりやすく記述できているように思います。

上記の query からは、下記の SQL が生成されます。

1
2
3
4
5
6
WITH anon_1 AS
(SELECT books.title AS title, books.author_id AS author_id
FROM books
WHERE (books.title LIKE 'A' || '%') OR (books.title LIKE 'a' || '%'))
 SELECT anon_1.title, users.name
FROM anon_1 JOIN users ON users.id = anon_1.author_id ORDER BY anon_1.title

snowflake-sqlalchemy

Snowflake でも、上述した SQLAlchemy を使うことができます。

公式のページで使い方がまとめられているので、こちらのページを参考にするのがよいと思います。

なお、 Snowflake の場合、テーブルを指定する際には DATABASE.SCHEMA.TABLE のように、データベース名とスキーマ名でテーブル名を修飾することで、一意にテーブルを指定することができます。
SQLAlchemy で、このようにデータベース名やスキーマ名も含めて修飾したテーブル名を指定する場合、以下のように quoted_name を用いてテーブル名を記述することで、実現できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
from sqlalchemy import Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.elements import quoted_name
 
Base = declarative_base()
 
 
class User(Base):
    # 下記のように、 quoted_name でテーブル名を指定する
    __tablename__ = quoted_name("database.schema.users", False)
 
    id = Column(Integer, primary_key=True)
    name = Column(String)

snowflake-sqlalchemy-json

上述したように、 Snowflake では VARIANT 型のカラムに対しては、 JSON 形式などの値に対して SQL 内から所定の書式でアクセスすることができます。

snowflake-sqlalchemy では、その辺りの処理は 2022年5月時点では実装されていないようだったので、 snowflake-sqlalchemy-json でいくつかを実装してみました。

現時点では読み込みのみしかサポートしておらず、機能も限定的ですが、それなりに使えそうに思うので、このライブラリを使った例を挙げておきます。
ここでは、 Snowflake のアカウント作成時に例として提供されていた SNOWFLAKE_SAMPLE_DATA.WEATHER.DAILY_14_TOTAL テーブルに含まれているデータを読み込む際の例について挙げます。データのサンプルは、末尾に挙げておきます。

VARIANT 型を SQLAlchemy の JSON にマッピングしアクセス

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from sqlalchemy import create_engine, select, Column, DateTime, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.elements import quoted_name
import snowflake_sqlalchemy_json
 
snowflake_sqlalchemy_json.register_json_handler()
 
Base = declarative_base()
 
 
class Daily14Total(Base):
    __tablename__ = quoted_name("SNOWFLAKE_SAMPLE_DATA.WEATHER.DAILY_14_TOTAL",
                                False)
 
    T = Column(DateTime, primary_key=True)
    V = Column(JSON)
 
 
query = select(
    Daily14Total.V["city"]["id"],
    Daily14Total.V["city"]["name"],
).select_from(
    Daily14Total,
).where(
    Daily14Total.V["city"]["country"] == "IN"
).limit(10)

上記の query で、 V カラムに入っているデータの中の city キーで取得できるオブジェクト内の要素にアクセスすることができます。
基本的に、 Python の dict と同様のアクセス方法になり、直感的に書けているのが分かると思います。

lateralflatten を用いたアクセス

Snowflake の VARIANT 型へのアクセスで重要な概念である lateral 結合を用いたアクセスについては、下記のように記述できます。
なお、 lateral 結合については結局 LATERAL とは何者なのか?の記事が分かりやすいように思います。

flatten は、あくまで通常の関数なので、 SQLAlchemy で関数を呼び出す際の形式である func.flatten(xxx) として記載します。
また、 lateral は SQLAlchemy がサポートしているので、公式のチュートリアルの通りに呼び出します。
lateral() で返されるテーブルの value カラムにアクセスすると、 flatten で展開された要素にアクセスすることができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from sqlalchemy import and_, create_engine, func, select, Column, DateTime, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.elements import quoted_name
import snowflake_sqlalchemy_json
 
snowflake_sqlalchemy_json.register_json_handler()
 
Base = declarative_base()
 
 
class Daily14Total(Base):
    __tablename__ = quoted_name("SNOWFLAKE_SAMPLE_DATA.WEATHER.DAILY_14_TOTAL",
                                False)
 
    T = Column(DateTime, primary_key=True)
    V = Column(JSON)
 
 
lateral_flatten = func.flatten(Daily14Total.V["data"]).lateral()
query = select(
    Daily14Total.T,
    lateral_flatten.c.value["clouds"],
    lateral_flatten.c.value["deg"],
    lateral_flatten.c.value["rain"],
).select_from(
    Daily14Total,
    lateral_flatten,
).where(
    and_(
        lateral_flatten.c.value["deg"] > 273,
        lateral_flatten.c.value["rain"] > 0,
    ),
).limit(10)

なお、上記は FROM 句に FROM DAILY_14_TOTAL, LATERAL FLATTEN(xxx) のように lateral を記述する方法ですが、この書き方では Warning が出るかもしれません。
その場合、以下のように、 JOIN 句として独立させて書くこともできます。
私はこちらの方が分かりやすいように思います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from sqlalchemy import and_, create_engine, func, select, Column, DateTime, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql.elements import quoted_name
import snowflake_sqlalchemy_json
 
snowflake_sqlalchemy_json.register_json_handler()
 
Base = declarative_base()
 
 
class Daily14Total(Base):
    __tablename__ = quoted_name("SNOWFLAKE_SAMPLE_DATA.WEATHER.DAILY_14_TOTAL",
                                False)
 
    T = Column(DateTime, primary_key=True)
    V = Column(JSON)
 
 
lateral_flatten = func.flatten(Daily14Total.V["data"]).lateral()
query = select(
    Daily14Total.T,
    lateral_flatten.c.value["clouds"],
    lateral_flatten.c.value["deg"],
    lateral_flatten.c.value["rain"],
).select_from(Daily14Total).join(
    lateral_flatten,
    True,
).where(
    and_(
        lateral_flatten.c.value["deg"] > 273,
        lateral_flatten.c.value["rain"] > 0,
    ),
).limit(10)

用いたデータの形式

最後に、 DAILY_14_TOTAL テーブルの V カラムに入っている JSON データの形式を挙げておきます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
{
    "city": {
        "coord": {
            "lat": 9.7,
            "lon": 78.48333
        },
        "country": "IN",
        "id": 1263965,
        "name": "Manamadurai"
    },
    "data": [{
        "clouds": 80,
        "deg": 277,
        "dt": 1500357600,
        "humidity": 47,
        "pressure": 980.94,
        "speed": 2.13,
        "temp": {
            "day": 303.15,
            "eve": 303.15,
            "max": 303.15,
            "min": 300.13,
            "morn": 303.15,
            "night": 300.13
        },
        "uvi": 12.892,
        "weather": [{
            "description": "broken clouds",
            "icon": "04n",
            "id": 803,
            "main": "Clouds"
        }]
    }, {
        "clouds": 80,
        "deg": 234,
        "dt": 1500444000,
        "humidity": 57,
        "pressure": 982.09,
        "rain": 0.85,
        "speed": 2.9,
        "temp": {
            "day": 302.81,
            "eve": 304.33,
            "max": 304.82,
            "min": 296.22,
            "morn": 296.22,
            "night": 298.93
        },
        "uvi": 12.679,
        "weather": [{
            "description": "light rain",
            "icon": "10d",
            "id": 500,
            "main": "Rain"
        }]
    }, {
        "clouds": 76,
        "deg": 243,
        "dt": 1500530400,
        "humidity": 61,
        "pressure": 980.75,
        "rain": 3,
        "speed": 1.71,
        "temp": {
            "day": 303.18,
            "eve": 304.86,
            "max": 304.86,
            "min": 297.51,
            "morn": 298.36,
            "night": 297.51
        },
        "uvi": 12.986,
        "weather": [{
            "description": "light rain",
            "icon": "10d",
            "id": 500,
            "main": "Rain"
        }]
    }],
    "time":
    1500396663
}

以上のようにすることで、 Snowflake の VARIANT 型へのアクセスを SQLAlchemy を用いて記載することができるようになります。

2022年3月7日月曜日

Azure DevOps 向けの拡張機能 PublishMarkdownReports

Azure DevOps 向けの拡張機能 PublishMarkdownReports

Microsoft の統合開発環境である Azure DevOps 向けに、拡張機能 Publish Markdown Reports を作成したので、その紹介です。

拡張機能自体の作り方は、 Web 拡張機能の開発のページに詳しい方法が載っているので、こちらを参照してみてください。

やりたかったこと

ソースコードから自動生成した Markdown の API 仕様書を、 Azure DevOps の CI/CD である Azure Pipelines のページで閲覧できるようにしたい、というのがやりたかったことでした。
Azure Pipeline から Azure DevOps Wiki を更新する方法もあるのですが、こちらの記事のように Wiki 用の Git Repository を自動で更新する方法では、「同じぐらいのタイミングで CI/CD が走ったとき」や「CI/CD タスクが Re-queue され、古いコミットに対するタスクが後で実行されたとき」に、古い内容で上書きしてしまわないような仕組みが必要になります。

今回は、そういったことを考慮しなくてすむように、 Publish Markdown Reports では Azure Pipelines のビルド結果の画面に任意の Markdown ドキュメントを表示できるようにすることにしました。
このようにすることで、別の Wiki 用の Git Repository は不要になり、 CI/CD タスクの実行順序などを考えなくてもよくなります。
ただし、ビルド結果は設定によっては古い結果が削除されるので、正式なドキュメントとして残す場合は別途対応が必要です。

完成した表示画面は下記になります。
screenshot

Publish Markdown Reports について

まずは、 Publish Markdown Reports の概要について説明しようと思います。
なお、ソースコード全体は GitHub に置いてあります。

表示用 Markdown ファイルのアップロード

この拡張機能では、 Markdown として表示するファイルをビルドタスクの中でアップロードします。
下記のような YAML の記述を Azure Pipelines に加えることで、指定されたディレクトリのファイルをアップロードします。

1
2
3
4
5
- task: PublishMarkdownReports@1
  inputs:
    contentPath: $(Build.SourcesDirectory)/markdown
    indexFile: main_page.md
    headingId: doxybook2

上記の場合、ソースコードのディレクトリの下の markdown ディレクトリの下にあるファイルすべてをアップロードします。
また、既定のページとして main_page.md を指定しています。このファイルは contentPath で指定したディレクトリからの相対パスで指定します。
headingId は、 Markdown の # 見出し のような章のタイトル (<h1> などのタグで囲まれた部分) に対して、どのような ID を付与するかを指定します。
現時点では完全ではないのですが、 Python の Markdown の HTML 変換ライブラリである Python-Markdown や、 Doxygen の結果を Markdown に変換する Doxybook2 の ID のつけ方に対応しています。
なお、 Pydoc-Markdown などのツールでは、 <a> タグでリンク先の場所が定義されているので、 headingId の指定は不要です。

内部的には、これらのファイルはビルド中に Attachment として、ビルド結果に対する添付ファイルの形でアップロードされています。
このファイルは API でしか取得できないもので、ビルド生成物として登録する Artifact とは異なるものです。

Markdown ファイルの表示

この拡張機能では、アップロードされた Markdown ファイルを Marked で HTML 変換して表示しています。

ただし、 Azure DevOps の拡張機能の仕組み上、少し面倒ですが、下記のように表示を実現しています。

  1. 拡張機能に含まれる frame.html をビルド結果のタブ内に表示
  2. frame.html から Azure DevOps の SDK を用いて Attachment として保存されている Markdown を JavaScript で取得
  3. 取得した Markdown ファイルを Marked ライブラリを用いて HTML に変換
    その際に、リンクを処理する際には、 frame.html?page=/link-to-page.md のように、 frame.html に対して page パラメータとして Markdown のファイルのパスを指定するようにします。
    また、画像を表示するために、画像に対するファイル名に対して Attachment を取得し、得られたデータを Blob 変換した上で、 URL.createObjectURL で生成した URL に差し替えます。
  4. 生成された HTML をタブに表示

高速化

上述した方法では、表示されるまでに少しタイムラグが発生します。これは、 Azure DevOps の SDK の性質上、ロード時の初期化に 1~2 秒かかることが多いためです。
これを解消するために、少し高速化を行います。

上述した frame.html をラップする main.html を用意し、 frame.htmlmain.html の中で iframe として表示するようにします。

その上で、それぞれのファイルで以下のような処理を行うことで、 Azure DevOps の SDK の初期化処理がページ遷移のたびに動作しないようにしています。

main.html
  • Azure DevOps の SDK である VSS.SDK.js をロードし、初期化する。
  • onmessage ハンドラで frame.html からのメッセージを待ち受け、メッセージに応じて Attachment をダウンロードし、 frame.html に送る。
frame.html
  • 表示すべき Markdown ファイルを main.html に要望する。
  • main.html から送り返されたデータを処理し、 Marked ライブラリで HTML に変換する。

このようにすることで、画面が切り替わる際は frame.html だけがロードされ、 main.html 側は破棄されないため、 VSS.SDK.js による初期化処理は動作しないことになります。
そのため、 Markdown ファイルの切り替えによる画面遷移時には、 Markdown ファイルや画像のロードのみで動作が完結し、高速な動作が可能となります。

このようにして、それなりに実用に耐えうる拡張機能ができました。

2022年2月7日月曜日

Bash 向けの tcsh 同様な history-search

Bash 向けの tcsh 同様な history-search

Bash のシェルスクリプトの勉強がてら、 tcsh 同様な history-search-backward のようなものを実装してみました。

Bash の history-search-backward

Bash には、元々 history-search-backward というコマンドが用意されています。
しかし、このコマンドは tcsh のコマンドとは異なり、カーソル位置が行の末尾に移動しません。
私は Solaris の tcsh で Unix 系の環境に初めて触れたこともあり、 tcsh のようにカーソルが末尾に移動するという挙動の方が馴染みがあるので、そのようなコマンドを作ってみようと思いました。

なお、あくまで私が Bash のシェルスクリプトの練習をするというのも重要な目的の一つなので、例えば zsh をなど他の解は、今回は気にしていません。

ポイント

今回は、 Bash のシェルスクリプトの勉強を兼ねていたので、以下を守ることにしました。

  • Bash 以外での動作は気にしない。
  • Bash の組み込みコマンド以外は使わない。
  • Windows 版の Git に含まれている Bash でも動作させる。

コードの概要

コードは GitHub に置いてあります。

おおまかな流れとしては、以下のようになっています。

  1. fc コマンドでコマンド履歴の一覧を取ってくる。
  2. 一行ずつ read コマンドで読み込む。
  3. 特定の文字列で始まるコマンドを抜き出し、それを READLINE_LINE, READLINE_POINT に設定することで、ターミナルの文字列を置換する。

coproc の使い方

fc コマンドを実行し、その結果を read コマンドで処理する際に、 coproc コマンドを使うことにしました。
Bash の while でパイプから読み込んだテキストを一行ずつ処理をする場合、 Qiita の記事にあるように、 Process Substitution などを使えばよいようですが、 Windows 版の Git に含まれている Bash などでは実行することができません。
そのため、今回はこの方法は諦め、 coproc を使って読み込むことにしました。

概要

coproc に関連する部分をシンプルにして抜き出すと以下のようになっています。

1
2
3
4
5
6
7
{ coproc FC_FD { fc -lnr ; exec 1>&- ; read -s ; } ; } 2>/dev/null
disown
declare -a HISTORY_ARRAY=()
while read -r -u ${FC_FD[0]} LINE; do
    HISTORY_ARRAY+=( "$LINE" )
done
echo END >&${FC_FD[1]}

coprocfc を実行するプロセスを作り、その出力を whileread で一行ずつ処理しています。

ちなみに、よく言われるように下記では while 内がサブシェルで実行されるので、 HISTORY_ARRAY に期待した通りに値が保存されません。

1
2
3
4
5
6
7
# 下記ではうまく動作しない
declare -a HISTORY_ARRAY=()
fc -lnr | while read -r LINE; do
    HISTORY_ARRAY+=( "$LINE" )
done
echo ${HISTORY_ARRAY[@]}
# => 何も保存されていない。

また、上述したように、下記のように Process Substitution を使う方法では、私の手元の Windows の bash.exe では動作しませんでした。

1
2
3
4
5
6
7
# 下記では Windows の bash.exe では動作しなかった。
declare -a HISTORY_ARRAY=()
while read -r LINE; do
    HISTORY_ARRAY+=( "$LINE" )
done < <(fc -lnr)
# 以下が表示
#  bash: /dev/fd/XX: No such file or directory

ジョブ情報の出力の抑制

そのまま使ってしまうと、[1] 1859[1]+ Done のような、ジョブの情報が出力されてしまうので、それらを抑制する必要がありました。

開始時は以下のようにします。
coproc の標準エラー出力を捨てればよいのですが、波括弧で囲ってリダイレクトすると指定しやすいです。

1
{ coproc FC_FD { fc -lnr -${HISTSIZE} ; } ; } 2>/dev/null

終了時の出力は、 coproc 呼び出し後に以下の disown を呼んでおくことで、抑制することができます。

1
2
{ coproc FC_FD { fc -lnr -${HISTSIZE} ; } ; } 2>/dev/null
disown

coproc からの読み込み

coproc で返されるファイルディスクリプタから read コマンドでテキストを読み込むには、 -u オプションを使うことで実現できます。
この辺りは Web 上で情報がたくさん見つかるので省略します。

一方で、ただ coproc FC_FD { fc -lnr -${HISTSIZE} ; } として実行した coproc を read -u ${FC_FD[0]} のように読むだけでは、場合によってはうまくいきません。

Stack Overflow に書かれているように、 Bash の coproc では、 coproc で実行したプロセスが先に完了してしまうと、プロセスからの出力をすべて処理する前に出力が読めなくなります。

そのため、 coproc のプロセスの出力を読み終わるまで、プロセスが終了しないように工夫する必要があります。
また、 coproc のプロセスの出力をバッファリングせずに出力しきるために、以下のようにします。

1
2
3
4
{ coproc FC_FD { fc -lnr -${HISTSIZE} ; exec 1>&- ; read -s ; } ; } 2>/dev/null
disown
# ここで fc の出力の処理を行う
echo END >&${FC_FD[1]}  # read -s に文字列を送る

このように、 coproc の中で read -s で入力を待ち、 exec 1>&- で出力のファイルディスクリプタを閉じることで出力を確実に処理させ、 coproc での出力をすべて処理できるようにします。

というような感じで、 Bash の組み込みコマンドのみで tcsh のような history-search-backward を実装することができました。
私はこれまでにほとんどシェルスクリプトを書く機会がなかったのですが、 Bash 4 以降は coproc や連想配列もあり、それなりにいろいろなことができるようです。

2022年1月28日金曜日

API による Microsoft Teams のステータスメッセージの取得/設定

API による Microsoft Teams のステータスメッセージの取得/設定

Microsoft Teams では、ステータスメッセージを設定することができます。

今回は、これをスクリプト経由で設定する方法について記載しようと思います。

Graph API でできる範囲

2022年1月現在、 Teams の Microsoft Graph REST API では、 ユーザーのプレゼンス情報の取得/設定ができます。
この API では、 availability, activity の取得/設定がサポートされており、 Python スクリプトなどで自由に設定することができます。

しかし、 beta 版も含め、ステータスメッセージの取得/設定は現状サポートされていないようです。

Teams アプリ、Teams Web アプリと同様の方法でのアクセス

そこで、公開されていない API を用いてアクセスすることになります。

結論としては、下記の情報で OAuth2 のアクセストークンを取得し、後述する URL に POST, PUT することで、ステータスメッセージの取得/設定することができます。
これは、原則として、 Teams アプリや Teams Web アプリが行っている方法と同様と思われます。

OAuth2 情報

OAuth2 のアクセストークンは、下記の情報で取得を行います。

Client ID
1fec8e78-bce4-4aaf-ab1b-5451cc387264
Client Secret
Public Client なので不要
Redirect URI
https://login.microsoftonline.com/common/oauth2/nativeclient
Scope
https://api.spaces.skype.com//.default

なお、上記の Client ID は、 Teams のデスクトップアプリの ID になっています。
また、 Client Secret, Redirect URI は、いずれも Public Client の設定になっています。
Scope の .default の前にスラッシュが二つありますが、 Microsoft のページによると、これで正しいようです。
(ただ、スラッシュが一つでも問題なく動いてはいるようでした。)

ステータスメッセージの取得

ステータスメッセージの取得は、下記の URL に JSON データを POST することで実現できます。

送信するデータの中の "mri" に設定している GUID は、 Graph API で取得できるユーザー情報の中の id のことです。
8:orgidPublicDirectoryParticipantPrefix と呼ばれるもののようで、固定値でよいようです。

URL
https://presence.teams.microsoft.com/v1/presence/getpresence/
HTTP Method
POST
Body として送信する JSON データ
1
2
3
4
[
    {"mri": "8:orgid:01234567-89ab-cdef-0123-456789abcdef"},
    {"mri": "8:orgid:76543210-89ab-cdef-0123-456789abcdef"}
]

上記を送ると、指定したユーザー分のステータスメッセージを含むユーザー情報が返ってきます。

ステータスメッセージの設定

自分のステータスメッセージの設定は、下記の URL に JSON データを PUT することで実現できます。

メッセージと有効期限を指定し、設定することができます。

URL
https://presence.teams.microsoft.com/v1/me/publishnote
HTTP Method
PUT
Body として送信する JSON データ
1
{"message":"Status Message","expiry":"2022-01-28T14:59:59.999Z"}

なお、 "message" の末尾に <pinnednote></pinnednote> を追加すると、「他のユーザーが自分にメッセージを送るときに表示する」にチェックした状態になるようです。

Python での実装例

最後に、 Python を用いてステータスメッセージを設定する例を書いておこうと思います。

Windows 上で簡易的に OAuth2 を扱えるようにした msal-interactive-token-acquirer と、 HTTP アクセスで広く使われている requests を用いた場合、以下のようになります。

なお、これは個人の Microsoft アカウントではなく、組織の Microsoft アカウントで行う必要があります。
個人で試す場合は、 Microsoft 365 開発者プログラムなどを活用するとよいと思います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from datetime import date
from msal_interactive_token_acquirer import MsalInteractiveTokenAcquirer
from msal_interactive_token_acquirer.requests import MsalSession
 
tenant = "common"
client_id = "1fec8e78-bce4-4aaf-ab1b-5451cc387264"
msal = MsalInteractiveTokenAcquirer(tenant, client_id, scopes)
msal.acquire_token_interactively()
session = MsalSession(msal)
expiry = date.today().strftime("%Y-%m-%dT14:59:59.999Z"# This means YYYY-MM-DDT23:59:59.999+09:00
message = "Sample Message"
# message += "<pinnednote></pinnednote>"  # 「他のユーザーが自分にメッセージを送るときに表示する」にチェック
data = {"message": message, "expiry": expiry}
res = session.put(url, json=data)
print(res)