Coverage for src / app.py: 61%
83 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-13 00:10 +0900
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-13 00:10 +0900
1#!/usr/bin/env python3
2"""
3電動シャッターの開閉を自動化するアプリのサーバーです
5Usage:
6 app.py [-c CONFIG] [-p PORT] [-d] [-D]
8Options:
9 -c CONFIG : CONFIG を設定ファイルとして読み込んで実行します。[default: config.yaml]
10 -p PORT : WEB サーバのポートを指定します。[default: 5000]
11 -d : ダミーモードで実行します。CI テストで利用することを想定しています。
12 -D : デバッグモードで動作します。
13"""
15import atexit
16import contextlib
17import logging
18import os
19import signal
20import sys
22import flask
23import flask_cors
24import my_lib.proc_util
25import my_lib.webapp.base
26import my_lib.webapp.config
27import my_lib.webapp.event
28import my_lib.webapp.log
29import my_lib.webapp.util
31import rasp_shutter.config
33SCHEMA_CONFIG = "config.schema"
36def term() -> None:
37 import rasp_shutter.control.scheduler
38 import rasp_shutter.control.webapi.schedule
40 rasp_shutter.control.scheduler.term()
42 # スケジュールワーカーの終了を待機(最大10秒)
43 try:
44 schedule_worker = rasp_shutter.control.webapi.schedule.get_worker_thread()
45 if schedule_worker:
46 logging.info("Waiting for schedule worker to finish...")
47 schedule_worker.join(timeout=10)
48 if schedule_worker.is_alive():
49 logging.warning("Schedule worker did not finish within timeout")
50 except Exception:
51 logging.exception("Error waiting for schedule worker")
53 my_lib.webapp.log.term()
55 # 子プロセスを終了
56 my_lib.proc_util.kill_child()
58 # プロセス終了
59 logging.info("Graceful shutdown completed")
60 sys.exit(0)
63def sig_handler(num: int, frame: object) -> None:
64 logging.warning("receive signal %d", num)
66 if num in (signal.SIGTERM, signal.SIGINT):
67 # Flask reloader の子プロセスも含めて終了する
68 try:
69 # 現在のプロセスがプロセスグループリーダーの場合、全体を終了
70 current_pid = os.getpid()
71 pgid = os.getpgid(current_pid)
72 if current_pid == pgid:
73 logging.info("Terminating process group %d", pgid)
74 os.killpg(pgid, signal.SIGTERM)
75 except (ProcessLookupError, PermissionError):
76 # プロセスグループ操作に失敗した場合は通常の終了処理
77 pass
79 term()
82def create_app(config: rasp_shutter.config.AppConfig, dummy_mode: bool = False) -> flask.Flask:
83 # NOTE: オプションでダミーモードが指定された場合、環境変数もそれに揃えておく
84 # control.py がモジュールロード時に DUMMY_MODE を参照するため、インポート前に設定する
85 if dummy_mode:
86 os.environ["DUMMY_MODE"] = "true"
87 else: # pragma: no cover
88 os.environ["DUMMY_MODE"] = "false"
90 # NOTE: DUMMY_MODE 環境変数を設定した後にモジュールをインポート
91 import rasp_shutter.control.webapi.control
92 import rasp_shutter.control.webapi.schedule
93 import rasp_shutter.control.webapi.sensor
94 import rasp_shutter.metrics.webapi.page
96 # NOTE: テストのため、環境変数 DUMMY_MODE をセットしてからロードしたいのでこの位置
97 my_lib.webapp.config.URL_PREFIX = "/rasp-shutter"
98 my_lib.webapp.config.init(rasp_shutter.config.to_my_lib_webapp_config(config))
100 app = flask.Flask("rasp-shutter")
102 # NOTE: アクセスログは無効にする
103 logging.getLogger("werkzeug").setLevel(logging.ERROR)
105 if os.environ.get("WERKZEUG_RUN_MAIN") == "true":
106 if dummy_mode:
107 logging.warning("Set dummy mode")
108 else: # pragma: no cover
109 pass
111 rasp_shutter.control.webapi.control.init()
112 rasp_shutter.control.webapi.schedule.init(config)
113 my_lib.webapp.log.init(config.slack)
115 def notify_terminate() -> None: # pragma: no cover
116 my_lib.webapp.log.info("🏃 アプリを再起動します。")
117 my_lib.webapp.log.term()
119 atexit.register(notify_terminate)
120 else: # pragma: no cover
121 pass
123 flask_cors.CORS(app)
125 app.config["CONFIG"] = config
126 app.config["DUMMY_MODE"] = dummy_mode
128 # Flask 2.2+のJSON互換性設定。mypy/tyはJSONProviderのcompat属性を認識しないため抑制
129 app.json.compat = True # type: ignore[attr-defined,union-attr]
131 app.register_blueprint(
132 rasp_shutter.control.webapi.control.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
133 )
134 app.register_blueprint(
135 rasp_shutter.control.webapi.schedule.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
136 )
137 app.register_blueprint(
138 rasp_shutter.control.webapi.sensor.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
139 )
140 app.register_blueprint(
141 rasp_shutter.metrics.webapi.page.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
142 )
144 app.register_blueprint(my_lib.webapp.base.blueprint_default)
145 app.register_blueprint(my_lib.webapp.base.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX)
146 app.register_blueprint(my_lib.webapp.event.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX)
147 app.register_blueprint(my_lib.webapp.log.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX)
148 app.register_blueprint(my_lib.webapp.util.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX)
150 if os.environ.get("TEST") == "true": 150 ↛ 161line 150 didn't jump to line 161 because the condition on line 150 was always true
151 import rasp_shutter.control.webapi.test.sync
152 import rasp_shutter.control.webapi.test.time
154 app.register_blueprint(
155 rasp_shutter.control.webapi.test.time.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
156 )
157 app.register_blueprint(
158 rasp_shutter.control.webapi.test.sync.blueprint, url_prefix=my_lib.webapp.config.URL_PREFIX
159 )
161 my_lib.webapp.config.show_handler_list(app)
163 return app
166if __name__ == "__main__":
167 import pathlib
169 import docopt
170 import my_lib.logger
172 # docstringを使用(__doc__がNoneでないことを確認)
173 assert __doc__ is not None, "Module docstring is required" # noqa: S101
174 args = docopt.docopt(__doc__)
176 config_file = args["-c"]
177 port = args["-p"]
178 dummy_mode = args["-d"]
179 debug_mode = args["-D"]
181 my_lib.logger.init("hems.rasp-shutter", level=logging.DEBUG if debug_mode else logging.INFO)
183 config = rasp_shutter.config.load(config_file, pathlib.Path(SCHEMA_CONFIG))
185 app = create_app(config, dummy_mode)
187 # プロセスグループリーダーとして実行(リローダープロセスの適切な管理のため)
188 with contextlib.suppress(PermissionError):
189 os.setpgrp()
191 # シグナルハンドラを登録(Kubernetes rollout時のSIGTERMに対応)
192 signal.signal(signal.SIGTERM, sig_handler)
193 signal.signal(signal.SIGINT, sig_handler)
195 # 異常終了時のクリーンアップ処理を登録
196 def cleanup_on_exit() -> None:
197 try:
198 current_pid = os.getpid()
199 pgid = os.getpgid(current_pid)
200 if current_pid == pgid:
201 # プロセスグループ内の他のプロセスを終了
202 os.killpg(pgid, signal.SIGTERM)
203 except (ProcessLookupError, PermissionError):
204 pass
206 atexit.register(cleanup_on_exit)
208 # Flaskアプリケーションを実行
209 try:
210 # NOTE: スクリプトの自動リロード停止したい場合は use_reloader=False にする
211 app.run(host="0.0.0.0", port=port, threaded=True, use_reloader=True) # noqa: S104
212 except KeyboardInterrupt:
213 logging.info("Received KeyboardInterrupt, shutting down...")
214 sig_handler(signal.SIGINT, None)