4848 named_wait_for_next_node ,
4949 named_wait_for_next_node_dict_basket ,
5050)
51- from .state import State , build_track_state_node
51+ from .stage import Stage , _StageManager , build_staging_node
52+ from .state import State , _StateManager , build_track_state_node
5253
5354if TYPE_CHECKING :
5455 from csp_gateway .utils import Query
@@ -186,6 +187,7 @@ def __new__(mcs: Any, name: Any, bases: Any, namespace: Any, **kwargs: Any) -> A
186187 _add_field_attributes (cls )
187188 ts_pydantic_field_types = {}
188189 declared_states : Dict [str , _StateSpec ] = {}
190+ declared_stages : List [str ] = []
189191 for field_name , field_type in cls .model_fields .items ():
190192 # Validate that timeseries types contain structs or list of structs
191193 outer_type = field_type .annotation
@@ -206,8 +208,12 @@ def __new__(mcs: Any, name: Any, bases: Any, namespace: Any, **kwargs: Any) -> A
206208 keyby = _normalize_keyby (meta ._meta_keyby ),
207209 indexer = meta ._meta_indexer ,
208210 )
211+ elif isinstance (meta , Stage ):
212+ if field_name not in declared_stages :
213+ declared_stages .append (field_name )
209214
210215 cls ._declared_states = declared_states
216+ cls ._declared_stages = declared_stages
211217
212218 ts_pydantic_field_types [_CSP_ENGINE_CYCLE_TIMESTAMP_FIELD ] = (Optional [datetime ], None )
213219 dynamic_pydantic_model = create_model ("_snapshot_model" , __base__ = _SnapshotModelBaseClass , ** ts_pydantic_field_types )
@@ -235,6 +241,9 @@ class Channels(BaseModel, metaclass=ChannelsMetaclass):
235241 # alias -> _StateSpec(source_field, keyby, indexer)
236242 _declared_states : Dict [str , _StateSpec ] = {}
237243
244+ # Populated by ChannelsMetaclass from Annotated[ts[X], Stage()] markers.
245+ _declared_stages : List [str ] = []
246+
238247 model_config = dict (arbitrary_types_allowed = True ) # (for FeedbackOutputDef)
239248
240249 _finalized : bool = PrivateAttr (default = False )
@@ -265,6 +274,11 @@ class Channels(BaseModel, metaclass=ChannelsMetaclass):
265274 _next_requests : Dict [Tuple [str , Optional [Union [str , int ]]], Any ] = PrivateAttr (default_factory = dict )
266275 _send_channels : Dict [Tuple [str , Optional [Union [str , int ]]], Any ] = PrivateAttr (default_factory = dict )
267276
277+ # Staging: channel_name -> _StageManager instance
278+ _stages : Dict [str , _StageManager ] = PrivateAttr (default_factory = dict )
279+ # Staging: channel_name -> GenericPushAdapter (release trigger)
280+ _stage_triggers : Dict [str , Any ] = PrivateAttr (default_factory = dict )
281+
268282 # inside context, the module being attached
269283 _module_being_attached : Any = PrivateAttr (None )
270284 # inside context, the requirements of the module being attached
@@ -410,6 +424,8 @@ def _finalize(self) -> None:
410424 if not self ._finalized :
411425 # Auto-wire state collections declared via annotations.
412426 self ._wire_declared_states ()
427+ # Auto-wire staging declared via annotations.
428+ self ._wire_declared_stages ()
413429
414430 # first ensure everything is provided
415431 for (
@@ -529,6 +545,37 @@ def _wire_declared_states(self) -> None:
529545 edge = self .get_channel (spec .source_field )
530546 self ._wire_state_edge (alias , edge , spec .keyby , spec .indexer )
531547
548+ def _wire_declared_stages (self ) -> None :
549+ """Auto-wire staging for channels declared via Annotated[ts[X], Stage()]."""
550+ for field_name in self .__class__ ._declared_stages :
551+ if field_name in self ._stages :
552+ continue # already enabled via set_stage in connect
553+
554+ if not self ._delayed_edge_providers .get (field_name ):
555+ continue # no provider for this channel
556+
557+ # Determine element type from the channel's ts type
558+ tstype = self .get_outer_type (field_name )
559+ if is_dict_basket (tstype ):
560+ element_type = get_dict_basket_value_type (tstype )
561+ elif isTsType (tstype ):
562+ element_type = tstype .typ
563+ else :
564+ continue
565+
566+ # Unwrap List[T] -> T
567+ from typing import get_args as _get_args , get_origin as _get_origin
568+
569+ if _get_origin (element_type ) is list :
570+ element_type = _get_args (element_type )[0 ]
571+
572+ stage , push_adapter = build_staging_node (element_type )
573+ self ._stages [field_name ] = stage
574+ self ._stage_triggers [field_name ] = push_adapter
575+
576+ # Wire the push adapter's output as an additional provider for this channel
577+ self ._delayed_edge_providers [field_name ].append ((None , push_adapter .out ()))
578+
532579 def _bind_delayed_channel (self , field , list_of_edges_and_modules , indexer = None ):
533580 tstype = self .get_outer_type (field )
534581 # Make sure a getter node exists first
@@ -893,7 +940,7 @@ def get_state(self, field: str, indexer: Union[str, int] = None) -> Any:
893940 delayed = self ._delayed_state_edges .get ((field , indexer ))
894941 if delayed is None :
895942 element_type = self ._pending_state_element_types [field ]
896- delayed = DelayedEdge (ts [State [element_type ]])
943+ delayed = DelayedEdge (ts [_StateManager [element_type ]])
897944 delayed .__name__ = "s_{}" .format (field )
898945 self ._delayed_state_edges [field , indexer ] = delayed
899946 return delayed
@@ -1128,6 +1175,130 @@ def send(self, field: str, value: Any, indexer: Union[str, int] = None) -> None:
11281175 # basket, push into first item of tuple
11291176 send_channel [0 ].push_tick (value )
11301177
1178+ # ------------------------------------------------------------------
1179+ # Staging API
1180+ # ------------------------------------------------------------------
1181+
1182+ def set_stage (self , field : str ) -> None :
1183+ """Enable staging mode for a channel.
1184+
1185+ Once staging is enabled, the channel gains stage_add/stage_remove/
1186+ stage_release/stage_list/stage_lookup methods. Released items are
1187+ injected into the channel as a single tick via a feedback edge.
1188+
1189+ Must be called during module connect (before finalization).
1190+ """
1191+ if field in self ._stages :
1192+ return # already enabled
1193+
1194+ if not self ._validate_field_name (field ):
1195+ raise AttributeError ("{} has no channel: {}" .format (self .__class__ , field ))
1196+
1197+ # Determine element type from the channel's ts type
1198+ tstype = self .get_outer_type (field )
1199+ if is_dict_basket (tstype ):
1200+ element_type = get_dict_basket_value_type (tstype )
1201+ elif isTsType (tstype ):
1202+ element_type = tstype .typ
1203+ else :
1204+ raise TypeError (f"Cannot enable staging on non-timeseries field: { field } " )
1205+
1206+ # Unwrap List[T] -> T
1207+ from typing import get_args as _get_args , get_origin as _get_origin
1208+
1209+ if _get_origin (element_type ) is list :
1210+ element_type = _get_args (element_type )[0 ]
1211+
1212+ stage , push_adapter = build_staging_node (element_type )
1213+ self ._stages [field ] = stage
1214+ self ._stage_triggers [field ] = push_adapter
1215+
1216+ # Wire the push adapter's output as an additional provider for this channel
1217+ module = self ._module_being_attached
1218+ self ._delayed_edge_providers [field ].append ((module , push_adapter .out ()))
1219+
1220+ def staged_channels (self ) -> List [str ]:
1221+ """Return list of channel names that have staging enabled."""
1222+ return list (self ._stages .keys ())
1223+
1224+ def stage_add (
1225+ self ,
1226+ field : str ,
1227+ struct : Any = None ,
1228+ staging_ids : Optional [List [str ]] = None ,
1229+ ) -> List [str ]:
1230+ """Add a struct to staging area(s) for a channel.
1231+
1232+ See STAGE.md for full semantics.
1233+ """
1234+ if field not in self ._stages :
1235+ raise NoProviderException (f"No staging enabled for channel: { field } " )
1236+ return self ._stages [field ].stage_add (struct , staging_ids )
1237+
1238+ def stage_remove (
1239+ self ,
1240+ field : str ,
1241+ struct : Any = None ,
1242+ staging_ids : Optional [List [str ]] = None ,
1243+ ) -> List [str ]:
1244+ """Remove struct(s) from staging area(s).
1245+
1246+ See STAGE.md for full semantics.
1247+ """
1248+ if field not in self ._stages :
1249+ raise NoProviderException (f"No staging enabled for channel: { field } " )
1250+ return self ._stages [field ].stage_remove (struct , staging_ids )
1251+
1252+ def stage_release (
1253+ self ,
1254+ field : str ,
1255+ staging_ids : Optional [List [str ]] = None ,
1256+ ) -> Dict [str , List [Any ]]:
1257+ """Release staged structs into the channel.
1258+
1259+ Released items are pushed into the csp graph individually.
1260+ Returns dict mapping staging_id -> list of released structs.
1261+ """
1262+ if field not in self ._stages :
1263+ raise NoProviderException (f"No staging enabled for channel: { field } " )
1264+
1265+ stage = self ._stages [field ]
1266+ released = stage .stage_release (staging_ids )
1267+
1268+ # Push each released item into the graph via the push adapter
1269+ push_adapter = self ._stage_triggers [field ]
1270+ for items in released .values ():
1271+ for item in items :
1272+ push_adapter .push_tick (item )
1273+
1274+ return released
1275+
1276+ def stage_list (
1277+ self ,
1278+ field : str ,
1279+ staging_id : Optional [str ] = None ,
1280+ ) -> List [str ]:
1281+ """List staging IDs for a channel.
1282+
1283+ See STAGE.md for full semantics.
1284+ """
1285+ if field not in self ._stages :
1286+ raise NoProviderException (f"No staging enabled for channel: { field } " )
1287+ return self ._stages [field ].stage_list (staging_id )
1288+
1289+ def stage_lookup (
1290+ self ,
1291+ field : str ,
1292+ staging_id : Optional [str ] = None ,
1293+ ) -> Dict [str , List [Any ]]:
1294+ """Look up contents of staging area(s).
1295+
1296+ Returns dict mapping staging_id -> list of structs.
1297+ """
1298+ if field not in self ._stages :
1299+ raise NoProviderException (f"No staging enabled for channel: { field } " )
1300+ return self ._stages [field ].stage_lookup (staging_id )
1301+
11311302 def _check (self , field : str , where : Dict , kind : str , indexer : Union [str , int ] = None ) -> None :
11321303 if (field , indexer ) not in where :
11331304 # TODO should only be called once the graph is started
0 commit comments