Skip to content

Commit 9128c9b

Browse files
authored
feat: add on_copy and on_patch optional lifecycle methods to Resource (PRA-286) (#26)
Add two new optional lifecycle methods to the provider resource interface for subgraph copy and patch operations. Both methods are opt-in: existing providers continue to work unchanged. New Resource methods: - on_copy(context) -> CopyResult: handles resource duplication with stateless/stateful copy strategies and tag propagation - on_patch(patch) -> PatchResult: handles applying patches (migrations, schema diffs) with compatibility constraint matching - supports_copy() / supports_patch(): classmethods for runtime detection of whether a resource type implements these operations New models (in types.py): - CopyStrategy, CopyContext, CopyResult - CompatibilityConstraint, PatchDefinition, PatchResult Also adds: - COPY and PATCH to EventType enum - invoke_copy() and invoke_patch() to ProviderHarness for local testing - copy_result and patch_result fields on LifecycleResult - 32 new tests covering all new functionality
1 parent e0d9ea8 commit 9128c9b

6 files changed

Lines changed: 761 additions & 5 deletions

File tree

src/pragma_sdk/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,14 +56,28 @@
5656
)
5757
from pragma_sdk.models import Provider as ProviderModel
5858
from pragma_sdk.provider import Provider
59-
from pragma_sdk.types import HealthStatus, LifecycleState, LogEntry
59+
from pragma_sdk.types import (
60+
CompatibilityConstraint,
61+
CopyContext,
62+
CopyResult,
63+
CopyStrategy,
64+
HealthStatus,
65+
LifecycleState,
66+
LogEntry,
67+
PatchDefinition,
68+
PatchResult,
69+
)
6070

6171

6272
__all__ = [
6373
"AsyncPragmaClient",
6474
"BuildInfo",
6575
"BuildStatus",
76+
"CompatibilityConstraint",
6677
"Config",
78+
"CopyContext",
79+
"CopyResult",
80+
"CopyStrategy",
6781
"Dependency",
6882
"DeploymentResult",
6983
"DeploymentStatus",
@@ -80,6 +94,8 @@
8094
"OrganizationStatus",
8195
"Outputs",
8296
"PaginatedResponse",
97+
"PatchDefinition",
98+
"PatchResult",
8399
"PragmaClient",
84100
"Provider",
85101
"ProviderAuthor",

src/pragma_sdk/models/base.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,15 @@
2424
Sensitive,
2525
format_resource_id,
2626
)
27-
from pragma_sdk.types import HealthStatus, LifecycleState, LogEntry
27+
from pragma_sdk.types import (
28+
CopyContext,
29+
CopyResult,
30+
HealthStatus,
31+
LifecycleState,
32+
LogEntry,
33+
PatchDefinition,
34+
PatchResult,
35+
)
2836

2937

3038
def _is_union_origin(origin: Any) -> bool:
@@ -714,6 +722,70 @@ async def on_delete(self) -> None:
714722
"""Handle resource deletion."""
715723
raise NotImplementedError(f"{self.__class__.__name__} must implement on_delete()")
716724

725+
async def on_copy(self, context: CopyContext) -> CopyResult:
726+
"""Handle resource duplication as part of a subgraph copy operation.
727+
728+
Override this method to control how the resource duplicates itself.
729+
Stateless resources (config-only) can return a modified config directly.
730+
Stateful resources (data-bearing) may need to clone underlying data
731+
(e.g., create a new database, copy vector indices).
732+
733+
The default implementation raises NotImplementedError, indicating the
734+
resource type does not support copying. Use ``supports_copy()`` to check
735+
before calling.
736+
737+
Args:
738+
context: Copy context including tags, target name, strategy, and
739+
provider-specific metadata.
740+
741+
Returns:
742+
CopyResult with the configuration for the new copied resource.
743+
744+
Raises:
745+
NotImplementedError: If the resource type does not support copying.
746+
"""
747+
raise NotImplementedError(f"{self.__class__.__name__} does not support on_copy()")
748+
749+
async def on_patch(self, patch: PatchDefinition) -> PatchResult:
750+
"""Handle applying a patch (migration script, schema diff) to this resource.
751+
752+
Override this method to control how patches are applied to the resource.
753+
Patches have compatibility constraints that are checked before this method
754+
is called -- the resource is guaranteed to satisfy all constraints.
755+
756+
The default implementation raises NotImplementedError, indicating the
757+
resource type does not support patching. Use ``supports_patch()`` to check
758+
before calling.
759+
760+
Args:
761+
patch: Patch definition including the payload, constraints, and metadata.
762+
763+
Returns:
764+
PatchResult indicating success/failure and any modified config/outputs.
765+
766+
Raises:
767+
NotImplementedError: If the resource type does not support patching.
768+
"""
769+
raise NotImplementedError(f"{self.__class__.__name__} does not support on_patch()")
770+
771+
@classmethod
772+
def supports_copy(cls) -> bool:
773+
"""Check if this resource type implements on_copy.
774+
775+
Returns:
776+
True if the resource subclass overrides on_copy from the base Resource.
777+
"""
778+
return cls.on_copy is not Resource.on_copy
779+
780+
@classmethod
781+
def supports_patch(cls) -> bool:
782+
"""Check if this resource type implements on_patch.
783+
784+
Returns:
785+
True if the resource subclass overrides on_patch from the base Resource.
786+
"""
787+
return cls.on_patch is not Resource.on_patch
788+
717789
@classmethod
718790
def upgrade(cls, config: dict, outputs: dict | None) -> tuple[dict, dict | None]:
719791
"""Migrate config and outputs from the previous provider version.

src/pragma_sdk/models/enums.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,13 @@ class DeploymentStatus(StrEnum):
2424

2525

2626
class EventType(StrEnum):
27-
"""Resource lifecycle event type: CREATE, UPDATE, or DELETE."""
27+
"""Resource lifecycle event type."""
2828

2929
CREATE = "CREATE"
3030
UPDATE = "UPDATE"
3131
DELETE = "DELETE"
32+
COPY = "COPY"
33+
PATCH = "PATCH"
3234
MIGRATE_UP = "MIGRATE_UP"
3335
MIGRATE_DOWN = "MIGRATE_DOWN"
3436

src/pragma_sdk/provider/harness.py

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def test_my_resource():
4242

4343
from pragma_sdk.context import reset_provider_name, set_provider_name
4444
from pragma_sdk.models import Config, Outputs, Resource
45-
from pragma_sdk.types import LifecycleState
45+
from pragma_sdk.types import CopyContext, CopyResult, LifecycleState, PatchDefinition, PatchResult
4646

4747

4848
class EventType(StrEnum):
@@ -51,6 +51,8 @@ class EventType(StrEnum):
5151
CREATE = "create"
5252
UPDATE = "update"
5353
DELETE = "delete"
54+
COPY = "copy"
55+
PATCH = "patch"
5456

5557

5658
@dataclass
@@ -71,11 +73,14 @@ class LifecycleResult:
7173
"""Result of executing a lifecycle method in tests.
7274
7375
Use `.success` or `.failed` to check status, `.outputs` for returned values,
74-
and `.error` for any exception raised.
76+
and `.error` for any exception raised. For copy and patch operations, use
77+
`.copy_result` and `.patch_result` respectively.
7578
"""
7679

7780
success: bool
7881
outputs: Outputs | None = None
82+
copy_result: CopyResult | None = None
83+
patch_result: PatchResult | None = None
7984
error: Exception | None = None
8085
resource: Resource | None = None
8186
event: LifecycleEvent | None = None
@@ -321,3 +326,125 @@ async def invoke_delete(
321326

322327
self._results.append(result)
323328
return result
329+
330+
async def invoke_copy(
331+
self,
332+
resource_class: type[Resource],
333+
name: str,
334+
config: Config,
335+
context: CopyContext,
336+
current_outputs: Outputs | None = None,
337+
tags: list[str] | None = None,
338+
) -> LifecycleResult:
339+
"""Invoke the on_copy lifecycle method.
340+
341+
Args:
342+
resource_class: Resource subclass to test.
343+
name: Source resource instance name.
344+
config: Configuration of the source resource.
345+
context: Copy context with target name, tags, strategy, and metadata.
346+
current_outputs: Outputs of the source resource.
347+
tags: Tags on the source resource.
348+
349+
Returns:
350+
Result containing success status, copy result as outputs, and any error.
351+
"""
352+
event = LifecycleEvent(
353+
event_id=str(uuid4()),
354+
event_type=EventType.COPY,
355+
resource_class=resource_class,
356+
name=name,
357+
config=config,
358+
)
359+
self._events.append(event)
360+
361+
resource = resource_class(
362+
name=name,
363+
config=config,
364+
lifecycle_state=LifecycleState.PROCESSING,
365+
outputs=current_outputs,
366+
tags=tags,
367+
)
368+
369+
provider_token = set_provider_name(self._provider_name)
370+
try:
371+
copy_result = await resource.on_copy(context)
372+
result = LifecycleResult(
373+
success=True,
374+
copy_result=copy_result,
375+
resource=resource,
376+
event=event,
377+
)
378+
except Exception as e:
379+
result = LifecycleResult(
380+
success=False,
381+
error=e,
382+
resource=resource,
383+
event=event,
384+
)
385+
finally:
386+
reset_provider_name(provider_token)
387+
388+
self._results.append(result)
389+
return result
390+
391+
async def invoke_patch(
392+
self,
393+
resource_class: type[Resource],
394+
name: str,
395+
config: Config,
396+
patch: PatchDefinition,
397+
current_outputs: Outputs | None = None,
398+
tags: list[str] | None = None,
399+
) -> LifecycleResult:
400+
"""Invoke the on_patch lifecycle method.
401+
402+
Args:
403+
resource_class: Resource subclass to test.
404+
name: Resource instance name.
405+
config: Configuration of the resource.
406+
patch: Patch definition to apply.
407+
current_outputs: Current outputs of the resource.
408+
tags: Tags on the resource.
409+
410+
Returns:
411+
Result containing success status, patch result, and any error.
412+
"""
413+
event = LifecycleEvent(
414+
event_id=str(uuid4()),
415+
event_type=EventType.PATCH,
416+
resource_class=resource_class,
417+
name=name,
418+
config=config,
419+
)
420+
self._events.append(event)
421+
422+
resource = resource_class(
423+
name=name,
424+
config=config,
425+
lifecycle_state=LifecycleState.PROCESSING,
426+
outputs=current_outputs,
427+
tags=tags,
428+
)
429+
430+
provider_token = set_provider_name(self._provider_name)
431+
try:
432+
patch_result = await resource.on_patch(patch)
433+
result = LifecycleResult(
434+
success=True,
435+
patch_result=patch_result,
436+
resource=resource,
437+
event=event,
438+
)
439+
except Exception as e:
440+
result = LifecycleResult(
441+
success=False,
442+
error=e,
443+
resource=resource,
444+
event=event,
445+
)
446+
finally:
447+
reset_provider_name(provider_token)
448+
449+
self._results.append(result)
450+
return result

0 commit comments

Comments
 (0)