interlocking 0.1.0__tar.gz → 0.2.1__tar.gz

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,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: interlocking
3
+ Version: 0.2.1
4
+ Summary: A lightweight task dispatcher with explicit control.
5
+ Keywords: task-runner,batch,exclusive-lock,cli,mutex,distributed-lock
6
+ Author: alpine
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Classifier: Topic :: System :: Distributed Computing
13
+ Classifier: Topic :: Utilities
14
+ Requires-Dist: boto3>1.40.0,<2 ; extra == 'aws'
15
+ Requires-Python: >=3.11
16
+ Provides-Extra: aws
17
+ Description-Content-Type: text/markdown
18
+
19
+ # Interlocking
20
+
21
+ > One track, one train.
22
+ > Easy to run. Explicit to control.
23
+
24
+ Interlocking is a framework for managing background task execution in distributed environments.
25
+ The name comes from the railway interlocking system, which prevents two trains from entering the same track at the same time. Exclusive control is at the core of the library.
26
+ Using a railway metaphor, it manages the lifecycle of batch tasks (exclusive control, run records, and CLI integration) in a single, consistent way.
27
+
28
+ ## Features
29
+
30
+ - **Exclusive control**: The same task never runs concurrently.
31
+ - **Run records**: Stores the result of the most recent run and hands it over to the next one (`Coupler`).
32
+ - **CLI integration**: Builds a command line from type hints and reports the result through the exit code.
33
+ - **Pluggable**: Extend behavior with a `Station` (storage backend) or `TrainMiddleware`.
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ pip install interlocking
39
+ ```
40
+
41
+ To use S3 as the storage backend:
42
+
43
+ ```bash
44
+ pip install "interlocking[aws]"
45
+ ```
46
+
47
+ Requires Python 3.11 or later.
48
+
49
+ ## Quick start
50
+
51
+ ```python
52
+ # tasks.py
53
+ from typing import Annotated
54
+ from interlocking import Line, Coupler
55
+
56
+ line = Line()
57
+
58
+ @line.track(name="daily", help="Daily job")
59
+ def daily_task(c: Coupler, target_date: Annotated[str, "Target date (YYYY-MM-DD)"]) -> dict:
60
+ last_id = c.last_data.get("last_id", 0)
61
+ # ... do the work ...
62
+ return {"last_id": 9999}
63
+ ```
64
+
65
+ ```python
66
+ # main.py
67
+ import sys
68
+
69
+ from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
70
+
71
+ dispatcher = TrainDispatcher(
72
+ station=LocalStation(base_path=".interlocking"),
73
+ lines={"batch": "tasks:line"},
74
+ )
75
+
76
+ cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="Batch tasks")
77
+ sys.exit(cli.execute(cli.parse_args())) # report the result via exit code
78
+ ```
79
+
80
+ ```bash
81
+ # `start` makes the intent to run explicit: without it, nothing runs
82
+ python main.py start batch daily --target-date 2026-04-01
83
+
84
+ # Release a stuck lock (a separate management operation)
85
+ python main.py abort batch daily
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT
@@ -0,0 +1,72 @@
1
+ # Interlocking
2
+
3
+ > One track, one train.
4
+ > Easy to run. Explicit to control.
5
+
6
+ Interlocking is a framework for managing background task execution in distributed environments.
7
+ The name comes from the railway interlocking system, which prevents two trains from entering the same track at the same time. Exclusive control is at the core of the library.
8
+ Using a railway metaphor, it manages the lifecycle of batch tasks (exclusive control, run records, and CLI integration) in a single, consistent way.
9
+
10
+ ## Features
11
+
12
+ - **Exclusive control**: The same task never runs concurrently.
13
+ - **Run records**: Stores the result of the most recent run and hands it over to the next one (`Coupler`).
14
+ - **CLI integration**: Builds a command line from type hints and reports the result through the exit code.
15
+ - **Pluggable**: Extend behavior with a `Station` (storage backend) or `TrainMiddleware`.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install interlocking
21
+ ```
22
+
23
+ To use S3 as the storage backend:
24
+
25
+ ```bash
26
+ pip install "interlocking[aws]"
27
+ ```
28
+
29
+ Requires Python 3.11 or later.
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ # tasks.py
35
+ from typing import Annotated
36
+ from interlocking import Line, Coupler
37
+
38
+ line = Line()
39
+
40
+ @line.track(name="daily", help="Daily job")
41
+ def daily_task(c: Coupler, target_date: Annotated[str, "Target date (YYYY-MM-DD)"]) -> dict:
42
+ last_id = c.last_data.get("last_id", 0)
43
+ # ... do the work ...
44
+ return {"last_id": 9999}
45
+ ```
46
+
47
+ ```python
48
+ # main.py
49
+ import sys
50
+
51
+ from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
52
+
53
+ dispatcher = TrainDispatcher(
54
+ station=LocalStation(base_path=".interlocking"),
55
+ lines={"batch": "tasks:line"},
56
+ )
57
+
58
+ cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="Batch tasks")
59
+ sys.exit(cli.execute(cli.parse_args())) # report the result via exit code
60
+ ```
61
+
62
+ ```bash
63
+ # `start` makes the intent to run explicit: without it, nothing runs
64
+ python main.py start batch daily --target-date 2026-04-01
65
+
66
+ # Release a stuck lock (a separate management operation)
67
+ python main.py abort batch daily
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "interlocking"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "A lightweight task dispatcher with explicit control."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "interlocking"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "A lightweight task dispatcher with explicit control."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -12,7 +12,7 @@ Easy to run. Explicit to control.
12
12
  from interlocking import Line, Coupler
13
13
  from interlocking import InterlockingCLI, TrainDispatcher, LocalStation
14
14
  """
15
- from .cli import ExitCode, InterlockingCLI
15
+ from .cli import CliRequest, ExitCode, InterlockingCLI
16
16
  from .dispatcher import Train, TrainDispatcher, TrainMiddleware
17
17
  from .line import Line
18
18
  from .models import (
@@ -20,6 +20,7 @@ from .models import (
20
20
  SignalBlockedError,
21
21
  SignalStatus,
22
22
  StaleLockError,
23
+ TrainPlan,
23
24
  TrainRecord,
24
25
  TrainResult,
25
26
  )
@@ -28,6 +29,7 @@ from .station import LocalStation, S3Station, Station
28
29
  __all__ = [
29
30
  # CLI ビルダー
30
31
  "InterlockingCLI",
32
+ "CliRequest",
31
33
  "ExitCode",
32
34
  # 路線定義(Router)
33
35
  "Line",
@@ -35,6 +37,7 @@ __all__ = [
35
37
  "Coupler",
36
38
  "TrainRecord",
37
39
  "TrainResult",
40
+ "TrainPlan",
38
41
  # ディスパッチャー
39
42
  "Train",
40
43
  "TrainDispatcher",
@@ -0,0 +1,426 @@
1
+ """
2
+ interlocking.cli
3
+ ===========
4
+ interlocking を使った CLI の共通ビルダー。
5
+
6
+ 動詞を先頭に置く(ADR-0007)。``start`` は実行を明示するガード、``abort`` は別の管理操作::
7
+
8
+ main.py start batch daily --target-date 2026-04-01 --force
9
+ main.py abort batch daily
10
+
11
+ argparse の2ステップインターフェースに準拠する::
12
+
13
+ cli = InterlockingCLI(
14
+ dispatcher=dispatcher,
15
+ prog="sails-task2",
16
+ description="SAILSタスク実行コマンド",
17
+ )
18
+ request = cli.parse_args() # パーサー構築 + 引数解析(CliRequest を返す)
19
+ context.setup_logging(...) # 起動時処理は、ここに書く
20
+ sys.exit(cli.execute(request)) # タスク実行。結果を終了コード(ExitCode)で返す
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import inspect
26
+ import json
27
+ import sys
28
+ from dataclasses import dataclass, field
29
+ from enum import IntEnum
30
+ from typing import (
31
+ Any,
32
+ Callable,
33
+ Dict,
34
+ Literal,
35
+ Optional,
36
+ Tuple,
37
+ get_args,
38
+ get_origin,
39
+ get_type_hints,
40
+ )
41
+
42
+ from .dispatcher import TrainDispatcher, _check_reserved_params
43
+ from .models import Coupler, TrainPlan, TrainResult
44
+
45
+ DEFAULT_STALE_AFTER = 600
46
+ OUTPUT_FORMATS = ("text", "json")
47
+
48
+
49
+ class ExitCode(IntEnum):
50
+ """CLI の終了コード。cron など上位のオーケストレーターが結果を判別するための規約。"""
51
+
52
+ SUCCESS = 0
53
+ FAILED = 1 # タスクが失敗した(--dry-run では、判定できなかった)
54
+ USAGE = 2 # argparse の引数エラー(argparse が返す。interlocking はこの値を他の用途に使わない)
55
+ BLOCKED = 3 # 赤信号(他の列車が運行中)で実行されなかった
56
+ STALE = 4 # 前回ロックの残骸(stale)で実行されなかった。復旧が必要
57
+ SKIPPED = 5 # Middleware が実行を見送った
58
+
59
+
60
+ def _exit_code(result: TrainResult) -> ExitCode:
61
+ """depart() の結果を終了コードに変換する。判断できないときは失敗扱いにする。"""
62
+ if result.status == "success":
63
+ return ExitCode.SUCCESS
64
+ if result.status == "blocked":
65
+ return ExitCode.STALE if result.is_stale else ExitCode.BLOCKED
66
+ if result.status == "skipped":
67
+ return ExitCode.SKIPPED
68
+ return ExitCode.FAILED
69
+
70
+
71
+ def _plan_exit_code(plan: TrainPlan) -> ExitCode:
72
+ """plan() の予測を、本番を実行したときと同じ終了コードに変換する。判定できないときは失敗扱い(ADR-0008)。"""
73
+ return {
74
+ "ready": ExitCode.SUCCESS,
75
+ "blocked": ExitCode.BLOCKED,
76
+ "stale": ExitCode.STALE,
77
+ }.get(plan.outcome, ExitCode.FAILED)
78
+
79
+
80
+ def _format_plan_text(plan: TrainPlan) -> str:
81
+ """TrainPlan を人が読む形に整形する。"""
82
+ last = plan.last_stop
83
+ lines = [
84
+ f"[DRY-RUN] {plan.track_id} (実行しません)",
85
+ f"outcome : {plan.outcome}",
86
+ f"signal : {plan.signal.value if plan.signal else '(未確認)'}",
87
+ f"first_departure : {plan.first_departure}",
88
+ ]
89
+ if not plan.first_departure:
90
+ lines.append(f"last stop : {last.status} run_id={last.run_id} run_by={last.run_by}")
91
+ lines.append(f"last_data : {json.dumps(last.data, ensure_ascii=False)}")
92
+ lines.append(f"params : {json.dumps(plan.params, ensure_ascii=False)}")
93
+ lines.append(
94
+ f"options : force={plan.force} allow_stale_takeover={plan.allow_stale_takeover}"
95
+ f" stale_after={plan.stale_after} run_by={plan.run_by}"
96
+ )
97
+ lines.extend(f"note : {note}" for note in plan.notes)
98
+ return "\n".join(lines)
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class CliRequest:
103
+ """parse_args() の結果。execute() が受け取る、型のある入れ物。
104
+
105
+ ``command="start"`` は depart() の呼び出し内容、``command="abort"`` は abort() の呼び出し内容を表す。
106
+ 利用者は parse_args() と execute() の間で、``dataclasses.replace()`` で加工できる。
107
+ 組み合わせは構築時に検査する(abort では実行用の属性を指定できない)。
108
+
109
+ Attributes:
110
+ command: ``"start"``(実行)または ``"abort"``(実行中フラグの強制解除)。
111
+ track_id: タスク識別子("line_name:track_name" 形式)。
112
+ params: タスク関数に渡す引数(start のみ)。
113
+ force: True のとき信号確認をスキップする(start のみ)。
114
+ allow_stale_takeover: True のとき、stale なロックだけを引き継いで実行する(start のみ)。
115
+ stale_after: stale とみなす秒数(start のみ)。
116
+ run_id: 実行ID。None のとき自動生成する(start のみ。dry_run とは併用できない)。
117
+ run_by: 実行(操作)ユーザー名。None のとき OS ユーザー。
118
+ dry_run: True のとき、タスクを呼ばずに実行の予測を標準出力に出す(start のみ)。
119
+ output: dry_run の出力形式 ``"text"`` / ``"json"``。None のとき text(dry_run のみ)。
120
+ """
121
+
122
+ command: Literal["start", "abort"]
123
+ track_id: str
124
+ params: Dict[str, Any] = field(default_factory=dict)
125
+ force: bool = False
126
+ allow_stale_takeover: bool = False
127
+ stale_after: int = DEFAULT_STALE_AFTER
128
+ run_id: Optional[str] = None
129
+ run_by: Optional[str] = None
130
+ dry_run: bool = False
131
+ output: Optional[Literal["text", "json"]] = None
132
+
133
+ def __post_init__(self) -> None:
134
+ if self.command not in ("start", "abort"):
135
+ raise ValueError(f"command は 'start' か 'abort' を指定してください: {self.command!r}")
136
+ if self.output is not None and self.output not in OUTPUT_FORMATS:
137
+ raise ValueError(f"output は {list(OUTPUT_FORMATS)} のいずれかを指定してください: {self.output!r}")
138
+ if self.command == "abort":
139
+ start_only = {
140
+ "params": bool(self.params),
141
+ "force": self.force,
142
+ "allow_stale_takeover": self.allow_stale_takeover,
143
+ "stale_after": self.stale_after != DEFAULT_STALE_AFTER,
144
+ "run_id": self.run_id is not None,
145
+ "dry_run": self.dry_run,
146
+ "output": self.output is not None,
147
+ }
148
+ invalid = sorted(name for name, specified in start_only.items() if specified)
149
+ if invalid:
150
+ raise ValueError(f"abort では {invalid} を指定できません(start のみの属性です)")
151
+ elif self.dry_run:
152
+ if self.run_id is not None:
153
+ raise ValueError("dry_run では run_id を指定できません(実行しないため)")
154
+ elif self.output is not None:
155
+ raise ValueError("output は dry_run のときだけ指定できます")
156
+
157
+
158
+ # params の抄出時に除外する、InterlockingCLI 内部の dest とオプション名
159
+ _CONTROL_KEYS: frozenset = frozenset({
160
+ "force", "allow_stale_takeover", "stale_after", "run_id", "run_by", "dry_run", "output",
161
+ })
162
+ # サブコマンドの dest。タスクの引数名と衝突しないよう、先頭に "_" を付ける
163
+ _COMMAND, _LINE, _TRACK, _TRACK_ID = "_command", "_line", "_track", "_track_id"
164
+
165
+
166
+ def _setup_argparsers(registry: Dict[str, Tuple[Callable, str]], parsers: Any, line_name: Optional[str] = None) -> None:
167
+ """路線 registry から argparse サブパーサーを構築する。
168
+
169
+ 関数の型ヒント (Annotated[Type, "help"]) から引数定義を自動生成する。
170
+ Annotated でない引数(Coupler 以外)はエラーとなる。
171
+
172
+ Args:
173
+ registry: TrainDispatcher.registry(line_name でフィルタ済みを推奨)。
174
+ parsers: argparse の add_subparsers() の戻り値。
175
+ line_name: 指定時は "line_name:track_name" の track_name 部分をサブコマンド名にする。
176
+ """
177
+ for track_id, (func, help_text) in registry.items():
178
+ # InterlockingCLI 経由の場合は track_name 部分のみをサブコマンド名にする
179
+ display_name = track_id.split(":", 1)[1] if line_name and ":" in track_id else track_id
180
+ parser = parsers.add_parser(display_name, help=help_text)
181
+ parser.set_defaults(**{_TRACK_ID: track_id})
182
+
183
+ try:
184
+ type_hints = get_type_hints(func, include_extras=True)
185
+ except Exception:
186
+ type_hints = {}
187
+
188
+ sig = inspect.signature(func)
189
+ _check_reserved_params(track_id, sig.parameters)
190
+
191
+ for param_name, param in sig.parameters.items():
192
+ hint = param.annotation if param.annotation is not inspect.Parameter.empty else None
193
+ type_hint = type_hints.get(param_name)
194
+ if type_hint is not None:
195
+ hint = type_hint
196
+ if hint is None:
197
+ continue
198
+
199
+ is_coupler = False
200
+ try:
201
+ if hint is Coupler:
202
+ is_coupler = True
203
+ elif hasattr(hint, '__name__') and hint.__name__ == 'Coupler':
204
+ is_coupler = True
205
+ elif isinstance(hint, str) and hint == 'Coupler':
206
+ is_coupler = True
207
+ except Exception:
208
+ pass
209
+ if is_coupler:
210
+ continue
211
+
212
+ origin = get_origin(hint)
213
+ has_metadata = hasattr(hint, '__metadata__')
214
+ import typing
215
+ is_annotated = (
216
+ has_metadata or
217
+ (origin is not None and (
218
+ origin is getattr(typing, 'Annotated', None) or
219
+ str(origin) == 'typing.Annotated'
220
+ ))
221
+ )
222
+ if not is_annotated:
223
+ raise TypeError(
224
+ f"タスク '{track_id}' の引数 '{param_name}' には "
225
+ f"Annotated[Type, 'ヘルプメッセージ'] が必要です。"
226
+ )
227
+
228
+ args = get_args(hint)
229
+ actual_type = args[0]
230
+ help_msg = hint.__metadata__[0] if hint.__metadata__ else ""
231
+ arg_required = param.default is inspect.Parameter.empty
232
+ arg_default = None if arg_required else param.default
233
+ # CLI フラグはケバブケース(--target-date)、params のキーは引数名(target_date)のまま
234
+ flag = f"--{param_name.replace('_', '-')}"
235
+ if actual_type is bool:
236
+ parser.add_argument(flag, dest=param_name, action="store_true", help=help_msg)
237
+ else:
238
+ # 必須の引数(デフォルトなし)が抜けたら、実行前に引数エラーにする
239
+ parser.add_argument(
240
+ flag, dest=param_name, type=actual_type, default=arg_default,
241
+ required=arg_required, help=help_msg,
242
+ )
243
+
244
+ parser.add_argument("--force", action="store_true", help="信号を無視して実行する(安全装置をすべて解除する)")
245
+ parser.add_argument(
246
+ "--allow-stale-takeover", dest="allow_stale_takeover", action="store_true",
247
+ help="stale(前回ロックの残骸)なロックだけを引き継いで実行する",
248
+ )
249
+ parser.add_argument(
250
+ "--stale-after", dest="stale_after", type=int, default=DEFAULT_STALE_AFTER,
251
+ help=f"この秒数が経過したロックを stale(前回ロックの残骸)とみなす (default={DEFAULT_STALE_AFTER})",
252
+ )
253
+ parser.add_argument("--run-by", dest="run_by", default=None, help="実行ユーザー名 (記録用)")
254
+ parser.add_argument("--run-id", dest="run_id", default=None, help="実行ID (記録用)")
255
+ parser.add_argument(
256
+ "--dry-run", dest="dry_run", action="store_true",
257
+ help="タスクを実行せず、実行したらどうなるか(信号・前回の状態・引数)を標準出力に出す",
258
+ )
259
+ parser.add_argument(
260
+ "--output", dest="output", choices=OUTPUT_FORMATS, default=None,
261
+ help="--dry-run の出力形式 (default=text)",
262
+ )
263
+
264
+
265
+ class InterlockingCLI:
266
+ """interlocking CLI のビルダー。
267
+
268
+ 動詞を先頭に置く(``start`` / ``abort``)。``start`` は実行を明示するガードで、動詞がなければ
269
+ 何も実行されない(引数エラー)。``abort`` は別の管理操作で、実行用のオプションは持たない。
270
+
271
+ argparse の2ステップに準拠した設計:
272
+ parse_args() でパーサーを構築して引数を解析し(CliRequest を返す)、
273
+ execute() でタスクを実行する。
274
+
275
+ 3フェーズでパーサーを構築する:
276
+ Phase 1 (Discovery): dispatcher.lines のキーだけでトップレベルパーサーを構成。
277
+ Phase 2 (Loading): ``start`` で指定された路線のモジュールを import し line 属性を取得。
278
+ line.register_to(dispatcher) でタスクを登録する。
279
+ ``abort`` は路線を読み込まない(路線のモジュールが壊れていても復旧できる)。
280
+ Phase 3 (Mapping): 登録済みタスクから詳細パーサーを構築する。
281
+
282
+ Args:
283
+ dispatcher: 構築済みの TrainDispatcher。
284
+ prog: CLI プログラム名(argparse の prog)。
285
+ description: CLI の説明文。
286
+ version: アプリの版。渡したときだけ ``--version`` が付き、``<prog> <version>`` を出す(ADR-0009)。
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ dispatcher: TrainDispatcher,
292
+ prog: str = "interlocking",
293
+ description: str = "",
294
+ version: Optional[str] = None,
295
+ ) -> None:
296
+ self.dispatcher = dispatcher
297
+ self.prog = prog
298
+ self.description = description
299
+ self.version = version
300
+
301
+ def parse_args(self, argv: Optional[list] = None) -> CliRequest:
302
+ """パーサーを構築して引数を解析する。
303
+
304
+ argparse の parse_args() に相当するステップ。
305
+ 戻り値の CliRequest を execute() に渡すことでタスクを実行する。
306
+ 起動時処理(ログ設定など)や ``dataclasses.replace()`` による加工は、この間に行える。
307
+
308
+ Args:
309
+ argv: パース対象の引数リスト。None のとき sys.argv[1:] を使用。
310
+
311
+ Returns:
312
+ 解析済みの CliRequest。引数エラーは argparse の標準動作で終了する(終了コード 2)。
313
+ """
314
+ _argv = list(argv) if argv is not None else sys.argv[1:]
315
+ lines = list(self.dispatcher.lines)
316
+
317
+ root = argparse.ArgumentParser(prog=self.prog, description=self.description)
318
+ if self.version is not None:
319
+ # argparse は version を % 書式で展開するので、利用者の文字列の % はエスケープする
320
+ escaped = self.version.replace("%", "%%")
321
+ root.add_argument("--version", action="version", version=f"%(prog)s {escaped}")
322
+ commands = root.add_subparsers(dest=_COMMAND, required=True, metavar="{start,abort}")
323
+
324
+ # Phase 1: 路線名だけでパーサーを構成(import なし)
325
+ start = commands.add_parser("start", help="タスクを実行する(実行を明示するガード)")
326
+ line_subparsers = start.add_subparsers(dest=_LINE, required=True, metavar="line", help="line name")
327
+ line_parsers: Dict[str, argparse.ArgumentParser] = {
328
+ line: line_subparsers.add_parser(line) for line in lines
329
+ }
330
+
331
+ # Phase 2: start で指定された路線だけをロードし登録
332
+ if len(_argv) >= 2 and _argv[0] == "start" and _argv[1] in line_parsers:
333
+ self.dispatcher.load_line(_argv[1])
334
+
335
+ # Phase 3: 登録済みタスクから詳細パーサーを構築。路線別にフィルタして track_name だけをサブコマンド名にする。
336
+ for line, lp in line_parsers.items():
337
+ track_subparsers = lp.add_subparsers(dest=_TRACK, required=True, metavar="track", help="タスク名")
338
+ line_registry = {
339
+ track_id: entry
340
+ for track_id, entry in self.dispatcher.registry.items()
341
+ if track_id.startswith(f"{line}:")
342
+ }
343
+ _setup_argparsers(line_registry, track_subparsers, line)
344
+
345
+ # abort は実行とは別の管理操作。路線は lines のキーで検証し、Track は文字列のまま受け取る。
346
+ abort = commands.add_parser("abort", help="実行中フラグを強制解除する(管理操作)")
347
+ abort.add_argument(_LINE, metavar="line", choices=lines, help="line name")
348
+ abort.add_argument(_TRACK, metavar="track", help="タスク名")
349
+ abort.add_argument("--run-by", dest="run_by", default=None, help="操作ユーザー名 (記録用)")
350
+
351
+ try:
352
+ return self._to_request(root.parse_args(_argv))
353
+ except ValueError as e: # CliRequest の組み合わせ検査(例: --output だけの指定)
354
+ root.error(str(e))
355
+
356
+ @staticmethod
357
+ def _to_request(ns: argparse.Namespace) -> CliRequest:
358
+ """argparse の Namespace を CliRequest に変換する。"""
359
+ values = vars(ns)
360
+ if values[_COMMAND] == "abort":
361
+ return CliRequest(
362
+ command="abort",
363
+ track_id=f"{values[_LINE]}:{values[_TRACK]}",
364
+ run_by=values.get("run_by"),
365
+ )
366
+ params = {k: v for k, v in values.items() if not k.startswith("_") and k not in _CONTROL_KEYS}
367
+ return CliRequest(
368
+ command="start",
369
+ track_id=values[_TRACK_ID],
370
+ params=params,
371
+ force=values["force"],
372
+ allow_stale_takeover=values["allow_stale_takeover"],
373
+ stale_after=values["stale_after"],
374
+ run_id=values.get("run_id"),
375
+ run_by=values.get("run_by"),
376
+ dry_run=values["dry_run"],
377
+ output=values.get("output"),
378
+ )
379
+
380
+ def execute(self, request: CliRequest) -> int:
381
+ """parse_args() の結果を受け取ってタスクを実行し、結果を終了コードで返す。
382
+
383
+ sys.exit() は呼ばない(Python コードから呼ぶライブラリ利用を妨げない)。
384
+ スクリプトでは ``sys.exit(cli.execute(request))`` として終了コードを伝える。
385
+ 組み合わせの検証は CliRequest の構築時に済んでいる。
386
+
387
+ ``dry_run=True`` のときは、タスクを呼ばず、``dispatcher.plan()`` の結果を標準出力に出し、
388
+ 本番を同じオプションで実行したときと同じ終了コードを返す(実行できる 0 / 赤信号 3 / stale 4 /
389
+ 判定できない 1)。TrainMiddleware は通らない(ADR-0008)。
390
+
391
+ Args:
392
+ request: parse_args() が返した CliRequest(``dataclasses.replace()`` で加工してよい)。
393
+
394
+ Returns:
395
+ :class:`ExitCode`(int)。成功 0 / 失敗 1 / 赤信号 3 / stale 4 / 見送り 5。
396
+ 引数エラー(2)は parse_args() が argparse の標準動作で終了する。
397
+ """
398
+ if request.command == "abort":
399
+ self.dispatcher.abort(request.track_id, run_by=request.run_by)
400
+ return ExitCode.SUCCESS
401
+
402
+ if request.dry_run:
403
+ plan = self.dispatcher.plan(
404
+ request.track_id,
405
+ request.params,
406
+ force=request.force,
407
+ allow_stale_takeover=request.allow_stale_takeover,
408
+ stale_after=request.stale_after,
409
+ run_by=request.run_by,
410
+ )
411
+ if request.output == "json":
412
+ print(json.dumps(plan.to_dict(), ensure_ascii=False, indent=2, default=str))
413
+ else:
414
+ print(_format_plan_text(plan))
415
+ return _plan_exit_code(plan)
416
+
417
+ result = self.dispatcher.depart(
418
+ request.track_id,
419
+ request.params,
420
+ force=request.force,
421
+ allow_stale_takeover=request.allow_stale_takeover,
422
+ stale_after=request.stale_after,
423
+ run_id=request.run_id,
424
+ run_by=request.run_by,
425
+ )
426
+ return _exit_code(result)
@@ -28,6 +28,7 @@ from .models import (
28
28
  SignalBlockedError,
29
29
  SignalStatus,
30
30
  StaleLockError,
31
+ TrainPlan,
31
32
  TrainRecord,
32
33
  TrainResult,
33
34
  )
@@ -35,6 +36,20 @@ from .station import Station
35
36
 
36
37
  logger = logging.getLogger(__name__)
37
38
 
39
+ # フレームワークの CLI オプション(--dry-run / --output)と衝突するため、タスクの引数には使えない(ADR-0008)。
40
+ # 利用者が自分で実装する場合は、track_dry_run のように接頭辞を付ける。
41
+ RESERVED_PARAM_NAMES = frozenset({"dry_run", "output"})
42
+
43
+
44
+ def _check_reserved_params(track_id: str, names: Any) -> None:
45
+ """タスクの引数に予約名が使われていたら TypeError にする。"""
46
+ reserved = sorted(RESERVED_PARAM_NAMES & set(names))
47
+ if reserved:
48
+ raise TypeError(
49
+ f"タスク '{track_id}' の引数 {reserved} はフレームワークの予約名です。"
50
+ f"別の名前にしてください(例: track_{reserved[0]})。"
51
+ )
52
+
38
53
 
39
54
  def _default_run_by() -> str:
40
55
  """実行ユーザー名の既定値。Lambda など passwd に UID がない環境では "unknown"。"""
@@ -341,12 +356,17 @@ class TrainDispatcher:
341
356
  """
342
357
  self.master.abort(track_id)
343
358
  record = self.master.load_record(track_id)
359
+ _run_by = run_by or _default_run_by()
344
360
  record.status = "aborted"
345
361
  record.error_type = None
346
362
  record.error_message = None
347
363
  record.aborted_at = time.time()
348
- record.aborted_by = run_by or _default_run_by()
364
+ record.aborted_by = _run_by
349
365
  self.master.save_record(track_id, record)
366
+ # abort は TrainMiddleware を通らないので、操作の事実をここで記録する(run_id は実行ではないため None)
367
+ logging.LoggerAdapter(
368
+ logger, {"track_id": track_id, "run_id": None, "run_by": _run_by}
369
+ ).info("Aborted: %s by=%s", track_id, _run_by)
350
370
 
351
371
  def load_line(self, line_name: str) -> None:
352
372
  """指定路線を lazy import して registry に登録する。
@@ -366,7 +386,12 @@ class TrainDispatcher:
366
386
  self.load_line(track_id.split(":")[0])
367
387
 
368
388
  def register(self, track_id: str, func: Callable, help: str) -> None:
369
- """タスクを registry に登録する。通常は Line.register_to() から呼ばれる。"""
389
+ """タスクを registry に登録する。通常は Line.register_to() から呼ばれる。
390
+
391
+ Raises:
392
+ TypeError: タスクの引数に予約名(dry_run / output)が使われている場合。
393
+ """
394
+ _check_reserved_params(track_id, _task_signature(func)[1])
370
395
  self.registry[track_id] = (func, help)
371
396
 
372
397
  def task_params(self, track_id: str) -> Tuple[Set[str], Set[str]]:
@@ -379,6 +404,85 @@ class TrainDispatcher:
379
404
  _, names, required = _task_signature(self.registry[track_id][0])
380
405
  return set(names), required
381
406
 
407
+ def plan(
408
+ self,
409
+ track_id: str,
410
+ params: Optional[Dict[str, Any]] = None,
411
+ *,
412
+ force: bool = False,
413
+ allow_stale_takeover: bool = False,
414
+ stale_after: int = 600,
415
+ run_by: Optional[str] = None,
416
+ ) -> TrainPlan:
417
+ """同じオプションで depart() したらどうなるかを、副作用なしで予測する(--dry-run 用)。
418
+
419
+ タスク関数は呼ばず、ロックも取らず、記録も保存しない。TrainMiddleware も通さない。
420
+ 結果は確認した時点のスナップショットで、depart() は実行時にあらためて信号を確認する。
421
+
422
+ Args:
423
+ track_id: タスク識別子("line_name:track_name" 形式)。
424
+ params: タスク関数に渡す引数の dict。depart() と同じ検証を行う。
425
+ force / allow_stale_takeover / stale_after: depart() と同じ意味。
426
+ run_by: 実行ユーザー名。省略時は getpass.getuser()。
427
+
428
+ Raises:
429
+ ValueError: params に未知のキーがある、または必須の引数が不足している場合。
430
+ """
431
+ _params = dict(params or {})
432
+ self._ensure_line_for(track_id)
433
+ _, arg_names, required = _task_signature(self.registry[track_id][0])
434
+
435
+ if unknown := _params.keys() - set(arg_names):
436
+ raise ValueError(
437
+ f"{track_id} に存在しない引数です: {sorted(unknown)} (使用可能: {sorted(arg_names)})"
438
+ )
439
+ if missing := required - _params.keys():
440
+ raise ValueError(f"{track_id} に必須の引数が不足しています: {sorted(missing)}")
441
+
442
+ notes: list = []
443
+ signal: Optional[SignalStatus] = None
444
+ if force:
445
+ outcome = "ready"
446
+ notes.append("--force のため、信号を確認せずに実行します。")
447
+ else:
448
+ try:
449
+ signal = self.master.peek_signal(track_id, stale_after)
450
+ except NotImplementedError as e:
451
+ outcome = "unknown"
452
+ notes.append(f"信号の状態を判定できません: {e}")
453
+ else:
454
+ if signal is SignalStatus.CLEAR:
455
+ outcome = "ready"
456
+ elif signal is SignalStatus.STALE:
457
+ if allow_stale_takeover:
458
+ outcome = "ready"
459
+ notes.append("前回ロックの残骸(stale)を引き継いで実行します。")
460
+ else:
461
+ outcome = "stale"
462
+ notes.append(
463
+ "前回ロックの残骸(stale)があります。abort か --allow-stale-takeover で復旧してください。"
464
+ )
465
+ else:
466
+ outcome = "blocked"
467
+ notes.append("他の列車が運行中です。")
468
+ notes.append("確認した時点のスナップショットです。実行時にあらためて信号を確認します。")
469
+ notes.append("TrainMiddleware は動作しないため、Middleware による見送りは予測に含まれません。")
470
+
471
+ last_stop = self.master.load_record(track_id)
472
+ return TrainPlan(
473
+ track_id=track_id,
474
+ outcome=outcome,
475
+ signal=signal,
476
+ first_departure=last_stop.run_id is None,
477
+ last_stop=last_stop,
478
+ params=_params,
479
+ force=force,
480
+ allow_stale_takeover=allow_stale_takeover,
481
+ stale_after=stale_after,
482
+ run_by=run_by or _default_run_by(),
483
+ notes=notes,
484
+ )
485
+
382
486
  def depart(
383
487
  self,
384
488
  track_id: str,
@@ -129,6 +129,57 @@ class TrainResult:
129
129
  }
130
130
 
131
131
 
132
+ @dataclass
133
+ class TrainPlan:
134
+ """TrainDispatcher.plan() の結果。実行したらどうなるかの予測(スナップショット)。
135
+
136
+ タスク関数は呼ばず、ロックも取らず、記録も保存しない。確認した時点の状態にすぎず、
137
+ 実行時の結果を保証しない。TrainMiddleware は通さないので、Middleware の見送り判断は含まれない。
138
+
139
+ Attributes:
140
+ track_id: タスク識別子。
141
+ outcome: 同じオプションで実行したときの予測。
142
+ ready(実行できる)/ blocked(赤信号)/ stale(前回ロックの残骸)/
143
+ unknown(Station が確認に対応していない、など判定できない)。
144
+ signal: 確認した信号の状態。確認していない(force)・判定できないときは None。
145
+ first_departure: 始発(初回実行)かどうか。
146
+ last_stop: 前回の到着記録。始発時は run_id=None の空の記録。
147
+ params: タスク関数に渡る引数。
148
+ force / allow_stale_takeover / stale_after / run_by: 実行の制御オプション。
149
+ notes: 判定の根拠や注意。人にも AI にも、出力だけで読み取れるようにする。
150
+ """
151
+ track_id: str
152
+ outcome: str
153
+ signal: Optional[SignalStatus]
154
+ first_departure: bool
155
+ last_stop: TrainRecord
156
+ params: Dict[str, Any] = field(default_factory=dict)
157
+ force: bool = False
158
+ allow_stale_takeover: bool = False
159
+ stale_after: int = 600
160
+ run_by: Optional[str] = None
161
+ notes: list = field(default_factory=list)
162
+
163
+ def to_dict(self) -> dict:
164
+ """JSON シリアライズ可能な dict を返す。"""
165
+ return {
166
+ "track_id": self.track_id,
167
+ "outcome": self.outcome,
168
+ "signal": None if self.signal is None else self.signal.value,
169
+ "first_departure": self.first_departure,
170
+ "last_stop": self.last_stop.to_dict(),
171
+ "last_data": self.last_stop.data,
172
+ "params": self.params,
173
+ "options": {
174
+ "force": self.force,
175
+ "allow_stale_takeover": self.allow_stale_takeover,
176
+ "stale_after": self.stale_after,
177
+ "run_by": self.run_by,
178
+ },
179
+ "notes": list(self.notes),
180
+ }
181
+
182
+
132
183
  class SignalBlockedError(Exception):
133
184
  """多重起動が検出されたとき raise される。Train.prepare() から発生する。
134
185
 
@@ -70,6 +70,25 @@ class Station(ABC):
70
70
  def clear_signal(self, track_id: str) -> None:
71
71
  """信号を解放する(Unlock)。"""
72
72
 
73
+ def peek_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
74
+ """信号の状態を、ロックを取らずに確認する(--dry-run 用)。
75
+
76
+ check_signal() と違い、ロックの取得・作成・削除などの副作用を持たない。
77
+ 結果は、確認した時点のスナップショットにすぎない。
78
+
79
+ 抽象メソッドではない(既存のカスタム Station を壊さない)。未対応の Station は
80
+ NotImplementedError を送出し、呼び出し側は「判定できない」と報告する。
81
+
82
+ Returns:
83
+ CLEAR: 今は、ロックが取れる状態。
84
+ BLOCKED: 他の列車が運行中。
85
+ STALE: 前回ロックの残骸。
86
+
87
+ Raises:
88
+ NotImplementedError: この Station は、ロックを取らない確認に対応していない。
89
+ """
90
+ raise NotImplementedError(f"{type(self).__name__} は peek_signal() に対応していません")
91
+
73
92
  @abstractmethod
74
93
  def abort(self, track_id: str) -> None:
75
94
  """信号を強制解除する。
@@ -196,6 +215,17 @@ class LocalStation(Station):
196
215
  return SignalStatus.BLOCKED
197
216
  return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
198
217
 
218
+ def peek_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
219
+ """シグナルファイルを作らず、ロックも保持せずに、信号の状態を確認する。"""
220
+ sig_path, _ = self._get_paths(track_id)
221
+ for _ in range(_MAX_ATTEMPTS):
222
+ if not sig_path.exists():
223
+ return SignalStatus.CLEAR
224
+ status = self._probe_existing(sig_path)
225
+ if status is not None:
226
+ return status
227
+ return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
228
+
199
229
  def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
200
230
  """プロセスが消失して残ったシグナルファイルのロックを取得する。
201
231
 
@@ -363,6 +393,18 @@ class S3Station(Station):
363
393
  return SignalStatus.STALE if elapsed >= stale_after else SignalStatus.BLOCKED
364
394
  return SignalStatus.BLOCKED # 確定できないときは止める側に倒す
365
395
 
396
+ def peek_signal(self, track_id: str, stale_after: int = 600) -> SignalStatus:
397
+ """head_object だけで信号の状態を確認する。PUT も DELETE もしない。"""
398
+ from botocore.exceptions import ClientError
399
+ try:
400
+ response = self._s3.head_object(Bucket=self.bucket, Key=self._signal_key(track_id))
401
+ except ClientError as e:
402
+ if e.response["Error"]["Code"] in ("NoSuchKey", "404"):
403
+ return SignalStatus.CLEAR
404
+ raise
405
+ elapsed = time.time() - response["LastModified"].timestamp()
406
+ return SignalStatus.STALE if elapsed >= stale_after else SignalStatus.BLOCKED
407
+
366
408
  def takeover_signal(self, track_id: str, stale_after: int = 600) -> bool:
367
409
  """stale なシグナルオブジェクトを、ETag を条件に上書きして引き継ぐ。
368
410
 
@@ -1,79 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: interlocking
3
- Version: 0.1.0
4
- Summary: A lightweight task dispatcher with explicit control.
5
- Keywords: task-runner,batch,exclusive-lock,cli,mutex,distributed-lock
6
- Author: alpine
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Classifier: Development Status :: 4 - Beta
10
- Classifier: Intended Audience :: Developers
11
- Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
- Classifier: Topic :: System :: Distributed Computing
13
- Classifier: Topic :: Utilities
14
- Requires-Dist: boto3>1.40.0,<2 ; extra == 'aws'
15
- Requires-Python: >=3.11
16
- Provides-Extra: aws
17
- Description-Content-Type: text/markdown
18
-
19
- # Interlocking
20
-
21
- > One track, one train.
22
- > Easy to run. Explicit to control.
23
-
24
- interlocking は、分散環境におけるバックグラウンドタスクの実行管理フレームワークです。
25
- 名前は鉄道の連動装置(同じ軌道に同時に列車を入れないよう進路を制御する仕組み)に由来し、排他制御を核に据えています。
26
- 鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
27
-
28
- ## 特長
29
-
30
- - **排他制御**: 同じタスクを同時に走らせない。
31
- - **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
32
- - **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
33
- - **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
34
-
35
- ## 最小構成
36
-
37
- ```python
38
- # tasks.py
39
- from typing import Annotated
40
- from interlocking import Line, Coupler
41
-
42
- line = Line()
43
-
44
- @line.track(name="daily", help="日次処理")
45
- def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
46
- last_id = c.last_data.get("last_id", 0)
47
- # ... 処理 ...
48
- return {"last_id": 9999}
49
- ```
50
-
51
- ```python
52
- # main.py
53
- import sys
54
-
55
- from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
56
-
57
- dispatcher = TrainDispatcher(
58
- station=LocalStation(base_path=".interlocking"),
59
- lines={"batch": "tasks:line"},
60
- )
61
-
62
- cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
63
- sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
64
- ```
65
-
66
- ```bash
67
- python main.py batch daily start --target-date 2026-04-01
68
- ```
69
-
70
- ## 開発
71
-
72
- ```bash
73
- uv sync
74
- uv run pytest
75
- ```
76
-
77
- ## ライセンス
78
-
79
- MIT
@@ -1,61 +0,0 @@
1
- # Interlocking
2
-
3
- > One track, one train.
4
- > Easy to run. Explicit to control.
5
-
6
- interlocking は、分散環境におけるバックグラウンドタスクの実行管理フレームワークです。
7
- 名前は鉄道の連動装置(同じ軌道に同時に列車を入れないよう進路を制御する仕組み)に由来し、排他制御を核に据えています。
8
- 鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
9
-
10
- ## 特長
11
-
12
- - **排他制御**: 同じタスクを同時に走らせない。
13
- - **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
14
- - **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
15
- - **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
16
-
17
- ## 最小構成
18
-
19
- ```python
20
- # tasks.py
21
- from typing import Annotated
22
- from interlocking import Line, Coupler
23
-
24
- line = Line()
25
-
26
- @line.track(name="daily", help="日次処理")
27
- def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
28
- last_id = c.last_data.get("last_id", 0)
29
- # ... 処理 ...
30
- return {"last_id": 9999}
31
- ```
32
-
33
- ```python
34
- # main.py
35
- import sys
36
-
37
- from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
38
-
39
- dispatcher = TrainDispatcher(
40
- station=LocalStation(base_path=".interlocking"),
41
- lines={"batch": "tasks:line"},
42
- )
43
-
44
- cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
45
- sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
46
- ```
47
-
48
- ```bash
49
- python main.py batch daily start --target-date 2026-04-01
50
- ```
51
-
52
- ## 開発
53
-
54
- ```bash
55
- uv sync
56
- uv run pytest
57
- ```
58
-
59
- ## ライセンス
60
-
61
- MIT
@@ -1,264 +0,0 @@
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)
File without changes