Sharepoint上でPython製プログラムを動かすためのWebテンプレート

Sharepoint上にReactフロントエンドつきのPythonツールを展開できるWebテンプレートを作成しました。実際には、Amazon S3のような静的Webホスティングサービスを含む任意のサーバ上で使用できますので、ぜひお安いサーバを借りてご使用ください。
https://github.com/narumichiaki/react-pyscript-template

特別なサーバを用意することなく、プログラミングに詳しくないユーザにもPythonツールを展開できます。コンパイルの必要もありません。企業内などでのPythonツール展開の方法にお困りの方、ぜひどうぞ。

## 概要
Reactをフロントエンド、PyScriptをバックエンドとして採用しています。
- フロントエンドは`renderer.tsx`に記述します。React + TypeScriptで書くことができます。
    - Babel Standaloneでtsxを読み込んで実行しています。したがって、事前にコンパイルする必要はありません。
- バックエンドは`backend.py`に記述します。Pythonで書くことができます。
    - PyScriptはWebAssembly上で動くPython実行環境であり、ユーザのブラウザ上でPythonを実行できるようにします。つまり、ここに書いたPython製プログラムはユーザのブラウザ上で実行されます。
- つなぎの部分が`main.tsx`に記述されていますが、特に触る必要はありません。
    - 本Webテンプレートでは、PyScript製バックエンドをReact製フロントエンドからAPIで(`callAPI`関数で)呼び出せるように繋いであります。

## 本テンプレートのメリット・デメリット
### メリット
- データ分析ライブラリが豊富なPythonをバックエンドとして使える。
    - Pythonは使える人が多いので引き継ぎがしやすい。
- プログラミング環境・スキルがない人にもPython製ツールを提供できる。
- バージョンをツール作成側でコントロールできる。旧バージョンが各ユーザの手元に残らない。
- ユーザによる意図しない改変を避けられる。
- セキュリティへの配慮がそれほど必要ない（すべてユーザのブラウザ上で実行されるため）
- Sharepoint環境のみ： Sharepoint上に設置して動作させられる(後述)。

### デメリット
- サーバ側でのデータ保持はできない。
- 秘匿性の高いデータ(API Keyなど)がユーザから隠せない。
- ユーザ側のPCに計算負荷がかかる。

## Sharepoint上への設置
本テンプレートは、[カスタムスクリプトが有効化されている](https://learn.microsoft.com/ja-jp/sharepoint/allow-or-prevent-custom-script)Sharepoint上に設置することができます。`index.html`の拡張子を`.aspx`に変更してアップロードしてください。

<font color="red">2024年11月中旬までに行われたSharepointのセキュリティ強化により、カスタムスクリプトの作成（aspxのアップロードを含む）は、管理者の事前許可を要するようになりました。</font>本テンプレートのアップロードにも管理者の事前許可が必要です。ただし、本テンプレートでは機能の変更に際して`index.aspx`を変更する必要がないので、一度アップロードしてしまえば、その後の更新は好きなように行うことができます。

## コード例
以下のコード例はVersion 1.3.0に基づきます。

<details><summary><b>render.tsx</b></summary><div>

```tsx
import React, { JSX, StrictMode, useState } from "https://esm.sh/react@17.0"
import ReactDOM from "https://esm.sh/react-dom@17.0"
import { z } from "https://esm.sh/zod@3.23.8"

// バックエンドAPI
type CallApiFunction = (
  path: string,
  json_param: Record<string, any>,
  response_schema?: z.ZodType<any, any, any>
) => Promise<Record<string, any>>
let callAPI: CallApiFunction

// API Responce Validators (バグ予防のためにAPIから受け取る値をチェックする)
// for "/log"
const LogMessageSchema = z.object({
  message: z.string()
})


function App(): JSX.Element {
  const [count, setCount] = useState<number>(0)

  const handleClick = React.useCallback(() => {
    setCount((prevCount) => {
      const newCount = prevCount + 1
      // レスポンスのバリデーションあり版
      callAPI("/log", { message: newCount }, LogMessageSchema).then(response => {
        console.info(`Response: ${response.message}`)
      }).catch(error => {
        console.error("ログの記録に失敗しました。", {error})
      })
      // レスポンスのバリデーションなし版
      callAPI("/log_unsafe", { message: newCount }).then(response => {
        console.info(`Response: ${response.message}`)
      }).catch(error => {
        console.error("ログの記録に失敗しました。", {error})
      })
      return newCount
    })
  }, [])

  return (
    <div>
      <div>Count: {count}</div>
      <button onClick={handleClick}>Increment</button>
    </div>
  )
}


function render(call_api: CallApiFunction) {
  callAPI = call_api
  ReactDOM.render(
    <StrictMode>
      <App />
    </StrictMode>,
    document.getElementById('root') as HTMLElement
  )
}

// renderをwindowに登録(必須)
(window as any).render = render
```
</div></details>


<details><summary><b>backend.py</b></summary><div>

```py
from typing import Any
import sys
import logging
import js
from pydantic import BaseModel, field_validator
from backend_helper import APP, Response, api_endpoint

logging.basicConfig(stream = sys.stdout)
LOGGER: logging.Logger = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG)


### MODEL ###



### CONTROLLER ###



### API ###
# サンプル： パラメータのバリデーションあり
class Message(BaseModel):
    message: str
    @field_validator('message', mode = 'before')
    @classmethod
    def convert_to_string(cls, v):
        return str(v)
@APP.route("/log", Message)
def log(message: Message):
    response_message: str = f"/log: Recieved log message: {message.message}"
    LOGGER.info(response_message)
    return Response.ok({"message": response_message})

# サンプル： パラメータのバリデーションなし
@APP.route("/log_unsafe")
def log_unsafe(message: dict[str, str]):
    response_message: str = f"/log_unsafe: Recieved log message: {message["message"]}"
    LOGGER.info(response_message)
    return Response.ok({"message": response_message})


# APIエンドポイントをwindowに登録(必須)
js.window.api_endpoint = api_endpoint
```
</div></details>

## ライセンス
Apache License 2.0で公開しますので、ライセンスの範囲内でご自由にお使いください。使ったら報告いただけると、私がたいへん喜びます。

## 参考資料
- https://qiita.com/murasuke/items/e0600497a8900ca8401b