Skip to content

Commit 8354b14

Browse files
authored
chore: harden Protocol contract checks (#138)
This PR hardens protocol contracts by: 1. Preventing nominal subclasses from inadvertently becoming instantiable unless they explicitly override the protocol methods. 2. Statically checking all in-source protocol implementations.
1 parent 783c848 commit 8354b14

30 files changed

Lines changed: 334 additions & 27 deletions

src/graphon/dsl/entities.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from abc import abstractmethod
34
from collections.abc import Mapping
45
from enum import StrEnum, auto
56
from typing import Any, Protocol
@@ -125,4 +126,5 @@ def loadable(self) -> bool:
125126

126127

127128
class TypedNodeFactory(Protocol):
129+
@abstractmethod
128130
def create_node(self, node_config: NodeConfigDict) -> Any: ...

src/graphon/file/protocols.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from abc import abstractmethod
34
from collections.abc import Generator
45
from typing import TYPE_CHECKING, Literal, Protocol
56

@@ -18,26 +19,32 @@ class WorkflowFileRuntimeProtocol(Protocol):
1819
"""
1920

2021
@property
22+
@abstractmethod
2123
def multimodal_send_format(self) -> str: ...
2224

25+
@abstractmethod
2326
def http_get(
2427
self,
2528
url: str,
2629
*,
2730
follow_redirects: bool = True,
2831
) -> HttpResponseProtocol: ...
2932

33+
@abstractmethod
3034
def storage_load(self, path: str, *, stream: bool = False) -> bytes | Generator: ...
3135

36+
@abstractmethod
3237
def load_file_bytes(self, *, file: File) -> bytes: ...
3338

39+
@abstractmethod
3440
def resolve_file_url(
3541
self,
3642
*,
3743
file: File,
3844
for_external: bool = True,
3945
) -> str | None: ...
4046

47+
@abstractmethod
4148
def resolve_upload_file_url(
4249
self,
4350
*,
@@ -46,6 +53,7 @@ def resolve_upload_file_url(
4653
for_external: bool = True,
4754
) -> str: ...
4855

56+
@abstractmethod
4957
def resolve_tool_file_url(
5058
self,
5159
*,
@@ -54,6 +62,7 @@ def resolve_tool_file_url(
5462
for_external: bool = True,
5563
) -> str: ...
5664

65+
@abstractmethod
5766
def verify_preview_signature(
5867
self,
5968
*,

src/graphon/graph/graph.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import logging
4+
from abc import abstractmethod
45
from collections import defaultdict
56
from collections.abc import Mapping, Sequence
67
from typing import Any, Protocol, final
@@ -27,6 +28,7 @@ class NodeFactory(Protocol):
2728
allowing for different node creation strategies while maintaining type safety.
2829
"""
2930

31+
@abstractmethod
3032
def create_node(self, node_config: NodeConfigDict) -> Node:
3133
"""Create a Node instance from node configuration data.
3234

src/graphon/graph/validation.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from abc import abstractmethod
34
from collections.abc import Sequence
45
from dataclasses import dataclass
56
from typing import TYPE_CHECKING, Protocol
@@ -34,6 +35,7 @@ def __init__(self, issues: Sequence[GraphValidationIssue]) -> None:
3435
class GraphValidationRule(Protocol):
3536
"""Protocol that individual validation rules must satisfy."""
3637

38+
@abstractmethod
3739
def validate(self, graph: Graph) -> Sequence[GraphValidationIssue]:
3840
"""Validate the provided graph and return any discovered issues."""
3941
...

src/graphon/graph_engine/command_channels/protocol.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
to/from a GraphEngine instance, supporting both local and distributed scenarios.
55
"""
66

7+
from abc import abstractmethod
78
from typing import Protocol
89

910
from ..entities.commands import GraphEngineCommand
@@ -16,6 +17,7 @@ class CommandChannel(Protocol):
1617
this channel is dedicated to that single execution.
1718
"""
1819

20+
@abstractmethod
1921
def fetch_commands(self) -> list[GraphEngineCommand]:
2022
"""Fetch pending commands for this GraphEngine instance.
2123
@@ -27,6 +29,7 @@ def fetch_commands(self) -> list[GraphEngineCommand]:
2729
"""
2830
...
2931

32+
@abstractmethod
3033
def send_command(self, command: GraphEngineCommand) -> None:
3134
"""Send a command to be processed by this GraphEngine instance.
3235

src/graphon/graph_engine/command_channels/redis_channel.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import json
9+
from abc import abstractmethod
910
from contextlib import AbstractContextManager
1011
from typing import Any, Protocol, final
1112

@@ -27,18 +28,32 @@
2728
class RedisPipelineProtocol(Protocol):
2829
"""Minimal Redis pipeline contract used by the command channel."""
2930

31+
@abstractmethod
3032
def lrange(self, name: str, start: int, end: int) -> Any: ...
33+
34+
@abstractmethod
3135
def delete(self, *names: str) -> Any: ...
36+
37+
@abstractmethod
3238
def execute(self) -> list[Any]: ...
39+
40+
@abstractmethod
3341
def rpush(self, name: str, *values: str) -> Any: ...
42+
43+
@abstractmethod
3444
def expire(self, name: str, time: int) -> Any: ...
45+
46+
@abstractmethod
3547
def set(self, name: str, value: str, ex: int | None = None) -> Any: ...
48+
49+
@abstractmethod
3650
def get(self, name: str) -> Any: ...
3751

3852

3953
class RedisClientProtocol(Protocol):
4054
"""Redis client contract required by the command channel."""
4155

56+
@abstractmethod
4257
def pipeline(self) -> AbstractContextManager[RedisPipelineProtocol]: ...
4358

4459

src/graphon/graph_engine/command_processing/command_processor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Main command processor for handling external commands."""
22

33
import logging
4+
from abc import abstractmethod
45
from collections.abc import Callable
56
from typing import Protocol, final
67

@@ -15,6 +16,7 @@
1516
class CommandHandler[CommandT: GraphEngineCommand](Protocol):
1617
"""Protocol for command handlers."""
1718

19+
@abstractmethod
1820
def handle(
1921
self,
2022
command: CommandT,

src/graphon/graph_engine/domain/graph_execution.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
from graphon.entities.pause_reason import PauseReason
1212
from graphon.enums import NodeState
13-
from graphon.runtime.graph_runtime_state import GraphExecutionProtocol
1413

1514
from .node_execution import NodeExecution
1615

@@ -246,6 +245,3 @@ def loads(self, data: str) -> None:
246245
def record_node_failure(self) -> None:
247246
"""Increment the count of node failures encountered during execution."""
248247
self.exceptions_count += 1
249-
250-
251-
_: GraphExecutionProtocol = GraphExecution(workflow_id="")

src/graphon/graph_engine/ready_queue/protocol.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
for execution, supporting both in-memory and persistent storage scenarios.
55
"""
66

7+
from abc import abstractmethod
78
from collections.abc import Sequence
89
from typing import Protocol
910

@@ -35,6 +36,7 @@ class ReadyQueue(Protocol):
3536
that can be serialized for state storage.
3637
"""
3738

39+
@abstractmethod
3840
def put(self, item: str) -> None:
3941
"""Add a node ID to the ready queue.
4042
@@ -44,6 +46,7 @@ def put(self, item: str) -> None:
4446
"""
4547
...
4648

49+
@abstractmethod
4750
def get(self, timeout: float | None = None) -> str:
4851
"""Retrieve and remove a node ID from the queue.
4952
@@ -56,6 +59,7 @@ def get(self, timeout: float | None = None) -> str:
5659
"""
5760
...
5861

62+
@abstractmethod
5963
def task_done(self) -> None:
6064
"""Indicate that a previously retrieved task is complete.
6165
@@ -64,6 +68,7 @@ def task_done(self) -> None:
6468
"""
6569
...
6670

71+
@abstractmethod
6772
def empty(self) -> bool:
6873
"""Check if the queue is empty.
6974
@@ -73,6 +78,7 @@ def empty(self) -> bool:
7378
"""
7479
...
7580

81+
@abstractmethod
7682
def qsize(self) -> int:
7783
"""Get the approximate size of the queue.
7884
@@ -82,6 +88,7 @@ def qsize(self) -> int:
8288
"""
8389
...
8490

91+
@abstractmethod
8592
def dumps(self) -> str:
8693
"""Serialize the queue state to a JSON string for storage.
8794
@@ -92,6 +99,7 @@ def dumps(self) -> str:
9299
"""
93100
...
94101

102+
@abstractmethod
95103
def loads(self, data: str) -> None:
96104
"""Restore the queue state from a JSON string.
97105

src/graphon/http/protocols.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from abc import abstractmethod
12
from collections.abc import Mapping
23
from typing import Any, Protocol
34

@@ -6,45 +7,59 @@
67

78
class HttpResponseProtocol(Protocol):
89
@property
10+
@abstractmethod
911
def headers(self) -> Mapping[str, str]: ...
1012

1113
@property
14+
@abstractmethod
1215
def content(self) -> bytes: ...
1316

1417
@property
18+
@abstractmethod
1519
def status_code(self) -> int: ...
1620

1721
@property
22+
@abstractmethod
1823
def text(self) -> str: ...
1924

2025
@property
26+
@abstractmethod
2127
def is_success(self) -> bool: ...
2228

29+
@abstractmethod
2330
def raise_for_status(self) -> None: ...
2431

2532

2633
class HttpClientProtocol(Protocol):
2734
@property
35+
@abstractmethod
2836
def max_retries_exceeded_error(self) -> type[Exception]: ...
2937

3038
@property
39+
@abstractmethod
3140
def request_error(self) -> type[Exception]: ...
3241

42+
@abstractmethod
3343
def get(self, url: str, max_retries: int = ..., **kwargs: Any) -> HttpResponse: ...
3444

45+
@abstractmethod
3546
def head(self, url: str, max_retries: int = ..., **kwargs: Any) -> HttpResponse: ...
3647

48+
@abstractmethod
3749
def post(self, url: str, max_retries: int = ..., **kwargs: Any) -> HttpResponse: ...
3850

51+
@abstractmethod
3952
def put(self, url: str, max_retries: int = ..., **kwargs: Any) -> HttpResponse: ...
4053

54+
@abstractmethod
4155
def delete(
4256
self,
4357
url: str,
4458
max_retries: int = ...,
4559
**kwargs: Any,
4660
) -> HttpResponse: ...
4761

62+
@abstractmethod
4863
def patch(
4964
self,
5065
url: str,

0 commit comments

Comments
 (0)