Coverage for src / app.py: 61%

83 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-13 00:10 +0900

1#!/usr/bin/env python3 

2""" 

3電動シャッターの開閉を自動化するアプリのサーバーです 

4 

5Usage: 

6 app.py [-c CONFIG] [-p PORT] [-d] [-D] 

7 

8Options: 

9 -c CONFIG : CONFIG を設定ファイルとして読み込んで実行します。[default: config.yaml] 

10 -p PORT : WEB サーバのポートを指定します。[default: 5000] 

11 -d : ダミーモードで実行します。CI テストで利用することを想定しています。 

12 -D : デバッグモードで動作します。 

13""" 

14 

15import atexit 

16import contextlib 

17import logging 

18import os 

19import signal 

20import sys 

21 

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 

30 

31import rasp_shutter.config 

32 

33SCHEMA_CONFIG = "config.schema" 

34 

35 

36def term() -> None: 

37 import rasp_shutter.control.scheduler 

38 import rasp_shutter.control.webapi.schedule 

39 

40 rasp_shutter.control.scheduler.term() 

41 

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") 

52 

53 my_lib.webapp.log.term() 

54 

55 # 子プロセスを終了 

56 my_lib.proc_util.kill_child() 

57 

58 # プロセス終了 

59 logging.info("Graceful shutdown completed") 

60 sys.exit(0) 

61 

62 

63def sig_handler(num: int, frame: object) -> None: 

64 logging.warning("receive signal %d", num) 

65 

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 

78 

79 term() 

80 

81 

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" 

89 

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 

95 

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)) 

99 

100 app = flask.Flask("rasp-shutter") 

101 

102 # NOTE: アクセスログは無効にする 

103 logging.getLogger("werkzeug").setLevel(logging.ERROR) 

104 

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 

110 

111 rasp_shutter.control.webapi.control.init() 

112 rasp_shutter.control.webapi.schedule.init(config) 

113 my_lib.webapp.log.init(config.slack) 

114 

115 def notify_terminate() -> None: # pragma: no cover 

116 my_lib.webapp.log.info("🏃 アプリを再起動します。") 

117 my_lib.webapp.log.term() 

118 

119 atexit.register(notify_terminate) 

120 else: # pragma: no cover 

121 pass 

122 

123 flask_cors.CORS(app) 

124 

125 app.config["CONFIG"] = config 

126 app.config["DUMMY_MODE"] = dummy_mode 

127 

128 # Flask 2.2+のJSON互換性設定。mypy/tyはJSONProviderのcompat属性を認識しないため抑制 

129 app.json.compat = True # type: ignore[attr-defined,union-attr] 

130 

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 ) 

143 

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) 

149 

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 

153 

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 ) 

160 

161 my_lib.webapp.config.show_handler_list(app) 

162 

163 return app 

164 

165 

166if __name__ == "__main__": 

167 import pathlib 

168 

169 import docopt 

170 import my_lib.logger 

171 

172 # docstringを使用(__doc__がNoneでないことを確認) 

173 assert __doc__ is not None, "Module docstring is required" # noqa: S101 

174 args = docopt.docopt(__doc__) 

175 

176 config_file = args["-c"] 

177 port = args["-p"] 

178 dummy_mode = args["-d"] 

179 debug_mode = args["-D"] 

180 

181 my_lib.logger.init("hems.rasp-shutter", level=logging.DEBUG if debug_mode else logging.INFO) 

182 

183 config = rasp_shutter.config.load(config_file, pathlib.Path(SCHEMA_CONFIG)) 

184 

185 app = create_app(config, dummy_mode) 

186 

187 # プロセスグループリーダーとして実行(リローダープロセスの適切な管理のため) 

188 with contextlib.suppress(PermissionError): 

189 os.setpgrp() 

190 

191 # シグナルハンドラを登録(Kubernetes rollout時のSIGTERMに対応) 

192 signal.signal(signal.SIGTERM, sig_handler) 

193 signal.signal(signal.SIGINT, sig_handler) 

194 

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 

205 

206 atexit.register(cleanup_on_exit) 

207 

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)