sfdctrust 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sfdctrust/__init__.py +54 -0
- sfdctrust/_async_client.py +132 -0
- sfdctrust/_async_pat_refresh.py +151 -0
- sfdctrust/_async_transport.py +129 -0
- sfdctrust/_auth.py +96 -0
- sfdctrust/_client.py +126 -0
- sfdctrust/_constants.py +14 -0
- sfdctrust/_exceptions.py +131 -0
- sfdctrust/_pat_refresh.py +239 -0
- sfdctrust/_pat_store.py +99 -0
- sfdctrust/_transport.py +326 -0
- sfdctrust/_version.py +84 -0
- sfdctrust/integrations/__init__.py +8 -0
- sfdctrust/models/__init__.py +8 -0
- sfdctrust/models/_wire.py +39 -0
- sfdctrust/models/events.py +228 -0
- sfdctrust/models/status.py +33 -0
- sfdctrust/models/tenants.py +74 -0
- sfdctrust/py.typed +0 -0
- sfdctrust/resources/__init__.py +8 -0
- sfdctrust/resources/_async_events.py +154 -0
- sfdctrust/resources/_async_status.py +29 -0
- sfdctrust/resources/_async_tenants.py +94 -0
- sfdctrust/resources/_requests.py +134 -0
- sfdctrust/resources/events.py +228 -0
- sfdctrust/resources/status.py +39 -0
- sfdctrust/resources/tenants.py +115 -0
- sfdctrust-0.1.0.dist-info/METADATA +208 -0
- sfdctrust-0.1.0.dist-info/RECORD +31 -0
- sfdctrust-0.1.0.dist-info/WHEEL +4 -0
- sfdctrust-0.1.0.dist-info/licenses/LICENSE +21 -0
sfdctrust/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust
|
|
4
|
+
:Synopsis: Primary module for the sfdctrust package
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._async_client import AsyncTrustCenterClient
|
|
11
|
+
from ._client import TrustCenterClient
|
|
12
|
+
from ._exceptions import (
|
|
13
|
+
APIStatusError,
|
|
14
|
+
AuthenticationError,
|
|
15
|
+
ConfigurationError,
|
|
16
|
+
MalformedResponseError,
|
|
17
|
+
NetworkError,
|
|
18
|
+
NotFoundError,
|
|
19
|
+
PaginationError,
|
|
20
|
+
PATPersistenceError,
|
|
21
|
+
PATProbeResult,
|
|
22
|
+
PATRefreshAmbiguousError,
|
|
23
|
+
PATRefreshPendingError,
|
|
24
|
+
RateLimitError,
|
|
25
|
+
RequestTimeoutError,
|
|
26
|
+
TrustCenterError,
|
|
27
|
+
)
|
|
28
|
+
from ._pat_refresh import PATRefreshResult
|
|
29
|
+
from ._pat_store import AsyncPATStore, PATStore, PATStoreRecord
|
|
30
|
+
from ._version import __version__
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
'__version__',
|
|
34
|
+
'TrustCenterClient',
|
|
35
|
+
'AsyncTrustCenterClient',
|
|
36
|
+
'TrustCenterError',
|
|
37
|
+
'ConfigurationError',
|
|
38
|
+
'NetworkError',
|
|
39
|
+
'RequestTimeoutError',
|
|
40
|
+
'APIStatusError',
|
|
41
|
+
'AuthenticationError',
|
|
42
|
+
'RateLimitError',
|
|
43
|
+
'NotFoundError',
|
|
44
|
+
'MalformedResponseError',
|
|
45
|
+
'PaginationError',
|
|
46
|
+
'PATStore',
|
|
47
|
+
'AsyncPATStore',
|
|
48
|
+
'PATStoreRecord',
|
|
49
|
+
'PATRefreshResult',
|
|
50
|
+
'PATProbeResult',
|
|
51
|
+
'PATRefreshPendingError',
|
|
52
|
+
'PATRefreshAmbiguousError',
|
|
53
|
+
'PATPersistenceError',
|
|
54
|
+
]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._async_client
|
|
4
|
+
:Synopsis: Asynchronous My Trust Center client
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._async_pat_refresh import _AsyncStoredPAT
|
|
17
|
+
from ._async_transport import _AsyncTransport
|
|
18
|
+
from ._auth import PATAuth, _Unset
|
|
19
|
+
from ._exceptions import ConfigurationError
|
|
20
|
+
from ._pat_refresh import PATRefreshResult, _validate_refresh_days
|
|
21
|
+
from ._pat_store import AsyncPATStore
|
|
22
|
+
from .resources._async_events import AsyncEventsResource
|
|
23
|
+
from .resources._async_status import AsyncStatusResource
|
|
24
|
+
from .resources._async_tenants import AsyncTenantsResource
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AsyncTrustCenterClient:
|
|
28
|
+
"""Asynchronous client for the Salesforce My Trust Center API.
|
|
29
|
+
|
|
30
|
+
Construction makes no network requests. The SDK owns its transport and
|
|
31
|
+
closes its default HTTP client on context exit or :meth:`aclose`.
|
|
32
|
+
|
|
33
|
+
:param pat: Personal access token without the Bearer prefix.
|
|
34
|
+
:param http_client: Trusted async HTTPX client whose lifecycle and TLS, proxy,
|
|
35
|
+
hook, and transport settings remain the caller's responsibility.
|
|
36
|
+
:param max_retries: Additional SDK attempts per request, from 0 through 5.
|
|
37
|
+
:raises ConfigurationError: If the PAT or retry limit is invalid.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, pat: str, *, http_client: httpx.AsyncClient | None = None, max_retries: int = 2) -> None:
|
|
41
|
+
self._initialize(PATAuth(pat), http_client, max_retries)
|
|
42
|
+
|
|
43
|
+
def _initialize(self, auth: PATAuth | _AsyncStoredPAT, http_client: httpx.AsyncClient | None, max_retries: int) -> None:
|
|
44
|
+
self._stored_pat = auth if isinstance(auth, _AsyncStoredPAT) else None
|
|
45
|
+
self._transport = _AsyncTransport(
|
|
46
|
+
auth.read_auth if isinstance(auth, _AsyncStoredPAT) else auth, client=http_client, max_retries=max_retries
|
|
47
|
+
)
|
|
48
|
+
self.tenants = AsyncTenantsResource(self._transport)
|
|
49
|
+
self.status = AsyncStatusResource(self._transport)
|
|
50
|
+
self.events = AsyncEventsResource(self._transport)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_env(
|
|
54
|
+
cls, *, pat: str | None | _Unset = _Unset.PAT, http_client: httpx.AsyncClient | None = None, max_retries: int = 2
|
|
55
|
+
) -> Self:
|
|
56
|
+
"""Create a client using ``SFDCTRUST_PAT`` or an explicit override.
|
|
57
|
+
|
|
58
|
+
:param pat: An explicit override. Omitting it selects the environment.
|
|
59
|
+
Explicit ``None`` or any invalid override raises immediately.
|
|
60
|
+
:param http_client: Optional caller-owned, trusted async HTTPX client.
|
|
61
|
+
:param max_retries: Additional SDK attempts per request, from 0 through 5.
|
|
62
|
+
:returns: A configured client.
|
|
63
|
+
:raises ConfigurationError: If the selected PAT is absent or invalid, or the retry limit is invalid.
|
|
64
|
+
"""
|
|
65
|
+
auth = PATAuth.from_env(pat=pat)
|
|
66
|
+
client = cls.__new__(cls)
|
|
67
|
+
client._initialize(auth, http_client, max_retries)
|
|
68
|
+
return client
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
async def from_store(
|
|
72
|
+
cls, store: AsyncPATStore, *, http_client: httpx.AsyncClient | None = None, max_retries: int = 2
|
|
73
|
+
) -> Self:
|
|
74
|
+
"""Load an explicitly configured, seeded PAT store for one lineage.
|
|
75
|
+
|
|
76
|
+
:param store: Trusted durable adapter shared by all rotators of this PAT.
|
|
77
|
+
:param http_client: Optional caller-owned, trusted async HTTPX client. Its
|
|
78
|
+
transport and hooks must never replay a refresh.
|
|
79
|
+
:param max_retries: Additional SDK attempts per eligible read, from 0 through 5.
|
|
80
|
+
:returns: A configured client.
|
|
81
|
+
:raises ConfigurationError: If the store is unsuitable, unreadable,
|
|
82
|
+
unseeded, or the retry limit is invalid.
|
|
83
|
+
:raises PATRefreshPendingError: If the store has an unresolved rotation.
|
|
84
|
+
"""
|
|
85
|
+
auth = await _AsyncStoredPAT.open(store)
|
|
86
|
+
client = cls.__new__(cls)
|
|
87
|
+
client._initialize(auth, http_client, max_retries)
|
|
88
|
+
return client
|
|
89
|
+
|
|
90
|
+
async def refresh_pat(self, *, expires_in_days: int | None = None) -> PATRefreshResult:
|
|
91
|
+
"""Explicitly rotate a stored PAT once and activate its durable replacement.
|
|
92
|
+
|
|
93
|
+
Cancellation propagates. If the replacement was received, keep this client
|
|
94
|
+
available to reconcile an interrupted commit with :meth:`retry_pat_persistence`.
|
|
95
|
+
|
|
96
|
+
:param expires_in_days: Replacement lifetime from 1 through 30.
|
|
97
|
+
None omits the body and uses Salesforce's default.
|
|
98
|
+
:returns: A secret-free result indicating whether response metadata validated.
|
|
99
|
+
:raises ConfigurationError: If the client uses a static credential, the
|
|
100
|
+
lifetime is invalid, or the store is unreadable.
|
|
101
|
+
:raises PATRefreshPendingError: If a pending or competing operation blocks rotation.
|
|
102
|
+
:raises AuthenticationError: If Salesforce rejected refresh with HTTP 401.
|
|
103
|
+
:raises RateLimitError: If Salesforce rejected refresh with HTTP 429.
|
|
104
|
+
:raises PATRefreshAmbiguousError: If Salesforce may have rotated without a usable replacement.
|
|
105
|
+
:raises PATPersistenceError: If a required durable state transition cannot be confirmed.
|
|
106
|
+
"""
|
|
107
|
+
if self._stored_pat is None:
|
|
108
|
+
raise ConfigurationError('PAT refresh requires a store-backed client.')
|
|
109
|
+
_validate_refresh_days(expires_in_days)
|
|
110
|
+
return await self._stored_pat.refresh(self._transport, expires_in_days)
|
|
111
|
+
|
|
112
|
+
async def retry_pat_persistence(self) -> PATRefreshResult:
|
|
113
|
+
"""Reconcile or retry saving a replacement held by this client, using only store I/O.
|
|
114
|
+
|
|
115
|
+
:returns: The original secret-free result once durable storage is confirmed.
|
|
116
|
+
:raises ConfigurationError: If this client has no replacement awaiting persistence.
|
|
117
|
+
:raises PATRefreshPendingError: If another PAT operation is running on this client.
|
|
118
|
+
:raises PATPersistenceError: If the replacement still cannot be confirmed or saved.
|
|
119
|
+
"""
|
|
120
|
+
if self._stored_pat is None:
|
|
121
|
+
raise ConfigurationError('PAT persistence requires a store-backed client.')
|
|
122
|
+
return await self._stored_pat.retry_persistence()
|
|
123
|
+
|
|
124
|
+
async def aclose(self) -> None:
|
|
125
|
+
"""Close SDK-owned HTTP connections."""
|
|
126
|
+
await self._transport.aclose()
|
|
127
|
+
|
|
128
|
+
async def __aenter__(self) -> Self:
|
|
129
|
+
return self
|
|
130
|
+
|
|
131
|
+
async def __aexit__(self, *_: object) -> None:
|
|
132
|
+
await self.aclose()
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._async_pat_refresh
|
|
4
|
+
:Synopsis: Asynchronous store coordination for explicit PAT rotation
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from asyncio import Lock
|
|
13
|
+
from typing import Self
|
|
14
|
+
from uuid import UUID, uuid4
|
|
15
|
+
|
|
16
|
+
from ._async_transport import _AsyncTransport
|
|
17
|
+
from ._auth import PATAuth
|
|
18
|
+
from ._exceptions import AuthenticationError, ConfigurationError, PATPersistenceError, PATRefreshPendingError, RateLimitError
|
|
19
|
+
from ._pat_refresh import (
|
|
20
|
+
PATRefreshResult,
|
|
21
|
+
_matches_replacement,
|
|
22
|
+
_probe_error,
|
|
23
|
+
_replacement_write,
|
|
24
|
+
_require_ready,
|
|
25
|
+
_UnconfirmedWrite,
|
|
26
|
+
)
|
|
27
|
+
from ._pat_store import AsyncPATStore, PATStoreRecord
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _AsyncStoredPAT:
|
|
31
|
+
def __init__(self, store: AsyncPATStore) -> None:
|
|
32
|
+
try:
|
|
33
|
+
suitable = isinstance(store, AsyncPATStore) and callable(store.load) and callable(store.compare_and_swap)
|
|
34
|
+
except Exception:
|
|
35
|
+
suitable = False
|
|
36
|
+
if not suitable:
|
|
37
|
+
raise ConfigurationError('A versioned async PAT store adapter is required.')
|
|
38
|
+
self._store = store
|
|
39
|
+
self._operation = Lock()
|
|
40
|
+
self._unconfirmed: _UnconfirmedWrite | None = None
|
|
41
|
+
self._loaded: tuple[UUID, PATAuth]
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
async def open(cls, store: AsyncPATStore) -> Self:
|
|
45
|
+
state = cls(store)
|
|
46
|
+
record = await state._load()
|
|
47
|
+
_require_ready(record)
|
|
48
|
+
state._activate(record)
|
|
49
|
+
return state
|
|
50
|
+
|
|
51
|
+
async def _load(self) -> PATStoreRecord:
|
|
52
|
+
try:
|
|
53
|
+
record = await self._store.load()
|
|
54
|
+
except Exception:
|
|
55
|
+
record = None
|
|
56
|
+
if type(record) is PATStoreRecord:
|
|
57
|
+
return record
|
|
58
|
+
raise ConfigurationError('The PAT store could not supply a valid seeded record.')
|
|
59
|
+
|
|
60
|
+
async def _swap(self, revision: UUID, attempt: UUID | None, replacement: PATStoreRecord, failure: str) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
changed = await self._store.compare_and_swap(
|
|
63
|
+
expected_revision=revision, expected_attempt=attempt, replacement=replacement
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
changed = None
|
|
67
|
+
if type(changed) is bool:
|
|
68
|
+
return changed
|
|
69
|
+
raise PATPersistenceError(failure)
|
|
70
|
+
|
|
71
|
+
def _activate(self, record: PATStoreRecord) -> None:
|
|
72
|
+
self._loaded = (record.revision, PATAuth(record.get_pat()))
|
|
73
|
+
|
|
74
|
+
async def read_auth(self) -> PATAuth:
|
|
75
|
+
if self._unconfirmed is not None:
|
|
76
|
+
raise PATPersistenceError('Retry persistence before using this client.')
|
|
77
|
+
revision, auth = self._loaded
|
|
78
|
+
record = await self._load()
|
|
79
|
+
_require_ready(record)
|
|
80
|
+
if record.revision != revision:
|
|
81
|
+
raise ConfigurationError('The stored PAT changed. Recreate the store-backed client to reload it.')
|
|
82
|
+
return auth
|
|
83
|
+
|
|
84
|
+
async def refresh(self, transport: _AsyncTransport, days: int | None) -> PATRefreshResult:
|
|
85
|
+
if self._operation.locked():
|
|
86
|
+
raise PATRefreshPendingError('A PAT operation is already in progress on this client.')
|
|
87
|
+
async with self._operation:
|
|
88
|
+
if self._unconfirmed is not None:
|
|
89
|
+
raise PATPersistenceError('Retry persistence before refreshing again.')
|
|
90
|
+
return await self._refresh(transport, days)
|
|
91
|
+
|
|
92
|
+
async def _refresh(self, transport: _AsyncTransport, days: int | None) -> PATRefreshResult:
|
|
93
|
+
current = await self._load()
|
|
94
|
+
_require_ready(current)
|
|
95
|
+
old_pat = current.get_pat()
|
|
96
|
+
auth = PATAuth(old_pat)
|
|
97
|
+
attempt = uuid4()
|
|
98
|
+
pending = PATStoreRecord(current.revision, old_pat, attempt)
|
|
99
|
+
failure = 'Could not confirm the pending marker. No refresh was sent.'
|
|
100
|
+
if not await self._swap(current.revision, None, pending, failure):
|
|
101
|
+
raise PATRefreshPendingError('The PAT store changed before refresh. No refresh was sent.')
|
|
102
|
+
|
|
103
|
+
async with transport.refresh_once(auth, days) as (status, payload):
|
|
104
|
+
write = _replacement_write(current, attempt, payload)
|
|
105
|
+
# Retain the replacement before response cleanup or durable commit can suspend.
|
|
106
|
+
self._unconfirmed = write
|
|
107
|
+
|
|
108
|
+
if status in (401, 429):
|
|
109
|
+
failure = f'Refresh returned HTTP {status}, but the pending marker could not be cleared.'
|
|
110
|
+
if not await self._swap(current.revision, attempt, current, failure):
|
|
111
|
+
raise PATPersistenceError(failure)
|
|
112
|
+
if status == 401:
|
|
113
|
+
raise AuthenticationError()
|
|
114
|
+
raise RateLimitError()
|
|
115
|
+
|
|
116
|
+
if write is None:
|
|
117
|
+
raise _probe_error(await transport.probe_once(auth))
|
|
118
|
+
return await self._commit(write)
|
|
119
|
+
|
|
120
|
+
async def _commit(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
121
|
+
failure = 'Could not confirm durable storage of the replacement. Retry persistence on this client.'
|
|
122
|
+
if not await self._swap(write.revision, write.attempt, write.replacement, failure):
|
|
123
|
+
raise PATPersistenceError(failure)
|
|
124
|
+
return self._finish(write)
|
|
125
|
+
|
|
126
|
+
def _finish(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
127
|
+
self._activate(write.replacement)
|
|
128
|
+
self._unconfirmed = None
|
|
129
|
+
return write.result
|
|
130
|
+
|
|
131
|
+
async def retry_persistence(self) -> PATRefreshResult:
|
|
132
|
+
if self._operation.locked():
|
|
133
|
+
raise PATRefreshPendingError('A PAT operation is already in progress on this client.')
|
|
134
|
+
async with self._operation:
|
|
135
|
+
write = self._unconfirmed
|
|
136
|
+
if write is None:
|
|
137
|
+
raise ConfigurationError('This client has no replacement awaiting persistence.')
|
|
138
|
+
return await self._reconcile(write)
|
|
139
|
+
|
|
140
|
+
async def _reconcile(self, write: _UnconfirmedWrite) -> PATRefreshResult:
|
|
141
|
+
try:
|
|
142
|
+
current = await self._load()
|
|
143
|
+
except ConfigurationError:
|
|
144
|
+
current = None
|
|
145
|
+
if current is None:
|
|
146
|
+
raise PATPersistenceError('Could not read the PAT store to confirm the replacement.')
|
|
147
|
+
if current.revision == write.revision and current.pending_attempt == write.attempt:
|
|
148
|
+
return await self._commit(write)
|
|
149
|
+
if _matches_replacement(current, write):
|
|
150
|
+
return self._finish(write)
|
|
151
|
+
raise PATPersistenceError('The store changed while persistence was unresolved. Reconcile the store before use.')
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._async_transport
|
|
4
|
+
:Synopsis: Native asynchronous HTTP transport for My Trust Center
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
14
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ._auth import PATAuth
|
|
20
|
+
from ._transport import (
|
|
21
|
+
_BASE,
|
|
22
|
+
_READ_EXCEPTIONS,
|
|
23
|
+
_REFRESH_URL,
|
|
24
|
+
_build_request,
|
|
25
|
+
_can_retry,
|
|
26
|
+
_decode_json,
|
|
27
|
+
_decode_refresh,
|
|
28
|
+
_destination,
|
|
29
|
+
_pool_limits,
|
|
30
|
+
_QueryParams,
|
|
31
|
+
_read_failure,
|
|
32
|
+
_refresh_body,
|
|
33
|
+
_request_timeout,
|
|
34
|
+
_retry_delay,
|
|
35
|
+
_status_failure,
|
|
36
|
+
_validate_max_retries,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
type _PATResponse = tuple[int | None, dict[str, object] | None]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@asynccontextmanager
|
|
43
|
+
async def _closing_response(response: httpx.Response) -> AsyncIterator[httpx.Response]:
|
|
44
|
+
try:
|
|
45
|
+
yield response
|
|
46
|
+
except asyncio.CancelledError:
|
|
47
|
+
with suppress(Exception):
|
|
48
|
+
await response.aclose()
|
|
49
|
+
raise
|
|
50
|
+
except BaseException:
|
|
51
|
+
await response.aclose()
|
|
52
|
+
raise
|
|
53
|
+
else:
|
|
54
|
+
await response.aclose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _AsyncTransport:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
auth: PATAuth | Callable[[], Awaitable[PATAuth]],
|
|
61
|
+
*,
|
|
62
|
+
client: httpx.AsyncClient | None = None,
|
|
63
|
+
max_retries: int = 2,
|
|
64
|
+
) -> None:
|
|
65
|
+
self._max_retries = _validate_max_retries(max_retries)
|
|
66
|
+
self._auth = auth
|
|
67
|
+
self._timeout = _request_timeout(None)
|
|
68
|
+
self._owns_client = client is None
|
|
69
|
+
self._client = httpx.AsyncClient(trust_env=False, limits=_pool_limits()) if client is None else client
|
|
70
|
+
|
|
71
|
+
async def aclose(self) -> None:
|
|
72
|
+
if self._owns_client:
|
|
73
|
+
await self._client.aclose()
|
|
74
|
+
|
|
75
|
+
async def _send(self, request: httpx.Request, auth: PATAuth) -> httpx.Response:
|
|
76
|
+
return await self._client.send(request, auth=auth, follow_redirects=False, stream=True)
|
|
77
|
+
|
|
78
|
+
def refresh_once(self, auth: PATAuth, days: int | None) -> AbstractAsyncContextManager[_PATResponse]:
|
|
79
|
+
"""Expose the decoded response before awaiting any further cleanup."""
|
|
80
|
+
return self._pat_operation('POST', _REFRESH_URL, auth, json_body=_refresh_body(days))
|
|
81
|
+
|
|
82
|
+
async def probe_once(self, auth: PATAuth) -> int | None:
|
|
83
|
+
async with self._pat_operation('GET', _destination('/tenants'), auth) as (status, _):
|
|
84
|
+
return status
|
|
85
|
+
|
|
86
|
+
@asynccontextmanager
|
|
87
|
+
async def _pat_operation(
|
|
88
|
+
self, method: str, url: httpx.URL, auth: PATAuth, *, json_body: dict[str, int] | None = None
|
|
89
|
+
) -> AsyncIterator[_PATResponse]:
|
|
90
|
+
response = None
|
|
91
|
+
status = None
|
|
92
|
+
payload = None
|
|
93
|
+
try:
|
|
94
|
+
try:
|
|
95
|
+
response = await self._send(_build_request(method, url, self._timeout, json_body=json_body), auth)
|
|
96
|
+
status = response.status_code
|
|
97
|
+
if method == 'POST' and response.is_success:
|
|
98
|
+
payload = _decode_refresh(await response.aread())
|
|
99
|
+
except Exception:
|
|
100
|
+
# Hooks and custom transports can raise errors containing credentials.
|
|
101
|
+
payload = None
|
|
102
|
+
yield status, payload
|
|
103
|
+
finally:
|
|
104
|
+
if response is not None:
|
|
105
|
+
with suppress(Exception):
|
|
106
|
+
await response.aclose()
|
|
107
|
+
|
|
108
|
+
async def request(self, method: str, path: str, *, params: _QueryParams | None = None, json_body: Any = None) -> Any:
|
|
109
|
+
url = _destination(path)
|
|
110
|
+
method = method.upper()
|
|
111
|
+
retryable = _can_retry(method, url.path.removeprefix(_BASE.path))
|
|
112
|
+
|
|
113
|
+
for attempt in range(self._max_retries + 1):
|
|
114
|
+
request = _build_request(method, url, self._timeout, params=params, json_body=json_body)
|
|
115
|
+
auth = self._auth if isinstance(self._auth, PATAuth) else await self._auth()
|
|
116
|
+
try:
|
|
117
|
+
async with _closing_response(await self._send(request, auth)) as response:
|
|
118
|
+
if response.is_success:
|
|
119
|
+
return _decode_json(await response.aread())
|
|
120
|
+
error, transient, retry_after = _status_failure(response)
|
|
121
|
+
except _READ_EXCEPTIONS as exc:
|
|
122
|
+
error, transient = _read_failure(exc)
|
|
123
|
+
retry_after = None
|
|
124
|
+
|
|
125
|
+
if not retryable or not transient or attempt == self._max_retries:
|
|
126
|
+
raise error
|
|
127
|
+
await asyncio.sleep(_retry_delay(attempt, retry_after))
|
|
128
|
+
|
|
129
|
+
raise AssertionError('Unreachable retry state.')
|
sfdctrust/_auth.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._auth
|
|
4
|
+
:Synopsis: Internal PAT validation and single-request Bearer authentication
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from collections.abc import Generator
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import NoReturn, Self
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
from pydantic import SecretStr
|
|
19
|
+
|
|
20
|
+
from ._constants import DEFAULT_BASE_URL
|
|
21
|
+
from ._exceptions import ConfigurationError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Unset(Enum):
|
|
25
|
+
PAT = 'unset'
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _validate_pat(pat: object) -> None:
|
|
29
|
+
if not isinstance(pat, str) or not pat or any(not 0x21 <= ord(char) <= 0x7E for char in pat):
|
|
30
|
+
raise ConfigurationError('PAT must be a nonempty string of visible ASCII characters without whitespace.')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PATAuth(httpx.Auth):
|
|
34
|
+
"""Internal HTTPX authentication using an explicitly supplied PAT.
|
|
35
|
+
|
|
36
|
+
Construction performs no I/O and does not inspect the environment. PATs
|
|
37
|
+
are opaque, nonempty strings of visible ASCII characters; whitespace and
|
|
38
|
+
control characters are rejected, never stripped. No JWT parsing occurs.
|
|
39
|
+
Ordinary representations redact the secret, and pickling is disabled.
|
|
40
|
+
|
|
41
|
+
This component only attaches credentials. It does not translate HTTP
|
|
42
|
+
statuses, retry, or refresh a PAT. Future transport code must disable
|
|
43
|
+
redirects; HTTPX handles redirect requests outside this auth flow.
|
|
44
|
+
|
|
45
|
+
:param pat: The PAT itself, without a Bearer prefix.
|
|
46
|
+
:raises ConfigurationError: If the PAT is missing, blank, or unsafe for a header.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, pat: str) -> None:
|
|
50
|
+
_validate_pat(pat)
|
|
51
|
+
self._pat = SecretStr(pat)
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def from_env(cls, *, pat: str | None | _Unset = _Unset.PAT) -> Self:
|
|
55
|
+
"""Read only ``SFDCTRUST_PAT``, unless an override is supplied.
|
|
56
|
+
|
|
57
|
+
An explicit override wins without reading the environment. Empty,
|
|
58
|
+
blank, and explicitly supplied ``None`` overrides fail validation;
|
|
59
|
+
they never fall back to the environment. No credential files or
|
|
60
|
+
``.env`` files are read, and the environment is never modified.
|
|
61
|
+
|
|
62
|
+
:param pat: Optional explicit override; omission selects the environment.
|
|
63
|
+
:returns: A validated authentication object.
|
|
64
|
+
:raises ConfigurationError: If the selected credential is invalid or absent.
|
|
65
|
+
"""
|
|
66
|
+
selected = os.environ.get('SFDCTRUST_PAT') if pat is _Unset.PAT else pat
|
|
67
|
+
if not isinstance(selected, str):
|
|
68
|
+
raise ConfigurationError('PAT must be a nonempty string of visible ASCII characters without whitespace.')
|
|
69
|
+
return cls(selected)
|
|
70
|
+
|
|
71
|
+
def __repr__(self) -> str:
|
|
72
|
+
return f'{type(self).__name__}(pat=**********)'
|
|
73
|
+
|
|
74
|
+
def __getstate__(self) -> NoReturn:
|
|
75
|
+
"""Prevent accidental credential serialization through pickle."""
|
|
76
|
+
raise TypeError('PAT authentication objects cannot be serialized.')
|
|
77
|
+
|
|
78
|
+
def auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:
|
|
79
|
+
"""Attach the PAT once, only to the production HTTPS origin.
|
|
80
|
+
|
|
81
|
+
:param request: The request to authenticate.
|
|
82
|
+
:yields: The request with its Authorization header replaced.
|
|
83
|
+
:raises ConfigurationError: If the destination is outside the allowed origin
|
|
84
|
+
or contains URL user information.
|
|
85
|
+
"""
|
|
86
|
+
destination = request.url
|
|
87
|
+
production = httpx.URL(DEFAULT_BASE_URL)
|
|
88
|
+
if (
|
|
89
|
+
destination.scheme != production.scheme
|
|
90
|
+
or destination.host != production.host
|
|
91
|
+
or destination.port != production.port
|
|
92
|
+
or destination.userinfo
|
|
93
|
+
):
|
|
94
|
+
raise ConfigurationError('PAT authentication requires the production HTTPS origin without URL user information.')
|
|
95
|
+
request.headers['Authorization'] = f'Bearer {self._pat.get_secret_value()}'
|
|
96
|
+
yield request
|
sfdctrust/_client.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
:Module: sfdctrust._client
|
|
4
|
+
:Synopsis: Synchronous My Trust Center client
|
|
5
|
+
:Created By: Jeff Shurtliff
|
|
6
|
+
:Last Modified: Jeff Shurtliff (via GPT-6.1 Sol)
|
|
7
|
+
:Modified Date: 04 Nov 2026
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Self
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from ._auth import PATAuth, _Unset
|
|
17
|
+
from ._exceptions import ConfigurationError
|
|
18
|
+
from ._pat_refresh import PATRefreshResult, _StoredPAT, _validate_refresh_days
|
|
19
|
+
from ._pat_store import PATStore
|
|
20
|
+
from ._transport import _Transport
|
|
21
|
+
from .resources.events import EventsResource
|
|
22
|
+
from .resources.status import StatusResource
|
|
23
|
+
from .resources.tenants import TenantsResource
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TrustCenterClient:
|
|
27
|
+
"""Synchronous client for the Salesforce My Trust Center API.
|
|
28
|
+
|
|
29
|
+
Construction makes no network requests. The SDK owns its transport and
|
|
30
|
+
closes its default HTTP client on context exit or :meth:`close`.
|
|
31
|
+
|
|
32
|
+
:param pat: Personal access token without the Bearer prefix.
|
|
33
|
+
:param http_client: Trusted HTTPX client whose lifecycle and TLS, proxy,
|
|
34
|
+
hook, and transport settings remain the caller's responsibility.
|
|
35
|
+
:param max_retries: Additional SDK attempts per request, from 0 through 5.
|
|
36
|
+
:raises ConfigurationError: If the PAT or retry limit is invalid.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, pat: str, *, http_client: httpx.Client | None = None, max_retries: int = 2) -> None:
|
|
40
|
+
self._initialize(PATAuth(pat), http_client, max_retries)
|
|
41
|
+
|
|
42
|
+
def _initialize(self, auth: PATAuth | _StoredPAT, http_client: httpx.Client | None, max_retries: int) -> None:
|
|
43
|
+
self._stored_pat = auth if isinstance(auth, _StoredPAT) else None
|
|
44
|
+
self._transport = _Transport(
|
|
45
|
+
auth.read_auth if isinstance(auth, _StoredPAT) else auth, client=http_client, max_retries=max_retries
|
|
46
|
+
)
|
|
47
|
+
self.tenants = TenantsResource(self._transport)
|
|
48
|
+
self.status = StatusResource(self._transport)
|
|
49
|
+
self.events = EventsResource(self._transport)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_env(
|
|
53
|
+
cls, *, pat: str | None | _Unset = _Unset.PAT, http_client: httpx.Client | None = None, max_retries: int = 2
|
|
54
|
+
) -> Self:
|
|
55
|
+
"""Create a client using ``SFDCTRUST_PAT`` or an explicit override.
|
|
56
|
+
|
|
57
|
+
:param pat: An explicit override. Omitting it selects the environment.
|
|
58
|
+
Explicit ``None`` or any invalid override raises immediately.
|
|
59
|
+
:param http_client: Optional caller-owned, trusted HTTPX client.
|
|
60
|
+
:param max_retries: Additional SDK attempts per request, from 0 through 5.
|
|
61
|
+
:returns: A configured client.
|
|
62
|
+
:raises ConfigurationError: If the selected PAT is absent or invalid, or the retry limit is invalid.
|
|
63
|
+
"""
|
|
64
|
+
auth = PATAuth.from_env(pat=pat)
|
|
65
|
+
client = cls.__new__(cls)
|
|
66
|
+
client._initialize(auth, http_client, max_retries)
|
|
67
|
+
return client
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_store(cls, store: PATStore, *, http_client: httpx.Client | None = None, max_retries: int = 2) -> Self:
|
|
71
|
+
"""Load an explicitly configured, seeded PAT store for one lineage.
|
|
72
|
+
|
|
73
|
+
:param store: Trusted durable adapter shared by all rotators of this PAT.
|
|
74
|
+
:param http_client: Optional caller-owned, trusted HTTPX client. Its
|
|
75
|
+
transport and hooks must never replay a refresh.
|
|
76
|
+
:param max_retries: Additional SDK attempts per eligible read, from 0 through 5.
|
|
77
|
+
:returns: A configured client.
|
|
78
|
+
:raises ConfigurationError: If the store is unsuitable, unreadable,
|
|
79
|
+
unseeded, or the retry limit is invalid.
|
|
80
|
+
:raises PATRefreshPendingError: If the store has an unresolved rotation.
|
|
81
|
+
"""
|
|
82
|
+
auth = _StoredPAT(store)
|
|
83
|
+
client = cls.__new__(cls)
|
|
84
|
+
client._initialize(auth, http_client, max_retries)
|
|
85
|
+
return client
|
|
86
|
+
|
|
87
|
+
def refresh_pat(self, *, expires_in_days: int | None = None) -> PATRefreshResult:
|
|
88
|
+
"""Explicitly rotate a stored PAT once and activate its durable replacement.
|
|
89
|
+
|
|
90
|
+
:param expires_in_days: Replacement lifetime from 1 through 30.
|
|
91
|
+
None omits the body and uses Salesforce's default.
|
|
92
|
+
:returns: A secret-free result indicating whether response metadata validated.
|
|
93
|
+
:raises ConfigurationError: If the client uses a static credential, the
|
|
94
|
+
lifetime is invalid, or the store is unreadable.
|
|
95
|
+
:raises PATRefreshPendingError: If a pending or competing operation blocks rotation.
|
|
96
|
+
:raises AuthenticationError: If Salesforce rejected refresh with HTTP 401.
|
|
97
|
+
:raises RateLimitError: If Salesforce rejected refresh with HTTP 429.
|
|
98
|
+
:raises PATRefreshAmbiguousError: If Salesforce may have rotated without a usable replacement.
|
|
99
|
+
:raises PATPersistenceError: If a required durable state transition cannot be confirmed.
|
|
100
|
+
"""
|
|
101
|
+
if self._stored_pat is None:
|
|
102
|
+
raise ConfigurationError('PAT refresh requires a store-backed client.')
|
|
103
|
+
_validate_refresh_days(expires_in_days)
|
|
104
|
+
return self._stored_pat.refresh(self._transport, expires_in_days)
|
|
105
|
+
|
|
106
|
+
def retry_pat_persistence(self) -> PATRefreshResult:
|
|
107
|
+
"""Reconcile or retry saving a replacement held by this client, using only store I/O.
|
|
108
|
+
|
|
109
|
+
:returns: The original secret-free result once durable storage is confirmed.
|
|
110
|
+
:raises ConfigurationError: If this client has no replacement awaiting persistence.
|
|
111
|
+
:raises PATRefreshPendingError: If another PAT operation is running on this client.
|
|
112
|
+
:raises PATPersistenceError: If the replacement still cannot be confirmed or saved.
|
|
113
|
+
"""
|
|
114
|
+
if self._stored_pat is None:
|
|
115
|
+
raise ConfigurationError('PAT persistence requires a store-backed client.')
|
|
116
|
+
return self._stored_pat.retry_persistence()
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
"""Close SDK-owned HTTP connections."""
|
|
120
|
+
self._transport.close()
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> Self:
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *_: object) -> None:
|
|
126
|
+
self.close()
|