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.
- interlocking-0.0.1/LICENSE +21 -0
- interlocking-0.0.1/PKG-INFO +82 -0
- interlocking-0.0.1/README.md +64 -0
- interlocking-0.0.1/pyproject.toml +84 -0
- interlocking-0.0.1/pyproject.toml.orig +102 -0
- interlocking-0.0.1/src/interlocking/__init__.py +49 -0
- interlocking-0.0.1/src/interlocking/cli.py +264 -0
- interlocking-0.0.1/src/interlocking/dispatcher.py +471 -0
- interlocking-0.0.1/src/interlocking/lambda_.py +186 -0
- interlocking-0.0.1/src/interlocking/line.py +78 -0
- interlocking-0.0.1/src/interlocking/models.py +191 -0
- interlocking-0.0.1/src/interlocking/station.py +428 -0
|
@@ -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)
|