Skip to content

Commit f17d264

Browse files
feat: keep the generated stub path out of the public API surface (#12) (#14)
* feat: keep the generated stub path out of the public API surface The per-language SDK repo is the distribution mechanism, so the generated tree has to stay an implementation detail. It was not one: add_github_source, list_sources, the three mint-side methods, attach and revision_request all named saas_sdk._gen types, and datasource/work_context re-exported the whole generated module as `pb` in __all__. Each facade now re-exports the types it takes and returns — the generated classes themselves, so isinstance and existing imports keep working — and tests/test_api_boundary.py fails when a consumer-reachable signature names the generated package again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: close three gaps found reviewing the boundary gate The README's mint example still built scopes through work_context.pb, which is the exact import the change removes from the API surface — and the one a consumer is most likely to copy, since it is the documented happy path for minting. A signature gate cannot see it, so the examples are now checked too. Two holes in the gate itself: a quoted annotation ("list[pb.Datasource]") parsed as an opaque string constant and passed silently, and an empty facade glob would have parametrized every check into nothing and reported green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: close the star-import leak and three gaps in the boundary gate authorization_revision declared no __all__, so `from saas_sdk.authorization_revision import *` handed a consumer `pb` — the generated module itself, and with it everything the gate was written to fence off. The __all__ check could not see this: it only inspected modules that already declared one, so it passed on the single module that leaked. No __all__ is now a failure rather than a skip, and a runtime star-import test asserts what Python actually exports, since that is the thing the static read was wrong about. Three further gaps, each demonstrated by restoring the defect it hides: - _consumer_reachable walked only the top level and one level into a class, so a public definition inside a try:/if block was invisible. This module already defines its optional transports behind `try: import`, so the blind spot sat directly on a pattern the package uses. It now recurses. - identity was the only proof that a re-export aliases the proto, which proves nothing for the three replay-policy enum values: they are small ints, so `= 0` satisfies `is` through CPython's integer cache while decoupling the constant from the proto and keeping a stale value across a renumbering. The binding is now checked structurally. - a non-literal __all__ raised AttributeError instead of failing with a reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2b8d1ef commit f17d264

5 files changed

Lines changed: 341 additions & 21 deletions

File tree

‎README.md‎

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import namespace.
1111

1212
Packages:
1313

14-
- **`saas_sdk._gen`** — the generated protobuf message bindings (`*_pb2`), from
14+
- **`saas_sdk._gen`** — private; consumers reach these types through the facades
15+
that re-export them (see [Consuming](#consuming)). The generated protobuf
16+
message bindings (`*_pb2`), from
1517
the accounts public proto at the ref recorded in `SOURCE.txt`. Only message
1618
types are generated; the runtime owns the Connect transport, so there are no
1719
client/server stubs. The bindings embed **only** the accounts protos this SDK
@@ -76,7 +78,7 @@ wc = work_context.new(gateway)
7678
parent = wc.start_task(
7779
bearer=user_bearer, org_id=org, task_id=task, session_id=session,
7880
audience="accounts",
79-
scopes=[work_context.pb.WorkContextScope(resource_kind="evidence", actions=["read"])],
81+
scopes=[work_context.WorkContextScope(resource_kind="evidence", actions=["read"])],
8082
)
8183
ctx = wc.exchange_audience(bearer=user_bearer, parent=parent, audience="evidence", scopes=scopes)
8284
work_context.attach(headers, ctx) # per outgoing request
@@ -195,7 +197,23 @@ and shared-proto dependencies (`scripts/strip_options.py`), and regenerates
195197

196198
## Consuming
197199

200+
Import `saas_sdk`, never `saas_sdk._gen`. Each facade re-exports the message
201+
types it takes and returns, so the generated tree stays an implementation
202+
detail:
203+
198204
```python
199-
from saas_sdk import datasource # the facade
200-
from saas_sdk._gen import datasource_pb2 # the message types
205+
from saas_sdk import datasource
206+
from saas_sdk.datasource import Datasource # the message type, re-exported
207+
208+
sources: list[Datasource] = datasource.new(gateway).list_sources(org)
201209
```
210+
211+
Likewise `work_context` re-exports `IssuedWorkContext`, `WorkContextScope`,
212+
`WorkContextReplayPolicy` and its enum values, and `authorization_revision`
213+
re-exports `CheckAuthorizationRevisionRequest`. These are the generated classes
214+
themselves, not wrappers, so `isinstance` and existing imports keep working.
215+
216+
Naming `saas_sdk._gen` in consumer code pins this SDK's internal layout into
217+
yours: regenerating against a newer contract, or swapping the committed tree for
218+
a served dependency, would become a breaking change for you rather than a change
219+
here. `tests/test_api_boundary.py` fails if a public signature reintroduces it.

‎src/saas_sdk/authorization_revision.py‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,23 @@
2424
from ._gen import work_contexts_pb2 as pb
2525
from .work_context import WorkContext
2626

27+
# Re-exported so a consumer names the request revision_request builds without
28+
# importing saas_sdk._gen. Same object, so isinstance keeps working.
29+
CheckAuthorizationRevisionRequest = pb.CheckAuthorizationRevisionRequest
30+
31+
# Without this, a star-import re-exports every public module-level name — which
32+
# includes ``pb``, the generated bindings themselves.
33+
__all__ = [
34+
"REVISION_PATH",
35+
"TIMEOUT",
36+
"RevisionDenied",
37+
"RevisionUnavailable",
38+
"CheckAuthorizationRevisionRequest",
39+
"revision_request",
40+
"ConnectClient",
41+
"GRPCClient",
42+
]
43+
2744
try:
2845
import grpc
2946
except ImportError:
@@ -94,7 +111,7 @@ def _https_origin(value: str) -> str:
94111
return value.rstrip("/")
95112

96113

97-
def revision_request(claims: WorkContext) -> pb.CheckAuthorizationRevisionRequest:
114+
def revision_request(claims: WorkContext) -> CheckAuthorizationRevisionRequest:
98115
"""Copy owner and every actor scope; the caller owns verification and policy."""
99116
if (
100117
not isinstance(claims, WorkContext)

‎src/saas_sdk/datasource.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,11 @@
3131

3232
from saas_sdk._gen import datasource_pb2 as pb
3333

34-
__all__ = ["Client", "Gateway", "new", "pb"]
34+
# Re-exported so a consumer names the types this facade returns without importing
35+
# saas_sdk._gen. Same objects, so isinstance and existing imports keep working.
36+
Datasource = pb.Datasource
37+
38+
__all__ = ["Client", "Datasource", "Gateway", "new"]
3539

3640
_M = TypeVar("_M")
3741

@@ -66,7 +70,7 @@ def add_github_source(
6670
paths: Sequence[str] = (),
6771
branch: str = "",
6872
webhook_secret: str = "",
69-
) -> pb.Datasource:
73+
) -> Datasource:
7074
"""Register a GitHub repository as a datasource and return the non-secret
7175
projection the server stored.
7276
@@ -95,7 +99,7 @@ def add_github_source(
9599
)
96100
return response.datasource
97101

98-
def list_sources(self, org_id: str) -> list[pb.Datasource]:
102+
def list_sources(self, org_id: str) -> list[Datasource]:
99103
"""Return the org's connected datasources."""
100104
response = self._gateway.unary(
101105
_SERVICE + "ListSources", pb.ListSourcesRequest(org_id=org_id), pb.ListSourcesResponse

‎src/saas_sdk/work_context.py‎

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@
5151

5252
from saas_sdk._gen import work_contexts_pb2 as pb
5353

54+
# Re-exported so a consumer names the types the mint side takes and returns
55+
# without importing saas_sdk._gen. Same objects, so isinstance and existing
56+
# imports keep working.
57+
IssuedWorkContext = pb.IssuedWorkContext
58+
WorkContextScope = pb.WorkContextScope
59+
WorkContextReplayPolicy = pb.WorkContextReplayPolicy
60+
WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED = pb.WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED
61+
WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT = pb.WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT
62+
WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE = pb.WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE
63+
5464
__all__ = [
5565
"WORK_CONTEXT_HEADER",
5666
"HEADER_NAME",
@@ -64,6 +74,12 @@
6474
"WorkContextDenied",
6575
"WorkContextUnavailable",
6676
"WorkContextMintError",
77+
"IssuedWorkContext",
78+
"WorkContextScope",
79+
"WorkContextReplayPolicy",
80+
"WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED",
81+
"WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT",
82+
"WORK_CONTEXT_REPLAY_POLICY_SINGLE_USE",
6783
"WorkScope",
6884
"WorkActor",
6985
"WorkContext",
@@ -77,7 +93,6 @@
7793
"token_from_headers",
7894
"attach",
7995
"new",
80-
"pb",
8196
]
8297

8398
WORK_CONTEXT_HEADER = "x-codefly-work-context"
@@ -929,13 +944,13 @@ def start_task(
929944
task_id: str,
930945
session_id: str,
931946
audience: str,
932-
scopes: Sequence[pb.WorkContextScope],
947+
scopes: Sequence[WorkContextScope],
933948
actor_principal_id: str = "",
934949
ttl: timedelta | None = None,
935-
replay_policy: pb.WorkContextReplayPolicy = pb.WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
950+
replay_policy: WorkContextReplayPolicy = WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
936951
workspace_id: str = "",
937952
project_id: str = "",
938-
) -> pb.IssuedWorkContext:
953+
) -> IssuedWorkContext:
939954
"""Mint the root capability for a task and return the issued context.
940955
941956
``actor_principal_id`` empty means a direct human-owned task; otherwise
@@ -962,12 +977,12 @@ def exchange_audience(
962977
self,
963978
*,
964979
bearer: str,
965-
parent: pb.IssuedWorkContext,
980+
parent: IssuedWorkContext,
966981
audience: str,
967-
scopes: Sequence[pb.WorkContextScope],
982+
scopes: Sequence[WorkContextScope],
968983
ttl: timedelta | None = None,
969-
replay_policy: pb.WorkContextReplayPolicy = pb.WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
970-
) -> pb.IssuedWorkContext:
984+
replay_policy: WorkContextReplayPolicy = WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
985+
) -> IssuedWorkContext:
971986
"""Reissue ``parent`` for one callee ``audience``, attenuating authority
972987
to ``scopes`` — the same task, session, and owner, never widened. Call
973988
once per distinct callee audience at turn start.
@@ -986,12 +1001,12 @@ def renew(
9861001
self,
9871002
*,
9881003
bearer: str,
989-
ctx: pb.IssuedWorkContext,
1004+
ctx: IssuedWorkContext,
9901005
audience: str | None = None,
991-
scopes: Sequence[pb.WorkContextScope] = (),
1006+
scopes: Sequence[WorkContextScope] = (),
9921007
ttl: timedelta | None = None,
993-
replay_policy: pb.WorkContextReplayPolicy = pb.WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
994-
) -> pb.IssuedWorkContext:
1008+
replay_policy: WorkContextReplayPolicy = WORK_CONTEXT_REPLAY_POLICY_UNSPECIFIED,
1009+
) -> IssuedWorkContext:
9951010
"""Extend ``ctx`` with a fresh TTL for work running past the cap, using
9961011
the current actor's ``bearer`` rather than the owner's.
9971012
@@ -1050,7 +1065,7 @@ def _validated_token(token: str) -> str:
10501065
return token
10511066

10521067

1053-
def attach(request_or_headers: _Headers, ctx: pb.IssuedWorkContext) -> _Headers:
1068+
def attach(request_or_headers: _Headers, ctx: IssuedWorkContext) -> _Headers:
10541069
"""Stamp ``ctx``'s token on an outgoing call and return the target.
10551070
10561071
``request_or_headers`` is either a mutable header mapping or a request object

0 commit comments

Comments
 (0)