reactPy
PythonやReactを実装されている方は、既に目にしているかもしれませんが、6月になってからでしょうか。reactPyに関する話が増えた気がしています。
まず、どういうものなのか、githubを見て、情報を得ることにしました。
ReactPy is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in ReactJS.
JavaScriptを使わずに(Pythoを使って)、Reactのようなふるまいをするコンポーネントを構築するライブラリ
とのことです。
公式ドキュメントを見てみましたが、まだまだ機能追加中のようです。(工事中のようなアイコンがある機能は開発中)
今後実現できることが増えそうで楽しみではありますが、まずは機能がそれほど多くないこの段階で、一度キャッチアップをしておきたいと思い、簡単なアプリケーションを作成することにしました。
実装準備
どんなものを作るか
React経験者には大変なじみのあるuseState
やuseEffect
を用いたり、ルーティング機能を実装したりすることにしました。
デザインにはtailwindcss
やMaterial UI
が使えるようです。今回はtailwindcss
を導入することにしました。
また、デプロイ先はfly.io
にしました。(既にデプロイ経験があり、作業が進めやすいと思ったため。)
必要なライブラリ
pipコマンドでインストールします。
(Pythonはインストール済の前提です。バージョンはPython 3.11.3
です。)
- reactPy
- reactPy-router
pip install reactPy reactPy-router
create-react-appコマンドはなさそう
公式ドキュメントで確認した限りは見つかりませんでした。
Reactでいうpackage.jsonを自動で作成してくれるわけではないので、自分で用意する必要があります。
今回は、fly.ioにデプロイした関係で、requirements.txt
に最低限必要なライブラリを定義しています。
fastapi==0.96.0
fonttools==4.39.4
httpcore==0.16.3
httplib2==0.22.0
numpy==1.23.5
packaging==23.1
pytest==7.3.2
python-dotenv==1.0.0
reactpy==1.0.0
reactpy-router==0.0.1
uvicorn==0.22.0
websockets==11.0.3
wsproto==1.2.0
requests==2.30.0
requests-oauthlib==1.3.1
typing-inspect==0.8.0
typing_extensions==4.5.0
dataclasses-json==0.5.7
FastAPIをベースに実装
FastAPIを使った実装経験がある関係で選択しました。
公式ドキュメントを見ますとFlaskやSanic?など、何種類か対応しているようです。
FastAPIを用いた実装例があります。今回も基本的な構造はこのファイルの通りです。
実装開始
※作成したアプリのリポジトリは最後に記載してます。
コンポーネント実装
ReactPyというだけあって、Reactで実装している感覚とそれほど変わらない、と思っています。画面にHello, World!
をと表示する場合は以下のように実装します。
(参考ページ)
# 必要なライブラリ定義
from reactpy import component, html
from reactpy.backend.fastapi from configure
from fastapi import FastAPI
@component
def hello_world():
# 画面に表示する内容を戻り値に設定
return html.h1("Hello, World!")
app = FastAPI()
configure(app, HelloWorld)
Routing
FastAPIベースといいましたが、どのPathに対してどのコンポーネントを表示するのか、ライブラリreactpy-router
を使って定義します。
定義したmy_router
コンポーネントをFast APIのconfigureに対して設定して、Routingを実現します。
from fastapi import FastAPI
from uvicorn import run
from reactpy import component, html
from reactpy.backend.fastapi import configure, Options
from reactpy_router import route, simple
from src.config import head
from src.pages import Home, Detail, Data, FilterableList, Error
@component
def my_router():
return simple.router(
route("/", Home()),
route("/data", Data()),
route("/table", FilterableList()),
route("/detail/{names}", Detail()),
route("/er", Error()),
route("*", html.h1("Not Found")) # 404エラー対応
)
# Router情報を設定
configure(
app,
my_router,
Options(head=head.create_head(tailwind_config={})),
)
if __name__ == "__main__":
run(app)
※いずれのRouteにも該当しない場合(404エラー対応)、参考にしていた動画や、こちらでも以下のように実装しています。ですが動作確認してみると、存在しないパスにアクセスした際、画面が真っ白になってしまいます・・・。ここは今後追加確認を行い、修正が必要な箇所です。
route("*", html.h1("Not Found"))
tailwindcssでデザイン適用
htmlでは、headタグ内部にscriptタグを記述して、必要なライブラリを定義します。その要領でtailwindcssを定義します。
今回は純正なhtmlではないので、同じとはいきませんが、CDNを適用したい場合に、以下のように記述します。
今回参考した記事と同じ実装ですが、CDNの適用をReactPyバージョンで実装しています。
return (
html.title("reactPy Sample Site"),
html.link(
{
"href": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"rel": "stylesheet",
}
),
html.script({"src": "https://cdn.tailwindcss.com"}, ""),
)
アプリケーション作成
React経験者には大変なじみのあるuseStateやuseEffectを用いたり、ルーティング機能を実装したりすることにしました。
始めの方に記載した上記内容の詳細です。以下の機能を実装しました。
- qiita APIからデータを取得して表示 + 条件を変更して再取得する機能
- use_stateを用いて、リアルタイム検索
- pathParameterを用いて、別ページ遷移時に、パラメータの値を画面に表示
参考にした記事・動画は以下の通りです。
use_effectを用いてデータ取得
use_stateを用いて、リアルタイム検索
pathParameterを用いて別ページでその情報を表示
フォルダ構成
Reactに近い形にしたいと思って、pagesとcomponentsとconfigにしました。
(componentsフォルダには参考記事のリポジトリのコードも含まれています。)
実装を通じて
全体的な実装の進めやすさ
他の言語と比べれば、Pythonは比較的シンプルに記述できる言語だと思ってます。
Reactの要素が入った場合に、複雑になってしまうのか?実装がほぼ完成した段階で、コードを眺めてみました。個人的な感覚で恐縮ですが、Djangoのように「pythonで処理を実装し、htmlファイルに対して変数の値を埋め込む」といった、ファイルを見比べて実装するよりは、進めやすかったです。
アプリケーションの起動
ここはFastAPIの話になりますが、以下のようにPythonコマンドで起動できます。(view.py
はリポジトリのルートディレクトリにあるファイル)
python view.py
ですが、開発中はホットリロードが助かるので、以下コマンドで起動した方がいいかもしれません。
uvicorn view:app --reload
共通Layoutの作成
reactとほぼ同じでした。使用するライブラリがどれか分かれば、あと実装するだけでした。
ページの中心部分に表示する内容をページごとに設定します。
今回以下画像の黒線の外側(ピンク線の内側)を、共通レイアウトとしました。
from reactpy.core.types import VdomChildren
@component
def Layout(children: VdomChildren):
return html.main(
html.div(
{
"class": "bg-gray-200 min-h-screen dark:bg-gray-900 dark:text-white",
},
html.div(
{
"class": "container mx-auto p-2",
},
html.div(
{"class": "flex justify-center items-center my-8"},
html.img(
{
"src": "https://reactpy.dev/docs/_static/reactpy-logo-landscape.svg",
# "src": "https://avatars.githubusercontent.com/u/106191177?s=200&v=4",
"style": {"width": "35%"},
"alt": "reactPy",
}
),
),
children,
),
),
)
エラーへの対応
特定の画面を表示しようとした場合に、エラーが発生することはあります。そのときは、コンソールにその内容が表示されます。本格的な実装までしてはいないので断定できないですが、Chromeのデベロッパーツールにエラーは表示されず、基本的にはコンソールに表示されていました。
return html._(
html.label(
"Search by Food Name: ",
),
html.input(
{
"class": "my-2 round",
},
{"value": value, "on_change": handle_change}),
)
コンソール
TypeError: 'input' nodes cannot have children.
ブラウザではエラーが発生すると、該当箇所(ピンク枠部分)は表示されない
指示に従って修正します。
return html._(
html.label(
"Search by Food Name: ",
),
html.span(
{
"class": "round",
},
html.input(
{"value": value, "on_change": handle_change}),
)
)
エラーはないが、画面に要素が表示されない
恥ずかしい話ですが、Pythonのインデント階層を間違えました。
テキストフィールドを表示しようと思って実装しましたが、画面に表示されず・・・
間違っていたときの実装は以下の通りです。
正しくはSearch
メソッドに対するreturnに対してhtml要素を設定するのですが、
そのすぐ下にあるhandle_change
メソッドのreturnとしてhtml要素を設定してしまいました。pythonの文法上はエラーではないため、なぜ??と15分くらいは時間を要したところでした。
@component
def Search(value, set_value):
def handle_change(event):
set_value(event["target"]["value"])
return html._(
html.label(
"Search by Food Name: ",
),
html.span(
html.input(
{"value": value, "on_change": handle_change}),
)
)
use_effectを用いたデータ取得
qiita APIからデータを取得する処理を実装しました。
データが取得できる場合・そうでない場合があるので、出力制御を加えるといった処理を加えてみました。(Qiita APIでデータ取得するときに、たまに403エラーが発生することがある・・・)
またデータ取得するだけでは少々面白さに欠けるので、日付とストック数を入力して、動的にデータを取得できる機能を追加しました。
入力項目が設定されているので、バリデーションも合わせて実装しています。
実装を貼り付けました!多少長めかと思うので、折りたたんでいます。
最後にコードのリポジトリも載せていますので、そちらで確認いただく形でも大丈夫です!!
【実装】
from reactpy import html, component, use_state, use_effect, event
from src.components.layout import Layout
from src.components.returnhome import ReturnHomeButton
import requests
from datetime import date, timedelta
@component
def pageTitle():
return html.div(
{
"class": "text-center mb-4",
},
html.label(
{
"class": "text-4xl font-serif text-green-500",
},
"Qiita Titles"
),
)
@component
def TableHeader():
style_table_header_td = ["text-center w-1/3 text-xl font-semibold"]
return html.tr(
html.td(
{"class": style_table_header_td},
"作成日(from)"
),
html.td(
{"class": style_table_header_td},
"作成日(to)"
),
html.td(
{"class": style_table_header_td},
"ストック数"
)
)
@component
def Titles(data, search_content):
# Qiitaのデータ取得結果によって、表示内容制御
if (len(data) > 0):
display_html = html.div(
{
"class": "text-center",
},
html.ul(
[
html.li(
{
"class": "text-xl font-serif my-2",
},
html.span(
{
"class": "text-xl my-2 text-blue-500 hover:underline hover:text-blue-700",
},
html.a(
{"href": f"{i['url']} ", "target": "_blank"},
f"{i['title']}",
),
),
html.span(
f" ( get {i['stocks_count']} stock Count ) "
),
) for i in data
])
)
else:
display_html = html.div(
{
"class": "text-center text-xl text-red-500 mt-8",
},
"Sorry... Fetch No Data Or Happened Error"
)
return html.div(
{
"class": "text-center",
},
html.div(
{
"class": "text-md font-semibold mb-8 text-gray-500",
},
search_content
),
display_html
)
# 画面に表示するDummyデータ
dummy_data_obj = {
"title": "Dummy(Failed Fetch)",
"url": "https://qiita.com/api/v2/docs",
"stocks_count": 10
}
@component
def Data():
# 直近2週間に公開されて、100より多くストックされている記事のデータ取得
init_to_date = date.today()
td = timedelta(days=14)
init_from_date = init_to_date - td
# 画面で管理する値
from_date, set_from_date = use_state(str(init_from_date))
to_date, set_to_date = use_state(str(init_to_date))
stock_count, set_stock_count = use_state(100)
search_content, set_search_content = use_state('')
msg, set_msg = use_state('')
data, set_data = use_state([])
# QiitaのAPIからデータ取得
url, set_url = use_state(
f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={stock_count}')
def get_data():
set_search_content(
f'※{from_date}~{to_date}の作成記事で、ストック数が{stock_count}以上を表示しています')
r = requests.get(url)
if r.status_code == 200:
data = r.json()
set_data(data)
else:
print("Fetch Data Error")
set_data([dummy_data_obj])
# 初回データの読み込み
use_effect(get_data, [])
# available research button
def available_research():
if msg == '':
return False
else:
return True
# validation helper
def check_under_one(value=stock_count):
if not value:
return True
if int(value) < 1:
set_msg("ストック数は1以上を設定してください")
return True
return False
# validation helper
def check_date_order(from_date, to_date):
if from_date >= to_date:
set_msg("作成日(to)は作成日(from)より未来を設定してください")
return True
return False
# validation
def check_allowed_search(value, field):
if field == 'from_date':
if check_date_order(value, to_date):
return False
if check_under_one():
return False
if field == 'to_date':
if check_date_order(from_date, value):
return False
if check_under_one():
return False
if field == 'stock_count':
if check_under_one(value) or check_date_order(from_date, to_date):
return False
set_msg('')
return True
# 作成日(from)更新時の処理
def update_from_date(value):
set_from_date(value)
if check_allowed_search(value, 'from_date'):
set_url(
f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={value}+created:<={to_date}+stocks:>={stock_count}')
# 作成日(to)更新時の処理
def update_to_date(value):
set_to_date(value)
if check_allowed_search(value, 'to_date'):
set_url(
f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={value}+stocks:>={stock_count}')
# ストック数更新時の処理
def update_stock_count(value):
if not value:
# invalid literal for int() with base 10: ''
return
set_stock_count(value)
if check_allowed_search(value, 'stock_count'):
set_url(
f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={value}')
# テーブルの列要素
@component
def TableBody():
style_table_rows = ["px-2 py-2 rounded-md m-2"]
from_date_td = html.td(
html.input(
{
"class": style_table_rows,
"type": "date",
"value": from_date,
"on_change": lambda event:
{
update_from_date(event["target"]["value"])
},
},
),)
to_date_td = html.td(
html.input(
{
"class": style_table_rows,
"type": "date",
"value": to_date,
"on_change": lambda event:
{
update_to_date(event["target"]["value"])
}
},
)
)
stock_count_td = html.td(
html.input(
{
"class": style_table_rows,
"type": "number",
"value": stock_count,
"on_change": lambda event:
{
update_stock_count(event["target"]["value"])
}
},
)
)
return html.tr(from_date_td, to_date_td, stock_count_td)
@component
def Table():
# 検索情報設定
return html.div(
{"class": "flex justify-center"},
html.table(
html.thead(TableHeader()),
html.tbody(TableBody())
),
)
@component
def ValidationMsg():
# バリデーションメッセージ表示
return html.div(
{"class": "text-center font-bold text-red-600 mb-4"},
msg
)
# データ再取得
def re_get_data():
get_data()
@component
def ReGetButton():
# 再検索ボタン
return html.div(
{"class": "flex justify-center mt-2"},
html.button(
{
"class": "font-semibold rounded items-center bg-green-400 disabled:bg-gray-400 disabled:text-gray-600 hover:bg-green-600 px-4 py-2 mb-4",
"disabled": available_research(),
"on_click": lambda event: {re_get_data()}
},
"再検索"
),
)
children = html.div(
ReturnHomeButton(),
pageTitle(),
Table(), # 検索情報設定
ValidationMsg(), # バリデーションメッセージ表示
ReGetButton(), # 再検索ボタン
Titles(data, search_content) # 取得結果表示
)
return Layout(children)
tailwindcssの補完機能
reactでtailwindcssを使って、デザイン適用をされたことがある方はご存じかと思いますが、VsCodeで実装中、classNameの中で、途中まで入力してctrl + space
を押すと、候補が表示されます。
ですが、reactPyでtailwindcssを用いた場合に、それが今のところは機能しませんでした。未経験の状態で導入される場合は、最初は大変かもしれません!
完成
画面レイアウト
デプロイ
冒頭にも記載した通り、fly.ioにデプロイしました。
ターミナルで以下を入力します。(インストール手順はこちらです。)
flyctl launch
Regionを最初に聞かれ、その後Database(Postgresql),UpStash RedisについてSet Upするか質問されます。今回はNo
を選択して進めます。
コマンド実行後に、ファイルが追加されていることが確認できます。(文字色が緑色のファイルが追加されたファイルです)
この後、Procfile
というファイルを編集します。to fit your needs
とあるように、自身の環境に適した形に修正します。
# Modify this Procfile to fit your needs
web: gunicorn server:app
私は以下のように変更しました。
# Modify this Procfile to fit your needs
web: uvicorn view:app --host "0.0.0.0" --port "8080"
※ホストの設定については、fly.ioのログに以下エラーが表示されたため
instance refused connection. is your app listening on 0.0.0.0:8080? make sure it is not only listening on 127.0.0.1
fastapiのサイトを参考に設定しました。
https://fastapi.tiangolo.com/ja/deployment/docker/
以下コマンドを実行してデプロイが開始されます。
flyctl deploy
デプロイ完了後のマネジメントコンソール
ブラウザで確認
fly.ioのMonitoring画面
今後と実装詳細
今回はGETリクエスト中心のサイトとなっています。公式ドキュメントを見ると、今後登場する機能で気になるところはあります。
今後の機能拡張を眺めながら、POSTリクエストを用いた実装・DBとのやりとり、などなど、より大きなものにできればと思います。
作成したサイトのコードです。
更新履歴
更新日付 | 内容 |
---|---|
23/06/15 | Qiita APIのデータ取得:動的に再検索できるよう、日付とストック数の条件追加 |