33from __future__ import annotations
44
55import typing
6+ from collections .abc import Iterator
7+ from contextlib import contextmanager
68
79import pydantic
810
911from .errors import ExtensionLifecycleError , translate_host_model_error
1012
13+ if typing .TYPE_CHECKING :
14+ from .publication import ExtensionPublication
15+
1116
1217class EmptyConfig (pydantic .BaseModel ): ...
1318
@@ -123,9 +128,102 @@ def mutate(
123128 translate_host_model_error (error )
124129 return typing .cast (tuple [ConfigT , StateT ], result )
125130
131+ @classmethod
132+ async def update_config_async (cls , value : dict [str , typing .Any ] | ConfigT ) -> ConfigT :
133+ """Persist validated configuration through the Host async capability."""
134+ config = (
135+ value
136+ if isinstance (value , cls .__configcls__ )
137+ else cls .__configcls__ .model_validate (value )
138+ )
139+ try :
140+ cls .__model__ = await cls ._model ().update_config_async (config .model_dump (mode = "json" ))
141+ except Exception as error :
142+ translate_host_model_error (error )
143+ return config
144+
145+ @classmethod
146+ async def get_state_async (cls ) -> StateT :
147+ """Read fresh state through the Host async capability."""
148+ try :
149+ state = await cls ._model ().read_state_async ()
150+ except Exception as error :
151+ translate_host_model_error (error )
152+ return cls .__statecls__ .model_validate (state )
153+
154+ @classmethod
155+ async def mutate_state_async (cls , transform : typing .Callable [[StateT ], StateT ]) -> StateT :
156+ """Await one committed state mutation; transform runs synchronously under the Host lock."""
157+ result : StateT | None = None
158+
159+ def mutate (raw : dict [str , typing .Any ]) -> dict [str , typing .Any ]:
160+ nonlocal result
161+ updated = transform (cls .__statecls__ .model_validate (raw ))
162+ if not isinstance (updated , cls .__statecls__ ):
163+ raise TypeError ("Extension state transform returned the wrong model" )
164+ result = updated
165+ return updated .model_dump (mode = "json" )
166+
167+ try :
168+ await cls ._model ().mutate_state_async (mutate )
169+ except Exception as error :
170+ translate_host_model_error (error )
171+ # The Host invokes the transform under its transaction and commits its
172+ # returned JSON unchanged. Keep the typed result rather than revalidating it.
173+ return typing .cast (StateT , result )
174+
175+ @classmethod
176+ async def mutate_config_and_state_async (
177+ cls , transform : typing .Callable [[ConfigT , StateT ], tuple [ConfigT , StateT ]]
178+ ) -> tuple [ConfigT , StateT ]:
179+ """Await one atomic config/state mutation with a synchronous typed transform."""
180+ result : tuple [ConfigT , StateT ] | None = None
181+
182+ def mutate (
183+ config : dict [str , typing .Any ], state : dict [str , typing .Any ]
184+ ) -> tuple [dict [str , typing .Any ], dict [str , typing .Any ]]:
185+ nonlocal result
186+ new_config , new_state = transform (
187+ cls .__configcls__ .model_validate (config ), cls .__statecls__ .model_validate (state )
188+ )
189+ if not isinstance (new_config , cls .__configcls__ ) or not isinstance (
190+ new_state , cls .__statecls__
191+ ):
192+ raise TypeError ("Extension config/state transform returned the wrong models" )
193+ result = new_config , new_state
194+ return new_config .model_dump (mode = "json" ), new_state .model_dump (mode = "json" )
195+
196+ try :
197+ await cls ._model ().mutate_config_and_state_async (mutate )
198+ except Exception as error :
199+ translate_host_model_error (error )
200+ return typing .cast (tuple [ConfigT , StateT ], result )
201+
126202 @classmethod
127203 def on_start (cls , app : typing .Any ) -> None :
128- """Publish concrete Core contributions; config reads restore types on use."""
204+ """Publish contributions using the synchronous Host persistence contract."""
205+ with cls ._publication_scope (app ) as publication :
206+ publication .activate_source_types ()
207+ try :
208+ cls .__model__ = cls ._model ().update_config_schema (dict (cls .__configschema__ ))
209+ except Exception as error :
210+ translate_host_model_error (error )
211+
212+ @classmethod
213+ async def on_start_async (cls , app : typing .Any ) -> None :
214+ """Publish contributions and await catalog/schema persistence before becoming active."""
215+ with cls ._publication_scope (app ) as publication :
216+ await publication .activate_source_types_async ()
217+ try :
218+ cls .__model__ = await cls ._model ().update_config_schema_async (
219+ dict (cls .__configschema__ )
220+ )
221+ except Exception as error :
222+ translate_host_model_error (error )
223+
224+ @classmethod
225+ @contextmanager
226+ def _publication_scope (cls , app : typing .Any ) -> Iterator [ExtensionPublication ]:
129227 import fastapi
130228 from app .business .peer import PeerManager
131229
@@ -134,9 +232,12 @@ def on_start(cls, app: typing.Any) -> None:
134232 PublicHTTPRouteClaim ,
135233 )
136234
137- if cls .runtime_active ():
138- raise ExtensionLifecycleError (f"Extension { cls .__extid__ } is already active" )
235+ if cls .runtime_active () or cls .__dict__ .get ("__runtime_starting__" , False ):
236+ raise ExtensionLifecycleError (
237+ f"Extension { cls .__extid__ } is already active or starting"
238+ )
139239 publication = ExtensionPublication (app )
240+ cls .__runtime_starting__ = True
140241 try :
141242 router = fastapi .APIRouter (
142243 prefix = f"/{ cls .__extid__ } " , dependencies = cls .api_dependencies ()
@@ -154,15 +255,15 @@ def on_start(cls, app: typing.Any) -> None:
154255 publication .public_http_claim = PublicHTTPRouteClaim .acquire (
155256 cls .__extid__ , cls .public_http_routes (), publication .routes
156257 )
157- publication .activate_source_types ()
158- try :
159- cls .__model__ = cls ._model ().update_config_schema (dict (cls .__configschema__ ))
160- except Exception as error :
161- translate_host_model_error (error )
162- except Exception :
258+ yield publication
259+ except BaseException :
260+ # Cancellation during Host persistence must revoke already-published effects.
163261 publication .withdraw ()
164262 raise
165- cls .__runtime_publication__ = publication
263+ else :
264+ cls .__runtime_publication__ = publication
265+ finally :
266+ del cls .__runtime_starting__
166267
167268 @classmethod
168269 def api_dependencies (cls ) -> list [typing .Any ]:
0 commit comments