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 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()