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.
- interlocking-0.2.1/PKG-INFO +90 -0
- interlocking-0.2.1/README.md +72 -0
- {interlocking-0.1.0 → interlocking-0.2.1}/pyproject.toml +1 -1
- {interlocking-0.1.0 → interlocking-0.2.1}/pyproject.toml.orig +1 -1
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/__init__.py +4 -1
- interlocking-0.2.1/src/interlocking/cli.py +426 -0
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/dispatcher.py +106 -2
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/models.py +51 -0
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/station.py +42 -0
- interlocking-0.1.0/PKG-INFO +0 -79
- interlocking-0.1.0/README.md +0 -61
- interlocking-0.1.0/src/interlocking/cli.py +0 -264
- {interlocking-0.1.0 → interlocking-0.2.1}/LICENSE +0 -0
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/lambda_.py +0 -0
- {interlocking-0.1.0 → interlocking-0.2.1}/src/interlocking/line.py +0 -0
|
@@ -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
|
|
@@ -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 =
|
|
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
|
|
interlocking-0.1.0/PKG-INFO
DELETED
|
@@ -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
|
interlocking-0.1.0/README.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|