interlocking 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """
2
+ interlocking
3
+ =======
4
+ One track, one train.
5
+ Easy to run. Explicit to control.
6
+
7
+ 分散環境におけるバックグラウンドタスクの「運行管理」フレームワーク。
8
+
9
+ 公開 API::
10
+
11
+ from interlocking import Line, Coupler
12
+ from interlocking import InterlockingCLI, TrainDispatcher, LocalStation
13
+ """
14
+ from .cli import ExitCode, InterlockingCLI
15
+ from .dispatcher import Train, TrainDispatcher, TrainMiddleware
16
+ from .line import Line
17
+ from .models import (
18
+ Coupler,
19
+ SignalBlockedError,
20
+ SignalStatus,
21
+ StaleLockError,
22
+ TrainRecord,
23
+ TrainResult,
24
+ )
25
+ from .station import LocalStation, S3Station, Station
26
+
27
+ __all__ = [
28
+ # CLI ビルダー
29
+ "InterlockingCLI",
30
+ "ExitCode",
31
+ # 路線定義(Router)
32
+ "Line",
33
+ # モデル
34
+ "Coupler",
35
+ "TrainRecord",
36
+ "TrainResult",
37
+ # ディスパッチャー
38
+ "Train",
39
+ "TrainDispatcher",
40
+ "TrainMiddleware",
41
+ # 例外
42
+ "SignalBlockedError",
43
+ "StaleLockError",
44
+ # ストレージ
45
+ "Station",
46
+ "SignalStatus",
47
+ "LocalStation",
48
+ "S3Station",
49
+ ]
interlocking/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """
2
+ interlocking.cli
3
+ ===========
4
+ interlocking を使った CLI の共通ビルダー。
5
+
6
+ argparse の2ステップインターフェースに準拠する::
7
+
8
+ cli = InterlockingCLI(
9
+ dispatcher=dispatcher,
10
+ prog="sails-task2",
11
+ description="SAILSタスク実行コマンド",
12
+ )
13
+ args = cli.parse_args() # パーサー構築 + 引数解析
14
+ context.setup_logging(args.log_config)
15
+ sys.exit(cli.execute(args)) # タスク実行。結果を終了コード(ExitCode)で返す
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import inspect
21
+ import sys
22
+ from enum import IntEnum
23
+ from typing import (
24
+ Any,
25
+ Callable,
26
+ Dict,
27
+ Optional,
28
+ Tuple,
29
+ get_args,
30
+ get_origin,
31
+ get_type_hints,
32
+ )
33
+
34
+ from .dispatcher import TrainDispatcher
35
+ from .models import Coupler, TrainResult
36
+
37
+
38
+ class ExitCode(IntEnum):
39
+ """CLI の終了コード。cron など上位のオーケストレーターが結果を判別するための規約。"""
40
+
41
+ SUCCESS = 0
42
+ FAILED = 1 # タスクが失敗した
43
+ USAGE = 2 # argparse の引数エラー(argparse が返す。interlocking はこの値を他の用途に使わない)
44
+ BLOCKED = 3 # 赤信号(他の列車が運行中)で実行されなかった
45
+ STALE = 4 # 前回ロックの残骸(stale)で実行されなかった。復旧が必要
46
+ SKIPPED = 5 # Middleware が実行を見送った
47
+
48
+
49
+ def _exit_code(result: TrainResult) -> ExitCode:
50
+ """depart() の結果を終了コードに変換する。判断できないときは失敗扱いにする。"""
51
+ if result.status == "success":
52
+ return ExitCode.SUCCESS
53
+ if result.status == "blocked":
54
+ return ExitCode.STALE if result.is_stale else ExitCode.BLOCKED
55
+ if result.status == "skipped":
56
+ return ExitCode.SKIPPED
57
+ return ExitCode.FAILED
58
+
59
+ # InterlockingCLI 内部で使用する共通キー(params の抄出時に除外)
60
+ _INTERLOCKING_PARAM_KEYS: frozenset = frozenset({
61
+ "command", "track_id", "line", "force", "allow_stale_takeover", "stale_after", "run_id", "run_by", "parser",
62
+ })
63
+
64
+
65
+ def _setup_argparsers(registry: Dict[str, Tuple[Callable, str]], parsers: Any, line_name: Optional[str] = None) -> None:
66
+ """路線 registry から argparse サブパーサーを構築する。
67
+
68
+ 関数の型ヒント (Annotated[Type, "help"]) から引数定義を自動生成する。
69
+ Annotated でない引数(Coupler 以外)はエラーとなる。
70
+
71
+ Args:
72
+ registry: TrainDispatcher.registry(line_name でフィルタ済みを推奨)。
73
+ parsers: argparse の add_subparsers() の戻り値。
74
+ line_name: 指定時は "line_name:track_name" の track_name 部分をサブコマンド名にする。
75
+ """
76
+ for track_id, (func, help_text) in registry.items():
77
+ # InterlockingCLI 経由の場合は track_name 部分のみをサブコマンド名にする
78
+ display_name = track_id.split(":", 1)[1] if line_name and ":" in track_id else track_id
79
+ parser = parsers.add_parser(display_name, help=help_text)
80
+ parser.set_defaults(track_id=track_id)
81
+ parser.add_argument("command", choices=["start", "abort"])
82
+
83
+ try:
84
+ type_hints = get_type_hints(func, include_extras=True)
85
+ except Exception:
86
+ type_hints = {}
87
+
88
+ sig = inspect.signature(func)
89
+
90
+ for param_name, param in sig.parameters.items():
91
+ hint = param.annotation if param.annotation is not inspect.Parameter.empty else None
92
+ type_hint = type_hints.get(param_name)
93
+ if type_hint is not None:
94
+ hint = type_hint
95
+ if hint is None:
96
+ continue
97
+
98
+ is_coupler = False
99
+ try:
100
+ if hint is Coupler:
101
+ is_coupler = True
102
+ elif hasattr(hint, '__name__') and hint.__name__ == 'Coupler':
103
+ is_coupler = True
104
+ elif isinstance(hint, str) and hint == 'Coupler':
105
+ is_coupler = True
106
+ except Exception:
107
+ pass
108
+ if is_coupler:
109
+ continue
110
+
111
+ origin = get_origin(hint)
112
+ has_metadata = hasattr(hint, '__metadata__')
113
+ import typing
114
+ is_annotated = (
115
+ has_metadata or
116
+ (origin is not None and (
117
+ origin is getattr(typing, 'Annotated', None) or
118
+ str(origin) == 'typing.Annotated'
119
+ ))
120
+ )
121
+ if not is_annotated:
122
+ raise TypeError(
123
+ f"タスク '{track_id}' の引数 '{param_name}' には "
124
+ f"Annotated[Type, 'ヘルプメッセージ'] が必要です。"
125
+ )
126
+
127
+ args = get_args(hint)
128
+ actual_type = args[0]
129
+ help_msg = hint.__metadata__[0] if hint.__metadata__ else ""
130
+ arg_default = (
131
+ param.default if param.default is not inspect.Parameter.empty else None
132
+ )
133
+ if actual_type is bool:
134
+ parser.add_argument(f"--{param_name}", action="store_true", help=help_msg)
135
+ else:
136
+ parser.add_argument(f"--{param_name}", type=actual_type, default=arg_default, help=help_msg)
137
+
138
+ parser.add_argument("--force", action="store_true", help="信号を無視して実行する(安全装置をすべて解除する)")
139
+ parser.add_argument(
140
+ "--allow-stale-takeover", dest="allow_stale_takeover", action="store_true",
141
+ help="stale(前回ロックの残骸)なロックだけを引き継いで実行する",
142
+ )
143
+ parser.add_argument(
144
+ "--stale-after", dest="stale_after", type=int, default=600,
145
+ help="この秒数が経過したロックを stale(前回ロックの残骸)とみなす (default=600)",
146
+ )
147
+ parser.add_argument("--run-by", dest="run_by", default=None, help="実行ユーザー名 (記録用)")
148
+ parser.add_argument("--run-id", dest="run_id", default=None, help="実行ID (記録用)")
149
+
150
+
151
+ class InterlockingCLI:
152
+ """interlocking CLI のビルダー。
153
+
154
+ argparse の2ステップに準拠した設計:
155
+ parse_args() でパーサーを構築して引数を解析し、
156
+ execute() でタスクを実行する。
157
+
158
+ 3フェーズでパーサーを構築する:
159
+ Phase 1 (Discovery): dispatcher.lines のキーだけでトップレベルパーサーを構成。
160
+ Phase 2 (Loading): 指定路線のモジュールを import し line 属性を取得。
161
+ line.register_to(dispatcher) でタスクを登録する。
162
+ Phase 3 (Mapping): 登録済みタスクから詳細パーサーを構築する。
163
+
164
+ Args:
165
+ dispatcher: 構築済みの TrainDispatcher。
166
+ prog: CLI プログラム名(argparse の prog)。
167
+ description: CLI の説明文。
168
+ extra_arguments: ルートパーサーに追加する引数を設定するコールバック。
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ dispatcher: TrainDispatcher,
174
+ prog: str = "interlocking",
175
+ description: str = "",
176
+ extra_arguments: Optional[Callable[[argparse.ArgumentParser], None]] = None,
177
+ ) -> None:
178
+ self.dispatcher = dispatcher
179
+ self.prog = prog
180
+ self.description = description
181
+ self.extra_arguments = extra_arguments
182
+
183
+ def parse_args(self, argv: Optional[list] = None) -> argparse.Namespace:
184
+ """パーサーを構築して引数を解析する。
185
+
186
+ argparse の parse_args() に相当するステップ。
187
+ 戻り値の Namespace を execute() に渡すことでタスクを実行する。
188
+
189
+ Args:
190
+ argv: パース対象の引数リスト。None のとき sys.argv[1:] を使用。
191
+
192
+ Returns:
193
+ 解析済みの argparse.Namespace。
194
+ """
195
+ _argv = argv if argv is not None else sys.argv[1:]
196
+
197
+ root = argparse.ArgumentParser(prog=self.prog, description=self.description)
198
+ root.set_defaults(parser=root, track_id=None)
199
+
200
+ if self.extra_arguments:
201
+ self.extra_arguments(root)
202
+
203
+ line_subparsers = root.add_subparsers(dest="line", help="line name")
204
+
205
+ # Phase 1: 路線名だけでパーサーを構成(import なし)
206
+ line_parsers: Dict[str, argparse.ArgumentParser] = {}
207
+ for line in self.dispatcher.lines:
208
+ lp = line_subparsers.add_parser(line)
209
+ lp.set_defaults(parser=lp, track_id=None)
210
+ line_parsers[line] = lp
211
+
212
+ # Phase 2: 指定路線をロードし登録
213
+ temp_parser = argparse.ArgumentParser(add_help=False)
214
+ if self.extra_arguments:
215
+ self.extra_arguments(temp_parser)
216
+ temp_parser.add_argument('line', nargs='?', choices=list(self.dispatcher.lines))
217
+ temp_args, _ = temp_parser.parse_known_args(_argv)
218
+ if temp_args.line:
219
+ self.dispatcher.load_line(temp_args.line)
220
+
221
+ # Phase 3: 登録済みタスクから詳細パーサーを構築。路線別にフィルタして track_name だけをサブコマンド名にする。
222
+ for line, lp in line_parsers.items():
223
+ track_subparsers = lp.add_subparsers(dest="track_id", help="タスク名")
224
+ line_registry = {
225
+ track_id: entry
226
+ for track_id, entry in self.dispatcher.registry.items()
227
+ if track_id.startswith(f"{line}:")
228
+ }
229
+ _setup_argparsers(line_registry, track_subparsers, line)
230
+
231
+ return root.parse_args(_argv)
232
+
233
+ def execute(self, args: argparse.Namespace) -> int:
234
+ """parse_args() の結果を受け取ってタスクを実行し、結果を終了コードで返す。
235
+
236
+ sys.exit() は呼ばない(Python コードから呼ぶライブラリ利用を妨げない)。
237
+ スクリプトでは ``sys.exit(cli.execute(args))`` として終了コードを伝える。
238
+
239
+ Args:
240
+ args: parse_args() が返した Namespace。
241
+
242
+ Returns:
243
+ :class:`ExitCode`(int)。成功 0 / 失敗 1 / 赤信号 3 / stale 4 / 見送り 5。
244
+ 引数エラー(2)は parse_args() が argparse の標準動作で終了する。
245
+ """
246
+ if args.track_id is None:
247
+ args.parser.print_help()
248
+ return ExitCode.SUCCESS
249
+
250
+ if args.command == "abort":
251
+ self.dispatcher.abort(args.track_id, run_by=getattr(args, "run_by", None))
252
+ return ExitCode.SUCCESS
253
+
254
+ params = {k: v for k, v in vars(args).items() if k not in _INTERLOCKING_PARAM_KEYS}
255
+ result = self.dispatcher.depart(
256
+ args.track_id,
257
+ params,
258
+ force=args.force,
259
+ allow_stale_takeover=args.allow_stale_takeover,
260
+ stale_after=args.stale_after,
261
+ run_id=getattr(args, "run_id", None),
262
+ run_by=getattr(args, "run_by", None),
263
+ )
264
+ return _exit_code(result)