interlocking 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interlocking/__init__.py +49 -0
- interlocking/cli.py +264 -0
- interlocking/dispatcher.py +471 -0
- interlocking/lambda_.py +186 -0
- interlocking/line.py +78 -0
- interlocking/models.py +191 -0
- interlocking/station.py +428 -0
- interlocking-0.0.1.dist-info/METADATA +82 -0
- interlocking-0.0.1.dist-info/RECORD +12 -0
- interlocking-0.0.1.dist-info/WHEEL +4 -0
- interlocking-0.0.1.dist-info/entry_points.txt +2 -0
- interlocking-0.0.1.dist-info/licenses/LICENSE +21 -0
interlocking/line.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interlocking.line
|
|
3
|
+
============
|
|
4
|
+
Line クラス。FastAPI の APIRouter に相当する「路線定義」。
|
|
5
|
+
|
|
6
|
+
Station・TrainDispatcher に依存しないため、モジュールレベルで安全にインスタンス化できる。
|
|
7
|
+
タスクモジュールで line = Line() を生成し @line.track() でタスクを定義する。
|
|
8
|
+
InterlockingCLI の Phase 2 で line.register_to(dispatcher, line_name) が呼ばれ TrainDispatcher に登録される。
|
|
9
|
+
track_id は register_to 時に "line_name:track_name" 形式で自動構築される。
|
|
10
|
+
|
|
11
|
+
使用例::
|
|
12
|
+
|
|
13
|
+
# tasks.py
|
|
14
|
+
from interlocking import Line, Coupler
|
|
15
|
+
from typing import Annotated
|
|
16
|
+
|
|
17
|
+
line = Line()
|
|
18
|
+
|
|
19
|
+
@line.track(name="daily", help="日次レポート生成")
|
|
20
|
+
def daily_report(
|
|
21
|
+
c: Coupler,
|
|
22
|
+
target_date: Annotated[str, "対象日付 (YYYY-MM-DD)"],
|
|
23
|
+
) -> dict:
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
# registry.py
|
|
27
|
+
LINES = {
|
|
28
|
+
"report": "myapp.tasks:line",
|
|
29
|
+
}
|
|
30
|
+
# → track_id = "report:daily" として登録される
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import TYPE_CHECKING, Callable, List
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .dispatcher import TrainDispatcher
|
|
38
|
+
|
|
39
|
+
# registry の値の型: (func, help)
|
|
40
|
+
_LineEntry = tuple[Callable, str]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Line:
|
|
44
|
+
"""路線定義。タスクを収集し TrainDispatcher に登録する。
|
|
45
|
+
|
|
46
|
+
Station に依存しないため、モジュールレベルで安全に生成できる。
|
|
47
|
+
InterlockingCLI が Phase 2 でモジュールをロードし、
|
|
48
|
+
line.register_to(dispatcher) を呼んで TrainDispatcher に委譲する。
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._entries: List[tuple[str, _LineEntry]] = []
|
|
53
|
+
|
|
54
|
+
def track(self, name: str, help: str = "") -> Callable:
|
|
55
|
+
"""タスク定義デコレーター。
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
name: タスク名(路線内で一意な短い名前)。"line_name:" プレフィックスは不要。
|
|
59
|
+
help: ヘルプ文字列。
|
|
60
|
+
"""
|
|
61
|
+
def decorator(func: Callable) -> Callable:
|
|
62
|
+
self._entries.append((name, (func, help)))
|
|
63
|
+
return func
|
|
64
|
+
return decorator
|
|
65
|
+
|
|
66
|
+
def register_to(self, dispatcher: "TrainDispatcher", line_name: str) -> None:
|
|
67
|
+
"""収集したタスクを TrainDispatcher に登録する。
|
|
68
|
+
|
|
69
|
+
track_id を "line_name:track_name" 形式で構築して登録する。
|
|
70
|
+
InterlockingCLI の Phase 2 から呼ばれる。
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
dispatcher: 登録先のディスパッチャー。
|
|
74
|
+
line_name: LINES で定義された路線名("report" など)。
|
|
75
|
+
"""
|
|
76
|
+
for track_name, (func, help_text) in self._entries:
|
|
77
|
+
track_id = f"{line_name}:{track_name}"
|
|
78
|
+
dispatcher.register(track_id, func, help_text)
|
interlocking/models.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""
|
|
2
|
+
interlocking.models
|
|
3
|
+
==============
|
|
4
|
+
コアデータモデル。TrainRecord と Coupler を定義する。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from typing import Any, Dict, Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SignalStatus(Enum):
|
|
14
|
+
"""Station.check_signal() が報告するロック状態。
|
|
15
|
+
|
|
16
|
+
Station は状態を報告するだけで、例外の送出や引き継ぎの判断は Train が行う。
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
CLEAR = "clear" # 進行可(ロックを取得した)
|
|
20
|
+
BLOCKED = "blocked" # 通常の赤信号(他の列車が運行中)
|
|
21
|
+
STALE = "stale" # 前回ロックの残骸(ロックは未取得。引き継ぎ待ち)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class TrainRecord:
|
|
26
|
+
"""列車の運行記録。タスクごとの直近 1 回分の状態(Simple State)。
|
|
27
|
+
|
|
28
|
+
タスク完了時(成功・失敗)に保存される。abort は status を aborted に更新し、aborted_at / aborted_by を
|
|
29
|
+
記録する。data / run_id などは保持し(Coupler.last_data を失わない)、error_* はクリアする。
|
|
30
|
+
abort されたハング中の実行は run_id を持たないため、run_id は最後に完了した実行のもの。
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
track_id: タスク識別子("line_name:track_name" 形式)。
|
|
34
|
+
run_id: 実行ID。始発(初回実行)では None。
|
|
35
|
+
run_by: 実行ユーザー。始発では None。
|
|
36
|
+
track_help: タスクの説明文(@track の help から設定される)。
|
|
37
|
+
data: タスクの戻り値。次回実行時に Coupler.last_data で参照される。
|
|
38
|
+
status: 実行状態。idle / running / success / failed / aborted。
|
|
39
|
+
started_at: 開始時刻(Unix タイムスタンプ)。
|
|
40
|
+
arrived_at: 到着時刻(Unix タイムスタンプ)。
|
|
41
|
+
force: force=True(安全装置をすべて解除)で実行された。
|
|
42
|
+
allow_stale_takeover: allow_stale_takeover=True で実行された。
|
|
43
|
+
error_type: 失敗したときの例外の型名。
|
|
44
|
+
error_message: 失敗したときの例外メッセージ。
|
|
45
|
+
aborted_at: abort を実行した時刻(Unix タイムスタンプ)。未実行は 0。
|
|
46
|
+
aborted_by: abort を実行したユーザー。
|
|
47
|
+
extra: このバージョンが知らないキー。Station 拡張や新しいバージョンの
|
|
48
|
+
記録を読んでも壊れないよう保持し、保存時にそのまま書き戻す。
|
|
49
|
+
"""
|
|
50
|
+
track_id: str
|
|
51
|
+
run_id: Optional[str] = None
|
|
52
|
+
run_by: Optional[str] = None
|
|
53
|
+
track_help: Optional[str] = None
|
|
54
|
+
data: Optional[dict] = None
|
|
55
|
+
status: str = "idle"
|
|
56
|
+
started_at: float = 0.0
|
|
57
|
+
arrived_at: float = 0.0
|
|
58
|
+
force: bool = False
|
|
59
|
+
allow_stale_takeover: bool = False
|
|
60
|
+
error_type: Optional[str] = None
|
|
61
|
+
error_message: Optional[str] = None
|
|
62
|
+
aborted_at: float = 0.0
|
|
63
|
+
aborted_by: Optional[str] = None
|
|
64
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def aborted(self) -> bool:
|
|
68
|
+
"""abort が実行されたかどうか。"""
|
|
69
|
+
return self.aborted_at > 0
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, data: Dict[str, Any]) -> "TrainRecord":
|
|
73
|
+
"""dict から復元する。未知のキーは extra に保持し、エラーにしない。"""
|
|
74
|
+
known = {f.name for f in fields(cls)}
|
|
75
|
+
kwargs = {k: v for k, v in data.items() if k in known}
|
|
76
|
+
unknown = {k: v for k, v in data.items() if k not in known}
|
|
77
|
+
extra = dict(kwargs.pop("extra", None) or {})
|
|
78
|
+
extra.update(unknown)
|
|
79
|
+
return cls(**kwargs, extra=extra)
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
82
|
+
"""JSON シリアライズ可能な dict を返す。extra はトップレベルに展開する。"""
|
|
83
|
+
data = asdict(self)
|
|
84
|
+
extra = data.pop("extra")
|
|
85
|
+
return {**extra, **data}
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def elapsed(self) -> float:
|
|
89
|
+
"""経過時間(秒)。started_at と arrived_at から計算される。"""
|
|
90
|
+
if self.arrived_at > 0 and self.started_at > 0:
|
|
91
|
+
return round(self.arrived_at - self.started_at, 1)
|
|
92
|
+
return 0.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class TrainResult:
|
|
97
|
+
"""TrainDispatcher.depart() の実行結果。
|
|
98
|
+
|
|
99
|
+
Attributes:
|
|
100
|
+
track_id: タスク識別子。
|
|
101
|
+
run_id: 実行ID。
|
|
102
|
+
status: success / failed / blocked / skipped。
|
|
103
|
+
blocked は赤信号(多重起動)で実行されなかった場合、
|
|
104
|
+
skipped は Middleware が prepare()/run() を呼ばなかった場合。
|
|
105
|
+
data: タスクの戻り値(dict 以外・未実行の場合は None)。
|
|
106
|
+
elapsed: 経過時間(秒)。
|
|
107
|
+
error: failed のときの例外。シリアライズ時は文字列化される。
|
|
108
|
+
is_stale: blocked のうち、前回ロックの残骸(stale)が原因だった場合 True。
|
|
109
|
+
復旧には abort か allow_stale_takeover が必要。
|
|
110
|
+
"""
|
|
111
|
+
track_id: str
|
|
112
|
+
run_id: Optional[str]
|
|
113
|
+
status: str
|
|
114
|
+
data: Optional[dict] = None
|
|
115
|
+
elapsed: float = 0.0
|
|
116
|
+
error: Optional[BaseException] = None
|
|
117
|
+
is_stale: bool = False
|
|
118
|
+
|
|
119
|
+
def to_dict(self) -> dict:
|
|
120
|
+
"""JSON シリアライズ可能な dict を返す(Lambda の戻り値など)。"""
|
|
121
|
+
return {
|
|
122
|
+
"track_id": self.track_id,
|
|
123
|
+
"run_id": self.run_id,
|
|
124
|
+
"status": self.status,
|
|
125
|
+
"data": self.data,
|
|
126
|
+
"elapsed": self.elapsed,
|
|
127
|
+
"error": None if self.error is None else f"{type(self.error).__name__}: {self.error}",
|
|
128
|
+
"is_stale": self.is_stale,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class SignalBlockedError(Exception):
|
|
133
|
+
"""多重起動が検出されたとき raise される。Train.prepare() から発生する。
|
|
134
|
+
|
|
135
|
+
Attributes:
|
|
136
|
+
track_id: ブロックされたタスク識別子("line_name:track_name" 形式)。
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, track_id: str) -> None:
|
|
140
|
+
self.track_id = track_id
|
|
141
|
+
super().__init__(f"Signal RED: {track_id} is already in operation.")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class StaleLockError(SignalBlockedError):
|
|
145
|
+
"""前回ロックの残骸(stale)が検出されたとき raise される。
|
|
146
|
+
|
|
147
|
+
赤信号の一種。自動では引き継がない。復旧は abort か allow_stale_takeover で行う。
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, track_id: str) -> None:
|
|
151
|
+
self.track_id = track_id
|
|
152
|
+
Exception.__init__(
|
|
153
|
+
self,
|
|
154
|
+
f"Signal RED (stale): {track_id} has a leftover lock from a previous run. "
|
|
155
|
+
"Recover with abort, or run with allow_stale_takeover.",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class Coupler:
|
|
161
|
+
"""連結器。今回(current)と前回(last_stop)の TrainRecord を繋ぐ。
|
|
162
|
+
|
|
163
|
+
列車が連結されているイメージ。始発(初回実行)では last_stop は
|
|
164
|
+
run_id=None の空の TrainRecord。
|
|
165
|
+
|
|
166
|
+
Attributes:
|
|
167
|
+
current: 今回の出発記録(run_id, run_by など)。
|
|
168
|
+
last_stop: 前回の到着記録。始発時は空の TrainRecord。
|
|
169
|
+
stale_lock: stale ロックを allow_stale_takeover で引き継いで実行した場合 True。
|
|
170
|
+
"""
|
|
171
|
+
current: TrainRecord
|
|
172
|
+
last_stop: TrainRecord
|
|
173
|
+
stale_lock: bool = False
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def last_data(self) -> Optional[dict]:
|
|
177
|
+
"""前回の戻り値(前回の記録の data)。前回の data が None のとき(始発・失敗など)は None。"""
|
|
178
|
+
return self.last_stop.data
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def first_departure(self) -> bool:
|
|
182
|
+
"""始発(初回実行)かどうか。"""
|
|
183
|
+
return self.last_stop.run_id is None
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def extra(self) -> dict:
|
|
187
|
+
"""ログの extra に渡す dict。"""
|
|
188
|
+
return {
|
|
189
|
+
"run_id": self.current.run_id,
|
|
190
|
+
"run_by": self.current.run_by,
|
|
191
|
+
}
|