interlocking 0.0.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 alpine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: interlocking
3
+ Version: 0.0.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 は、分散環境におけるバックグラウンドタスクの「運行管理」フレームワークです。
25
+ 鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
26
+
27
+ ## 特長
28
+
29
+ - **排他制御**: 同じタスクを同時に走らせない。
30
+ - **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
31
+ - **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
32
+ - **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
33
+
34
+ ## 最小構成
35
+
36
+ ```python
37
+ # tasks.py
38
+ from typing import Annotated
39
+ from interlocking import Line, Coupler
40
+
41
+ line = Line()
42
+
43
+ @line.track(name="daily", help="日次処理")
44
+ def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
45
+ last_id = c.last_data.get("last_id", 0)
46
+ # ... 処理 ...
47
+ return {"last_id": 9999}
48
+ ```
49
+
50
+ ```python
51
+ # main.py
52
+ import sys
53
+
54
+ from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
55
+
56
+ dispatcher = TrainDispatcher(
57
+ station=LocalStation(base_path=".interlocking"),
58
+ lines={"batch": "tasks:line"},
59
+ )
60
+
61
+ cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
62
+ sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
63
+ ```
64
+
65
+ ```bash
66
+ python main.py batch daily start --target-date 2026-04-01
67
+ ```
68
+
69
+ 詳しくは [docs/user/interlocking.md](docs/user/interlocking.md) を参照してください。
70
+
71
+ ## 開発
72
+
73
+ ```bash
74
+ uv sync
75
+ uv run pytest
76
+ ```
77
+
78
+ 設計方針は [.github/copilot-instructions.md](.github/copilot-instructions.md) の Design Principles、決定事項は [docs/dev/adr/](docs/dev/adr/) を参照してください。
79
+
80
+ ## ライセンス
81
+
82
+ MIT
@@ -0,0 +1,64 @@
1
+ # Interlocking
2
+
3
+ > One track, one train.
4
+ > Easy to run. Explicit to control.
5
+
6
+ interlocking は、分散環境におけるバックグラウンドタスクの「運行管理」フレームワークです。
7
+ 鉄道メタファーにより、バッチタスクのライフサイクル(排他制御・実行記録・CLI 統合)を統一された方法で管理します。
8
+
9
+ ## 特長
10
+
11
+ - **排他制御**: 同じタスクを同時に走らせない。
12
+ - **実行記録**: 直近の実行結果を記録し、次回実行に引き継ぐ(`Coupler`)。
13
+ - **CLI 統合**: 型ヒントからコマンドラインを組み立て、結果を終了コードで返す。
14
+ - **差し込み**: `Station`(ストレージ)や `TrainMiddleware` で、振る舞いを拡張できる。
15
+
16
+ ## 最小構成
17
+
18
+ ```python
19
+ # tasks.py
20
+ from typing import Annotated
21
+ from interlocking import Line, Coupler
22
+
23
+ line = Line()
24
+
25
+ @line.track(name="daily", help="日次処理")
26
+ def daily_task(c: Coupler, target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"]) -> dict:
27
+ last_id = c.last_data.get("last_id", 0)
28
+ # ... 処理 ...
29
+ return {"last_id": 9999}
30
+ ```
31
+
32
+ ```python
33
+ # main.py
34
+ import sys
35
+
36
+ from interlocking import TrainDispatcher, LocalStation, InterlockingCLI
37
+
38
+ dispatcher = TrainDispatcher(
39
+ station=LocalStation(base_path=".interlocking"),
40
+ lines={"batch": "tasks:line"},
41
+ )
42
+
43
+ cli = InterlockingCLI(dispatcher=dispatcher, prog="my-task", description="バッチタスク")
44
+ sys.exit(cli.execute(cli.parse_args())) # 結果を終了コードで伝える
45
+ ```
46
+
47
+ ```bash
48
+ python main.py batch daily start --target-date 2026-04-01
49
+ ```
50
+
51
+ 詳しくは [docs/user/interlocking.md](docs/user/interlocking.md) を参照してください。
52
+
53
+ ## 開発
54
+
55
+ ```bash
56
+ uv sync
57
+ uv run pytest
58
+ ```
59
+
60
+ 設計方針は [.github/copilot-instructions.md](.github/copilot-instructions.md) の Design Principles、決定事項は [docs/dev/adr/](docs/dev/adr/) を参照してください。
61
+
62
+ ## ライセンス
63
+
64
+ MIT
@@ -0,0 +1,84 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.5,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "interlocking"
7
+ version = "0.0.1"
8
+ description = "A lightweight task dispatcher with explicit control."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = []
14
+ keywords = [
15
+ "task-runner",
16
+ "batch",
17
+ "exclusive-lock",
18
+ "cli",
19
+ "mutex",
20
+ "distributed-lock",
21
+ ]
22
+ classifiers = [
23
+ "Development Status :: 4 - Beta",
24
+ "Intended Audience :: Developers",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Topic :: System :: Distributed Computing",
27
+ "Topic :: Utilities",
28
+ ]
29
+
30
+ [[project.authors]]
31
+ name = "alpine"
32
+
33
+ [project.scripts]
34
+
35
+ [project.optional-dependencies]
36
+ aws = ["boto3>1.40.0,<2"]
37
+
38
+ [dependency-groups]
39
+ dev = [
40
+ "interlocking[aws]",
41
+ "ruff",
42
+ "pyyaml>=6.0.3",
43
+ "pytest>=7.0.0",
44
+ "pytest-mock>=3.10.0",
45
+ "pytest-env>=1.2.0",
46
+ "moto[s3]>=5.2.5",
47
+ ]
48
+
49
+ [tool.uv]
50
+ package = true
51
+
52
+ [tool.ruff]
53
+ src = ["src"]
54
+
55
+ [tool.ruff.lint]
56
+ select = [
57
+ "E2",
58
+ "E4",
59
+ "E7",
60
+ "E9",
61
+ "W",
62
+ "F",
63
+ "INP",
64
+ ]
65
+
66
+ [tool.ruff.lint.per-file-ignores]
67
+ "docker/*.py" = ["INP"]
68
+ "examples/**/*.py" = ["INP"]
69
+ "scripts/*.py" = ["INP"]
70
+ "tests/*.py" = ["INP"]
71
+ "var/*.py" = ["INP"]
72
+
73
+ [tool.pytest.ini_options]
74
+ cache_dir = "var/.pytest_cache"
75
+ asyncio_mode = "auto"
76
+ testpaths = ["tests"]
77
+ pythonpath = ["."]
78
+ addopts = "-m 'not moto and not seaweedfs'"
79
+ markers = [
80
+ "moto: moto server(実 HTTP)を使う結合テスト。既定では実行しない(`pytest -m moto` で実行)",
81
+ "seaweedfs: ローカルの SeaweedFS(S3 互換)を使う結合テスト。既定では実行しない(`pytest -m seaweedfs` で実行)",
82
+ ]
83
+
84
+ [tool.pytest_env]
@@ -0,0 +1,102 @@
1
+ [build-system]
2
+ requires = ["uv_build>=0.12.5,<0.13.0"]
3
+ build-backend = "uv_build"
4
+
5
+ [project]
6
+ name = "interlocking"
7
+ version = "0.0.1"
8
+ description = "A lightweight task dispatcher with explicit control."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = [
14
+ ]
15
+ authors = [
16
+ { name = "alpine" },
17
+ ]
18
+ keywords = [
19
+ "task-runner",
20
+ "batch",
21
+ "exclusive-lock",
22
+ "cli",
23
+ "mutex",
24
+ "distributed-lock",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "Topic :: Software Development :: Libraries :: Python Modules",
30
+ "Topic :: System :: Distributed Computing",
31
+ "Topic :: Utilities",
32
+ ]
33
+ [project.scripts]
34
+
35
+
36
+ [project.optional-dependencies]
37
+ aws = [
38
+ "boto3>1.40.0,<2",
39
+ ]
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "interlocking[aws]",
44
+ # -- ruff --
45
+ "ruff",
46
+ #-- dev-docs skill --
47
+ "pyyaml>=6.0.3",
48
+ # pytest
49
+ "pytest>=7.0.0",
50
+ "pytest-mock>=3.10.0",
51
+ "pytest-env>=1.2.0",
52
+ "moto[s3]>=5.2.5",
53
+ ]
54
+
55
+
56
+ [tool.uv]
57
+ package = true
58
+
59
+ [tool.ruff]
60
+ # https://docs.astral.sh/ruff/configuration/
61
+ src = ["src"]
62
+
63
+ [tool.ruff.lint]
64
+ # https://docs.astral.sh/ruff/rules/
65
+ select = [
66
+ # pycodestyle (E, W)
67
+ "E2",
68
+ "E4",
69
+ "E7",
70
+ "E9",
71
+ "W",
72
+
73
+ # Pyflakes (F)
74
+ "F",
75
+
76
+ # flake8-no-pep420 (INP)
77
+ "INP",
78
+
79
+ # pep8-naming (N)
80
+ #"N",
81
+ ]
82
+
83
+ [tool.ruff.lint.per-file-ignores]
84
+ "docker/*.py" = ["INP"]
85
+ "examples/**/*.py" = ["INP"]
86
+ "scripts/*.py" = ["INP"]
87
+ "tests/*.py" = ["INP"]
88
+ "var/*.py" = ["INP"]
89
+
90
+
91
+ [tool.pytest.ini_options]
92
+ cache_dir = "var/.pytest_cache"
93
+ asyncio_mode = "auto"
94
+ testpaths = ["tests"]
95
+ pythonpath = ["."]
96
+ addopts = "-m 'not moto and not seaweedfs'"
97
+ markers = [
98
+ "moto: moto server(実 HTTP)を使う結合テスト。既定では実行しない(`pytest -m moto` で実行)",
99
+ "seaweedfs: ローカルの SeaweedFS(S3 互換)を使う結合テスト。既定では実行しない(`pytest -m seaweedfs` で実行)",
100
+ ]
101
+
102
+ [tool.pytest_env]
@@ -0,0 +1,49 @@
1
+ """
2
+ interlocking
3
+ =======
4
+ One track, one train.
5
+ Easy to run. Explicit to control.
6
+
7
+ 分散環境におけるバックグラウンドタスクの「運行管理」フレームワーク。
8
+
9
+ 公開 API::
10
+
11
+ from interlocking import Line, Coupler
12
+ from interlocking import InterlockingCLI, TrainDispatcher, LocalStation
13
+ """
14
+ from .cli import ExitCode, InterlockingCLI
15
+ from .dispatcher import Train, TrainDispatcher, TrainMiddleware
16
+ from .line import Line
17
+ from .models import (
18
+ Coupler,
19
+ SignalBlockedError,
20
+ SignalStatus,
21
+ StaleLockError,
22
+ TrainRecord,
23
+ TrainResult,
24
+ )
25
+ from .station import LocalStation, S3Station, Station
26
+
27
+ __all__ = [
28
+ # CLI ビルダー
29
+ "InterlockingCLI",
30
+ "ExitCode",
31
+ # 路線定義(Router)
32
+ "Line",
33
+ # モデル
34
+ "Coupler",
35
+ "TrainRecord",
36
+ "TrainResult",
37
+ # ディスパッチャー
38
+ "Train",
39
+ "TrainDispatcher",
40
+ "TrainMiddleware",
41
+ # 例外
42
+ "SignalBlockedError",
43
+ "StaleLockError",
44
+ # ストレージ
45
+ "Station",
46
+ "SignalStatus",
47
+ "LocalStation",
48
+ "S3Station",
49
+ ]
@@ -0,0 +1,264 @@
1
+ """
2
+ interlocking.cli
3
+ ===========
4
+ interlocking を使った CLI の共通ビルダー。
5
+
6
+ argparse の2ステップインターフェースに準拠する::
7
+
8
+ cli = InterlockingCLI(
9
+ dispatcher=dispatcher,
10
+ prog="sails-task2",
11
+ description="SAILSタスク実行コマンド",
12
+ )
13
+ args = cli.parse_args() # パーサー構築 + 引数解析
14
+ context.setup_logging(args.log_config)
15
+ sys.exit(cli.execute(args)) # タスク実行。結果を終了コード(ExitCode)で返す
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import inspect
21
+ import sys
22
+ from enum import IntEnum
23
+ from typing import (
24
+ Any,
25
+ Callable,
26
+ Dict,
27
+ Optional,
28
+ Tuple,
29
+ get_args,
30
+ get_origin,
31
+ get_type_hints,
32
+ )
33
+
34
+ from .dispatcher import TrainDispatcher
35
+ from .models import Coupler, TrainResult
36
+
37
+
38
+ class ExitCode(IntEnum):
39
+ """CLI の終了コード。cron など上位のオーケストレーターが結果を判別するための規約。"""
40
+
41
+ SUCCESS = 0
42
+ FAILED = 1 # タスクが失敗した
43
+ USAGE = 2 # argparse の引数エラー(argparse が返す。interlocking はこの値を他の用途に使わない)
44
+ BLOCKED = 3 # 赤信号(他の列車が運行中)で実行されなかった
45
+ STALE = 4 # 前回ロックの残骸(stale)で実行されなかった。復旧が必要
46
+ SKIPPED = 5 # Middleware が実行を見送った
47
+
48
+
49
+ def _exit_code(result: TrainResult) -> ExitCode:
50
+ """depart() の結果を終了コードに変換する。判断できないときは失敗扱いにする。"""
51
+ if result.status == "success":
52
+ return ExitCode.SUCCESS
53
+ if result.status == "blocked":
54
+ return ExitCode.STALE if result.is_stale else ExitCode.BLOCKED
55
+ if result.status == "skipped":
56
+ return ExitCode.SKIPPED
57
+ return ExitCode.FAILED
58
+
59
+ # InterlockingCLI 内部で使用する共通キー(params の抄出時に除外)
60
+ _INTERLOCKING_PARAM_KEYS: frozenset = frozenset({
61
+ "command", "track_id", "line", "force", "allow_stale_takeover", "stale_after", "run_id", "run_by", "parser",
62
+ })
63
+
64
+
65
+ def _setup_argparsers(registry: Dict[str, Tuple[Callable, str]], parsers: Any, line_name: Optional[str] = None) -> None:
66
+ """路線 registry から argparse サブパーサーを構築する。
67
+
68
+ 関数の型ヒント (Annotated[Type, "help"]) から引数定義を自動生成する。
69
+ Annotated でない引数(Coupler 以外)はエラーとなる。
70
+
71
+ Args:
72
+ registry: TrainDispatcher.registry(line_name でフィルタ済みを推奨)。
73
+ parsers: argparse の add_subparsers() の戻り値。
74
+ line_name: 指定時は "line_name:track_name" の track_name 部分をサブコマンド名にする。
75
+ """
76
+ for track_id, (func, help_text) in registry.items():
77
+ # InterlockingCLI 経由の場合は track_name 部分のみをサブコマンド名にする
78
+ display_name = track_id.split(":", 1)[1] if line_name and ":" in track_id else track_id
79
+ parser = parsers.add_parser(display_name, help=help_text)
80
+ parser.set_defaults(track_id=track_id)
81
+ parser.add_argument("command", choices=["start", "abort"])
82
+
83
+ try:
84
+ type_hints = get_type_hints(func, include_extras=True)
85
+ except Exception:
86
+ type_hints = {}
87
+
88
+ sig = inspect.signature(func)
89
+
90
+ for param_name, param in sig.parameters.items():
91
+ hint = param.annotation if param.annotation is not inspect.Parameter.empty else None
92
+ type_hint = type_hints.get(param_name)
93
+ if type_hint is not None:
94
+ hint = type_hint
95
+ if hint is None:
96
+ continue
97
+
98
+ is_coupler = False
99
+ try:
100
+ if hint is Coupler:
101
+ is_coupler = True
102
+ elif hasattr(hint, '__name__') and hint.__name__ == 'Coupler':
103
+ is_coupler = True
104
+ elif isinstance(hint, str) and hint == 'Coupler':
105
+ is_coupler = True
106
+ except Exception:
107
+ pass
108
+ if is_coupler:
109
+ continue
110
+
111
+ origin = get_origin(hint)
112
+ has_metadata = hasattr(hint, '__metadata__')
113
+ import typing
114
+ is_annotated = (
115
+ has_metadata or
116
+ (origin is not None and (
117
+ origin is getattr(typing, 'Annotated', None) or
118
+ str(origin) == 'typing.Annotated'
119
+ ))
120
+ )
121
+ if not is_annotated:
122
+ raise TypeError(
123
+ f"タスク '{track_id}' の引数 '{param_name}' には "
124
+ f"Annotated[Type, 'ヘルプメッセージ'] が必要です。"
125
+ )
126
+
127
+ args = get_args(hint)
128
+ actual_type = args[0]
129
+ help_msg = hint.__metadata__[0] if hint.__metadata__ else ""
130
+ arg_default = (
131
+ param.default if param.default is not inspect.Parameter.empty else None
132
+ )
133
+ if actual_type is bool:
134
+ parser.add_argument(f"--{param_name}", action="store_true", help=help_msg)
135
+ else:
136
+ parser.add_argument(f"--{param_name}", type=actual_type, default=arg_default, help=help_msg)
137
+
138
+ parser.add_argument("--force", action="store_true", help="信号を無視して実行する(安全装置をすべて解除する)")
139
+ parser.add_argument(
140
+ "--allow-stale-takeover", dest="allow_stale_takeover", action="store_true",
141
+ help="stale(前回ロックの残骸)なロックだけを引き継いで実行する",
142
+ )
143
+ parser.add_argument(
144
+ "--stale-after", dest="stale_after", type=int, default=600,
145
+ help="この秒数が経過したロックを stale(前回ロックの残骸)とみなす (default=600)",
146
+ )
147
+ parser.add_argument("--run-by", dest="run_by", default=None, help="実行ユーザー名 (記録用)")
148
+ parser.add_argument("--run-id", dest="run_id", default=None, help="実行ID (記録用)")
149
+
150
+
151
+ class InterlockingCLI:
152
+ """interlocking CLI のビルダー。
153
+
154
+ argparse の2ステップに準拠した設計:
155
+ parse_args() でパーサーを構築して引数を解析し、
156
+ execute() でタスクを実行する。
157
+
158
+ 3フェーズでパーサーを構築する:
159
+ Phase 1 (Discovery): dispatcher.lines のキーだけでトップレベルパーサーを構成。
160
+ Phase 2 (Loading): 指定路線のモジュールを import し line 属性を取得。
161
+ line.register_to(dispatcher) でタスクを登録する。
162
+ Phase 3 (Mapping): 登録済みタスクから詳細パーサーを構築する。
163
+
164
+ Args:
165
+ dispatcher: 構築済みの TrainDispatcher。
166
+ prog: CLI プログラム名(argparse の prog)。
167
+ description: CLI の説明文。
168
+ extra_arguments: ルートパーサーに追加する引数を設定するコールバック。
169
+ """
170
+
171
+ def __init__(
172
+ self,
173
+ dispatcher: TrainDispatcher,
174
+ prog: str = "interlocking",
175
+ description: str = "",
176
+ extra_arguments: Optional[Callable[[argparse.ArgumentParser], None]] = None,
177
+ ) -> None:
178
+ self.dispatcher = dispatcher
179
+ self.prog = prog
180
+ self.description = description
181
+ self.extra_arguments = extra_arguments
182
+
183
+ def parse_args(self, argv: Optional[list] = None) -> argparse.Namespace:
184
+ """パーサーを構築して引数を解析する。
185
+
186
+ argparse の parse_args() に相当するステップ。
187
+ 戻り値の Namespace を execute() に渡すことでタスクを実行する。
188
+
189
+ Args:
190
+ argv: パース対象の引数リスト。None のとき sys.argv[1:] を使用。
191
+
192
+ Returns:
193
+ 解析済みの argparse.Namespace。
194
+ """
195
+ _argv = argv if argv is not None else sys.argv[1:]
196
+
197
+ root = argparse.ArgumentParser(prog=self.prog, description=self.description)
198
+ root.set_defaults(parser=root, track_id=None)
199
+
200
+ if self.extra_arguments:
201
+ self.extra_arguments(root)
202
+
203
+ line_subparsers = root.add_subparsers(dest="line", help="line name")
204
+
205
+ # Phase 1: 路線名だけでパーサーを構成(import なし)
206
+ line_parsers: Dict[str, argparse.ArgumentParser] = {}
207
+ for line in self.dispatcher.lines:
208
+ lp = line_subparsers.add_parser(line)
209
+ lp.set_defaults(parser=lp, track_id=None)
210
+ line_parsers[line] = lp
211
+
212
+ # Phase 2: 指定路線をロードし登録
213
+ temp_parser = argparse.ArgumentParser(add_help=False)
214
+ if self.extra_arguments:
215
+ self.extra_arguments(temp_parser)
216
+ temp_parser.add_argument('line', nargs='?', choices=list(self.dispatcher.lines))
217
+ temp_args, _ = temp_parser.parse_known_args(_argv)
218
+ if temp_args.line:
219
+ self.dispatcher.load_line(temp_args.line)
220
+
221
+ # Phase 3: 登録済みタスクから詳細パーサーを構築。路線別にフィルタして track_name だけをサブコマンド名にする。
222
+ for line, lp in line_parsers.items():
223
+ track_subparsers = lp.add_subparsers(dest="track_id", help="タスク名")
224
+ line_registry = {
225
+ track_id: entry
226
+ for track_id, entry in self.dispatcher.registry.items()
227
+ if track_id.startswith(f"{line}:")
228
+ }
229
+ _setup_argparsers(line_registry, track_subparsers, line)
230
+
231
+ return root.parse_args(_argv)
232
+
233
+ def execute(self, args: argparse.Namespace) -> int:
234
+ """parse_args() の結果を受け取ってタスクを実行し、結果を終了コードで返す。
235
+
236
+ sys.exit() は呼ばない(Python コードから呼ぶライブラリ利用を妨げない)。
237
+ スクリプトでは ``sys.exit(cli.execute(args))`` として終了コードを伝える。
238
+
239
+ Args:
240
+ args: parse_args() が返した Namespace。
241
+
242
+ Returns:
243
+ :class:`ExitCode`(int)。成功 0 / 失敗 1 / 赤信号 3 / stale 4 / 見送り 5。
244
+ 引数エラー(2)は parse_args() が argparse の標準動作で終了する。
245
+ """
246
+ if args.track_id is None:
247
+ args.parser.print_help()
248
+ return ExitCode.SUCCESS
249
+
250
+ if args.command == "abort":
251
+ self.dispatcher.abort(args.track_id, run_by=getattr(args, "run_by", None))
252
+ return ExitCode.SUCCESS
253
+
254
+ params = {k: v for k, v in vars(args).items() if k not in _INTERLOCKING_PARAM_KEYS}
255
+ result = self.dispatcher.depart(
256
+ args.track_id,
257
+ params,
258
+ force=args.force,
259
+ allow_stale_takeover=args.allow_stale_takeover,
260
+ stale_after=args.stale_after,
261
+ run_id=getattr(args, "run_id", None),
262
+ run_by=getattr(args, "run_by", None),
263
+ )
264
+ return _exit_code(result)