Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/cassette.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ with RecordingChannel(cassette, "localhost:50051") as recording:
- record_interaction
- interactions
- record_mode
- target
- can_record

::: grpcvcr.use_cassette
3 changes: 3 additions & 0 deletions docs/concepts/cassettes.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interactions:
metadata:
authorization:
- "Bearer token123"
target: "localhost:50051"
response:
body: "base64-encoded-protobuf"
code: OK
Expand All @@ -27,6 +28,8 @@ interactions:
recorded_at: "2024-01-15T10:30:00Z"
```

The `target` field records the gRPC server address (e.g. `localhost:50051`) the interaction was recorded against. It's set automatically from the `target` passed to `RecordingChannel`/`AsyncRecordingChannel`, and is useful when a project records interactions against multiple hosts (e.g. different providers) and needs to tell which one a given cassette entry came from. It's optional — cassettes recorded before this field existed simply omit it, and it has no effect on default request matching.

## Cassette Location

By default, cassettes are stored in `tests/cassettes/`. You can customize this:
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/custom-matchers.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ def my_matcher(request, recorded_request):
# Metadata as dict[str, list[str]]
request.metadata

# gRPC server address this request was recorded against (or None)
request.target

return True # or False
```

Expand Down
2 changes: 2 additions & 0 deletions src/grpcvcr/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
MetadataMatcher,
MethodMatcher,
RequestMatcher,
TargetMatcher,
)
from grpcvcr.record_modes import RecordMode

Expand All @@ -65,6 +66,7 @@
"RecordMode",
"RequestMatcher",
"SerializationError",
"TargetMatcher",
"async_recorded_channel",
"recorded_channel",
"use_cassette",
Expand Down
9 changes: 8 additions & 1 deletion src/grpcvcr/cassette.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ class Cassette:
match_on: Matcher = field(default_factory=lambda: DEFAULT_MATCHER)
"""Matcher(s) to use for finding recorded interactions."""

target: str | None = None
"""The gRPC target (host:port) this cassette's interactions are recorded against.
Set automatically by RecordingChannel/AsyncRecordingChannel; can also be passed
explicitly for advanced use (e.g. raw interceptor usage without a channel wrapper)."""

_data: CassetteData = field(default_factory=CassetteData, init=False)
_dirty: bool = field(default=False, init=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False)
Expand Down Expand Up @@ -130,13 +135,15 @@ def get_response(
method: str,
request_body: bytes,
metadata: tuple[tuple[str, str], ...] | None = None,
target: str | None = None,
) -> Interaction:
"""Get the recorded response for a request.

Args:
method: Full gRPC method path.
request_body: Serialized protobuf request.
metadata: Optional request metadata (headers).
target: Optional target this interaction is recorded against.

Returns:
The matching recorded interaction.
Expand All @@ -147,7 +154,7 @@ def get_response(
RecordingDisabledError: If no matching interaction is found
and recording is disabled.
"""
request = InteractionRequest.from_grpc(method, request_body, metadata)
request = InteractionRequest.from_grpc(method, request_body, metadata, target)
interaction = self.find_interaction(request)

if interaction is None:
Expand Down
6 changes: 6 additions & 0 deletions src/grpcvcr/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ def __init__(
self.target = target
"""The gRPC server address."""

if not self.cassette.target:
self.cassette.target = target

interceptors = create_interceptors(cassette)

if credentials:
Expand Down Expand Up @@ -118,6 +121,9 @@ def __init__(
self.target = target
"""The gRPC server address."""

if not self.cassette.target:
self.cassette.target = target

interceptors = create_async_interceptors(cassette)

if credentials:
Expand Down
8 changes: 4 additions & 4 deletions src/grpcvcr/interceptors/aio.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ async def intercept_unary_unary(
request_bytes = request.SerializeToString()
metadata = client_call_details.metadata

req = InteractionRequest.from_grpc(method, request_bytes, metadata)
req = InteractionRequest.from_grpc(method, request_bytes, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -237,7 +237,7 @@ async def intercept_unary_stream(
request_bytes = request.SerializeToString()
metadata = client_call_details.metadata

req = InteractionRequest.from_grpc(method, request_bytes, metadata)
req = InteractionRequest.from_grpc(method, request_bytes, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -324,7 +324,7 @@ async def intercept_stream_unary(
requests = [r async for r in request_iterator]
combined_request = b"".join(r.SerializeToString() for r in requests)

req = InteractionRequest.from_grpc(method, combined_request, metadata)
req = InteractionRequest.from_grpc(method, combined_request, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -407,7 +407,7 @@ async def intercept_stream_stream(
requests = [r async for r in request_iterator]
combined_request = b"".join(r.SerializeToString() for r in requests)

req = InteractionRequest.from_grpc(method, combined_request, metadata)
req = InteractionRequest.from_grpc(method, combined_request, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down
8 changes: 4 additions & 4 deletions src/grpcvcr/interceptors/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def intercept_unary_unary(
request_bytes = request.SerializeToString()
metadata = client_call_details.metadata

req = InteractionRequest.from_grpc(method, request_bytes, metadata)
req = InteractionRequest.from_grpc(method, request_bytes, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -104,7 +104,7 @@ def intercept_unary_stream(
request_bytes = request.SerializeToString()
metadata = client_call_details.metadata

req = InteractionRequest.from_grpc(method, request_bytes, metadata)
req = InteractionRequest.from_grpc(method, request_bytes, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -174,7 +174,7 @@ def intercept_stream_unary(
requests = list(request_iterator)
combined_request = b"".join(r.SerializeToString() for r in requests)

req = InteractionRequest.from_grpc(method, combined_request, metadata)
req = InteractionRequest.from_grpc(method, combined_request, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down Expand Up @@ -243,7 +243,7 @@ def intercept_stream_stream(
requests = list(request_iterator)
combined_request = b"".join(r.SerializeToString() for r in requests)

req = InteractionRequest.from_grpc(method, combined_request, metadata)
req = InteractionRequest.from_grpc(method, combined_request, metadata, self.cassette.target)

if self.cassette.record_mode != RecordMode.ALL:
interaction = self.cassette.find_interaction(req)
Expand Down
24 changes: 24 additions & 0 deletions src/grpcvcr/matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,30 @@ def matches(
return request.method == recorded.method


@dataclass
class TargetMatcher(Matcher):
"""Matches requests by gRPC target (host:port).

Useful when a cassette contains interactions recorded against multiple
hosts (e.g. different providers) and playback should only match an
interaction recorded against the same target.

Example:
```python
# Match by method AND target host
matcher = MethodMatcher() & TargetMatcher()
```
"""

def matches(
self,
request: InteractionRequest,
recorded: InteractionRequest,
) -> bool:
"""Check if targets match exactly."""
return request.target == recorded.target


@dataclass
class MetadataMatcher(Matcher):
"""Matches requests by metadata (headers).
Expand Down
7 changes: 7 additions & 0 deletions src/grpcvcr/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,24 @@ class InteractionRequest:
metadata: dict[str, list[str]] = field(default_factory=dict)
"""Request metadata as a dict mapping header names to lists of values."""

target: str | None = None
"""The gRPC server address this request was recorded against."""

@classmethod
def from_grpc(
cls,
method: str,
body: bytes,
metadata: tuple[tuple[str, str], ...] | None = None,
target: str | None = None,
) -> InteractionRequest:
"""Create an InteractionRequest from gRPC call details.

Args:
method: Full gRPC method path.
body: Raw protobuf bytes.
metadata: Optional request metadata as tuples of (key, value).
target: Optional gRPC server address this request was recorded against.

Returns:
A new InteractionRequest instance.
Expand All @@ -110,6 +115,7 @@ def from_grpc(
method="/test.TestService/GetUser",
body=get_user_request.SerializeToString(),
metadata=(("x-request-id", "123"),),
target="grpc-server.io",
)
```
"""
Expand All @@ -122,6 +128,7 @@ def from_grpc(
method=method,
body=base64.b64encode(body).decode("ascii"),
metadata=meta_dict,
target=target,
)

def get_body_bytes(self) -> bytes:
Expand Down
19 changes: 19 additions & 0 deletions tests/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ def test_record_unary_call(
assert grpc_servicer.call_count == 1
assert tmp_cassette_path.exists()
assert len(cassette.interactions) == 1
assert cassette.target == grpc_target
assert cassette.interactions[0].request.target == grpc_target

def test_preseeded_cassette_target_takes_precedence(
self,
grpc_target: str,
tmp_cassette_path: Path,
grpc_servicer,
pb2,
pb2_grpc,
) -> None:
"""An explicitly pre-seeded cassette target is not overwritten by the channel's target."""
cassette = Cassette(tmp_cassette_path, record_mode=RecordMode.ALL, target="preseeded-target")
with RecordingChannel(cassette, grpc_target) as recording:
stub = pb2_grpc.TestServiceStub(recording.channel)
stub.GetUser(pb2.GetUserRequest(id=42))

assert cassette.target == "preseeded-target"

def test_playback_unary_call(
self,
Expand All @@ -59,6 +77,7 @@ def test_playback_unary_call(

grpc_servicer.call_count = 0
cassette2 = Cassette(tmp_cassette_path, record_mode=RecordMode.NONE)
assert cassette2.interactions[0].request.target == grpc_target
with RecordingChannel(cassette2, grpc_target) as recording:
stub = pb2_grpc.TestServiceStub(recording.channel)
response = stub.GetUser(pb2.GetUserRequest(id=42))
Expand Down
19 changes: 19 additions & 0 deletions tests/test_integration_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ async def test_record_unary_call(
assert grpc_servicer.call_count == 1
assert tmp_cassette_path.exists()
assert len(cassette.interactions) == 1
assert cassette.target == grpc_target
assert cassette.interactions[0].request.target == grpc_target

async def test_preseeded_cassette_target_takes_precedence(
self,
grpc_target: str,
tmp_cassette_path: Path,
grpc_servicer,
pb2,
pb2_grpc,
) -> None:
"""An explicitly pre-seeded cassette target is not overwritten by the channel's target."""
cassette = Cassette(tmp_cassette_path, record_mode=RecordMode.ALL, target="preseeded-target")
async with AsyncRecordingChannel(cassette, grpc_target) as recording:
stub = pb2_grpc.TestServiceStub(recording.channel)
await stub.GetUser(pb2.GetUserRequest(id=42))

assert cassette.target == "preseeded-target"

async def test_playback_unary_call(
self,
Expand All @@ -60,6 +78,7 @@ async def test_playback_unary_call(

grpc_servicer.call_count = 0
cassette2 = Cassette(tmp_cassette_path, record_mode=RecordMode.NONE)
assert cassette2.interactions[0].request.target == grpc_target
async with AsyncRecordingChannel(cassette2, grpc_target) as recording:
stub = pb2_grpc.TestServiceStub(recording.channel)
response = await stub.GetUser(pb2.GetUserRequest(id=42))
Expand Down
24 changes: 23 additions & 1 deletion tests/test_matchers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
MetadataMatcher,
MethodMatcher,
RequestMatcher,
TargetMatcher,
find_matching_interaction,
)
from grpcvcr.serialization import (
Expand All @@ -19,9 +20,10 @@ def make_request(
method: str = "/test/Method",
body: bytes = b"body",
metadata: dict[str, list[str]] | None = None,
target: str | None = None,
) -> InteractionRequest:
"""Helper to create InteractionRequest."""
req = InteractionRequest.from_grpc(method, body)
req = InteractionRequest.from_grpc(method, body, target=target)
if metadata:
req.metadata = metadata
return req
Expand All @@ -41,6 +43,26 @@ def test_not_matches_different_method(self) -> None:
assert not matcher.matches(req1, req2)


class TestTargetMatcher:
def test_matches_same_target(self) -> None:
matcher = TargetMatcher()
req1 = make_request(target="localhost:50051")
req2 = make_request(target="localhost:50051")
assert matcher.matches(req1, req2)

def test_not_matches_different_target(self) -> None:
matcher = TargetMatcher()
req1 = make_request(target="localhost:50051")
req2 = make_request(target="localhost:50052")
assert not matcher.matches(req1, req2)

def test_matches_when_both_targets_unset(self) -> None:
matcher = TargetMatcher()
req1 = make_request()
req2 = make_request()
assert matcher.matches(req1, req2)


class TestRequestMatcher:
def test_matches_same_body(self) -> None:
matcher = RequestMatcher()
Expand Down
Loading