contextbase-shared-plugins 0.0.0a1__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.
- contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
- contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
- shared_plugins/__init__.py +8 -0
- shared_plugins/automation.py +11 -0
- shared_plugins/base_platform.py +105 -0
- shared_plugins/bindings.py +260 -0
- shared_plugins/dlt.py +89 -0
- shared_plugins/env.py +104 -0
- shared_plugins/exceptions.py +10 -0
- shared_plugins/google_client/__init__.py +1 -0
- shared_plugins/google_client/auth.py +108 -0
- shared_plugins/google_client/batch_retry.py +308 -0
- shared_plugins/google_client/http_errors.py +27 -0
- shared_plugins/machine_token.py +419 -0
- shared_plugins/microsoft_dataverse/__init__.py +27 -0
- shared_plugins/microsoft_dataverse/annotations.py +61 -0
- shared_plugins/microsoft_dataverse/auth.py +26 -0
- shared_plugins/microsoft_dataverse/binding_config.py +35 -0
- shared_plugins/microsoft_dataverse/client.py +468 -0
- shared_plugins/microsoft_dataverse/ctx.py +21 -0
- shared_plugins/microsoft_dataverse/identifiers.py +62 -0
- shared_plugins/microsoft_dataverse/ingress.py +53 -0
- shared_plugins/microsoft_dataverse/metadata.py +106 -0
- shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
- shared_plugins/microsoft_dataverse/source.py +299 -0
- shared_plugins/microsoft_dataverse/tables.py +34 -0
- shared_plugins/microsoft_dataverse/translators.py +133 -0
- shared_plugins/microsoft_dataverse/types.py +355 -0
- shared_plugins/microsoft_graph.py +250 -0
- shared_plugins/models.py +91 -0
- shared_plugins/naming.py +83 -0
- shared_plugins/pg_column_comments.py +59 -0
- shared_plugins/provider_token.py +238 -0
- shared_plugins/pyairbyte.py +485 -0
- shared_plugins/resources.py +179 -0
- shared_plugins/scratch.py +127 -0
- shared_plugins/sentry.py +117 -0
- shared_plugins/sqlalchemy_types.py +225 -0
- shared_plugins/sqlite.py +123 -0
- shared_plugins/values.py +117 -0
shared_plugins/naming.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _slugify(value: str) -> str:
|
|
11
|
+
return re.sub(r"[^a-zA-Z0-9]+", "_", value).strip("_").lower() or "value"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@lru_cache(maxsize=None)
|
|
15
|
+
def _plugin_id_from_dir(plugin_dir: str) -> str:
|
|
16
|
+
manifest_path = Path(plugin_dir) / "plugin.json"
|
|
17
|
+
with manifest_path.open("r", encoding="utf-8") as manifest_file:
|
|
18
|
+
manifest = json.load(manifest_file)
|
|
19
|
+
return str(manifest["plugin_id"]).strip()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def plugin_id_from_module(module_file: str) -> str:
|
|
23
|
+
module_path = Path(module_file).resolve()
|
|
24
|
+
current = module_path.parent
|
|
25
|
+
|
|
26
|
+
while True:
|
|
27
|
+
if (current / "plugin.json").exists():
|
|
28
|
+
return _plugin_id_from_dir(str(current))
|
|
29
|
+
if current == current.parent:
|
|
30
|
+
break
|
|
31
|
+
current = current.parent
|
|
32
|
+
|
|
33
|
+
raise FileNotFoundError(
|
|
34
|
+
f"Could not resolve plugin.json for module '{module_file}'."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dlt_dataset_name(plugin_id: str) -> str:
|
|
39
|
+
return plugin_id.strip()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def dagster_asset_group_name(plugin_id: str) -> str:
|
|
43
|
+
return plugin_id.strip()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def dagster_partition_def_name(plugin_id: str) -> str:
|
|
47
|
+
return f"{plugin_id.strip()}_bindings"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def dagster_pool_name(plugin_id: str) -> str:
|
|
51
|
+
return f"{plugin_id.strip()}_pool"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dlt_source_name(plugin_id: str, job: str) -> str:
|
|
55
|
+
return f"{_slugify(plugin_id)}_{_slugify(job)}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def dlt_resource_name(entity: str) -> str:
|
|
59
|
+
return entity.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def dlt_pipeline_name(plugin_id: str, binding_id: str, job: str) -> str:
|
|
63
|
+
return f"{_slugify(plugin_id)}__{_slugify(binding_id)}__{_slugify(job)}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dagster_dlt_asset_key(source_name: str, entity: str) -> str:
|
|
67
|
+
"""Default asset key produced by DagsterDltTranslator for a dlt resource."""
|
|
68
|
+
return f"dlt_{source_name}_{entity}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dagster_airbyte_sync_asset_key(plugin_id: str) -> str:
|
|
72
|
+
return f"ab_{_slugify(plugin_id)}_sync"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dagster_asset_tags(
|
|
76
|
+
plugin_id: str,
|
|
77
|
+
*,
|
|
78
|
+
extra_tags: Mapping[str, str] | None = None,
|
|
79
|
+
) -> dict[str, str]:
|
|
80
|
+
tags = {"plugin_id": plugin_id.strip()}
|
|
81
|
+
if extra_tags:
|
|
82
|
+
tags.update(dict(extra_tags))
|
|
83
|
+
return tags
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Monkey-patch PostgresClient to emit COMMENT ON COLUMN for dlt column descriptions.
|
|
2
|
+
|
|
3
|
+
dlt's ``TColumnSchema`` supports a ``description`` field, but the Postgres destination
|
|
4
|
+
does not act on it. Databricks and Snowflake do (inline ``COMMENT`` in DDL). Postgres
|
|
5
|
+
uses a separate ``COMMENT ON COLUMN`` statement, so we hook into
|
|
6
|
+
PostgresClient's ``_build_schema_update_sql`` to append COMMENT statements for the
|
|
7
|
+
same tables dlt is updating — regardless of whether those tables have new columns.
|
|
8
|
+
|
|
9
|
+
ctxb docs reads ``COMMENT ON COLUMN`` metadata and surfaces it in generated schema docs
|
|
10
|
+
that are injected into the agent context. This closes the loop:
|
|
11
|
+
|
|
12
|
+
dlt schema (description) → COMMENT ON COLUMN → ctxb docs → agent prompt
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Iterable, List, Tuple
|
|
18
|
+
|
|
19
|
+
from dlt.common.data_writers.escape import (
|
|
20
|
+
escape_postgres_identifier,
|
|
21
|
+
escape_postgres_literal,
|
|
22
|
+
)
|
|
23
|
+
from dlt.common.schema.typing import (
|
|
24
|
+
TSchemaTables,
|
|
25
|
+
TTableSchemaColumns,
|
|
26
|
+
)
|
|
27
|
+
from dlt.destinations.impl.postgres.postgres import PostgresClient
|
|
28
|
+
|
|
29
|
+
_original_build_schema_update_sql = PostgresClient._build_schema_update_sql
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_schema_update_sql_with_comments(
|
|
33
|
+
self: PostgresClient,
|
|
34
|
+
storage_tables: Iterable[Tuple[str, TTableSchemaColumns]],
|
|
35
|
+
) -> Tuple[List[str], TSchemaTables]:
|
|
36
|
+
storage_tables = list(storage_tables)
|
|
37
|
+
sql_updates, schema_update = _original_build_schema_update_sql(self, storage_tables)
|
|
38
|
+
|
|
39
|
+
dlt_table_names = set(self.schema.dlt_table_names())
|
|
40
|
+
for table_name, _storage_columns in storage_tables:
|
|
41
|
+
if table_name in dlt_table_names:
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
full_table = self.prepare_load_table(table_name)
|
|
45
|
+
qualified_table = self.sql_client.make_qualified_table_name(table_name)
|
|
46
|
+
for col in full_table["columns"].values():
|
|
47
|
+
description = col.get("description")
|
|
48
|
+
comment_literal = (
|
|
49
|
+
escape_postgres_literal(description) if description else "NULL"
|
|
50
|
+
)
|
|
51
|
+
col_name = escape_postgres_identifier(col["name"])
|
|
52
|
+
sql_updates.append(
|
|
53
|
+
f"COMMENT ON COLUMN {qualified_table}.{col_name} IS {comment_literal}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return sql_updates, schema_update
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
PostgresClient._build_schema_update_sql = _build_schema_update_sql_with_comments # type: ignore[method-assign]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
from base_client import BindingAuth_AuthenticatedAccount
|
|
11
|
+
from shared_plugins.env import (
|
|
12
|
+
CTXB_SCRATCH_DIR_ENV_VAR,
|
|
13
|
+
normalize_optional_absolute_path,
|
|
14
|
+
normalize_required_string,
|
|
15
|
+
)
|
|
16
|
+
from shared_plugins.machine_token import (
|
|
17
|
+
CTX_MACHINE_CLIENT_ID_ENV_VAR,
|
|
18
|
+
CTX_MACHINE_CLIENT_SECRET_ENV_VAR,
|
|
19
|
+
CTX_WEB_URL_ENV_VAR,
|
|
20
|
+
MachineTokenMinter,
|
|
21
|
+
raise_on_error,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
# Redeems third-party provider tokens server-side (the Python parallel of web's
|
|
25
|
+
# `lib/ctx-token.ts` redemption endpoint). The daemon presents its `aud=ctx`
|
|
26
|
+
# machine token — minted and cached by `shared_plugins.machine_token`, the one
|
|
27
|
+
# audited copy of the mint — to web's `/provider-token` endpoint, which returns
|
|
28
|
+
# a fresh Google/Slack access token. Host-side only: the machine secret and
|
|
29
|
+
# the minted token never enter the synthesis guest, where a prompt-injected
|
|
30
|
+
# synthesizer could exfiltrate them.
|
|
31
|
+
#
|
|
32
|
+
# One extra hardening the TS side deferred: on a 401 from `/provider-token` the
|
|
33
|
+
# Python caller CAN observe the downstream status, so it invalidates the failed
|
|
34
|
+
# machine token and retries once with a fresh one — re-minted, or adopted from
|
|
35
|
+
# the deployment's shared cache when another process already replaced it (the
|
|
36
|
+
# TS `() => Promise<string>` seam could not).
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ProviderTokenSettings(BaseSettings):
|
|
40
|
+
"""Machine-auth config for provider-token redemption — all required, no
|
|
41
|
+
fallback. CTX_WEB_URL has NO localhost default here (unlike the CLI env): a
|
|
42
|
+
daemon that redeems provider tokens is always platform-managed, so a missing or
|
|
43
|
+
blank var is a deploy mistake, not a local-dev mode — fail loud."""
|
|
44
|
+
|
|
45
|
+
model_config = SettingsConfigDict(extra="ignore")
|
|
46
|
+
|
|
47
|
+
ctx_web_url: str = Field(alias=CTX_WEB_URL_ENV_VAR)
|
|
48
|
+
ctx_machine_client_id: str = Field(alias=CTX_MACHINE_CLIENT_ID_ENV_VAR)
|
|
49
|
+
ctx_machine_client_secret: str = Field(alias=CTX_MACHINE_CLIENT_SECRET_ENV_VAR)
|
|
50
|
+
# The deployment-shared machine-token cache lives here (see machine_token.py);
|
|
51
|
+
# required for the same reason as the creds — a redeeming daemon without it
|
|
52
|
+
# would regress to per-process minting.
|
|
53
|
+
ctx_scratch_dir: Path = Field(alias=CTXB_SCRATCH_DIR_ENV_VAR)
|
|
54
|
+
|
|
55
|
+
@field_validator("ctx_web_url", mode="before")
|
|
56
|
+
@classmethod
|
|
57
|
+
def _validate_web_url(cls, value: object) -> str:
|
|
58
|
+
normalized = normalize_required_string(value, env_var_name=CTX_WEB_URL_ENV_VAR)
|
|
59
|
+
if not normalized.startswith(("http://", "https://")):
|
|
60
|
+
raise ValueError(f"{CTX_WEB_URL_ENV_VAR} must use http:// or https://.")
|
|
61
|
+
return normalized.rstrip("/")
|
|
62
|
+
|
|
63
|
+
@field_validator("ctx_machine_client_id", mode="before")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _validate_client_id(cls, value: object) -> str:
|
|
66
|
+
return normalize_required_string(
|
|
67
|
+
value, env_var_name=CTX_MACHINE_CLIENT_ID_ENV_VAR
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@field_validator("ctx_scratch_dir", mode="before")
|
|
71
|
+
@classmethod
|
|
72
|
+
def _validate_scratch_dir(cls, value: object) -> Path:
|
|
73
|
+
# The one canonical CTXB_SCRATCH_DIR normalization (absolute, resolved).
|
|
74
|
+
normalized = normalize_optional_absolute_path(
|
|
75
|
+
value, env_var_name=CTXB_SCRATCH_DIR_ENV_VAR
|
|
76
|
+
)
|
|
77
|
+
if normalized is None:
|
|
78
|
+
raise ValueError(f"{CTXB_SCRATCH_DIR_ENV_VAR} cannot be blank.")
|
|
79
|
+
return normalized
|
|
80
|
+
|
|
81
|
+
@field_validator("ctx_machine_client_secret", mode="before")
|
|
82
|
+
@classmethod
|
|
83
|
+
def _validate_client_secret(cls, value: object) -> str:
|
|
84
|
+
return normalize_required_string(
|
|
85
|
+
value, env_var_name=CTX_MACHINE_CLIENT_SECRET_ENV_VAR
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ProviderTokenLease(BaseModel):
|
|
90
|
+
"""A redeemed provider access token. `accessTokenExpiresAt` is the
|
|
91
|
+
provider-DECLARED expiry: ISO-8601 with an offset → parsed to a tz-aware
|
|
92
|
+
datetime (a naive value would make `_normalize_google_expiry` raise
|
|
93
|
+
downstream), or None for providers that declare no expiry (notion never
|
|
94
|
+
sends expires_in). None means "valid until proven otherwise" — the caller's
|
|
95
|
+
recovery is a `refresh=True` redemption, which web maps to the provider's
|
|
96
|
+
real refresh grant.
|
|
97
|
+
|
|
98
|
+
TS twin: packages/base-schemas/lib/provider-token-lease.ts (the slate-bridge
|
|
99
|
+
MCP gateway's copy of this contract) — a contract change updates BOTH."""
|
|
100
|
+
|
|
101
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
102
|
+
|
|
103
|
+
access_token: str = Field(alias="accessToken", min_length=1)
|
|
104
|
+
access_token_expires_at: datetime | None = Field(alias="accessTokenExpiresAt")
|
|
105
|
+
|
|
106
|
+
@field_validator("access_token_expires_at")
|
|
107
|
+
@classmethod
|
|
108
|
+
def _require_tz_aware(cls, value: datetime | None) -> datetime | None:
|
|
109
|
+
if value is None:
|
|
110
|
+
return value
|
|
111
|
+
if value.tzinfo is None or value.utcoffset() is None:
|
|
112
|
+
raise ValueError("accessTokenExpiresAt must be timezone-aware.")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ProviderTokenClient:
|
|
117
|
+
"""Redeems provider tokens through web's `/provider-token`, authenticated by
|
|
118
|
+
the `aud=ctx` machine token from `MachineTokenMinter`; a 401 from
|
|
119
|
+
`/provider-token` invalidates the failed token and retries once with a fresh
|
|
120
|
+
one (re-minted, or adopted from the deployment's shared cache)."""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
settings: ProviderTokenSettings,
|
|
125
|
+
*,
|
|
126
|
+
http_client: httpx.Client | None = None,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._redeem_url = f"{settings.ctx_web_url}/api/auth/provider-token"
|
|
129
|
+
# One plain (auth-less) client serves both the redeem POSTs and the
|
|
130
|
+
# minter's token POSTs — headers are stamped explicitly per call.
|
|
131
|
+
self._http = http_client or httpx.Client(timeout=30.0)
|
|
132
|
+
self._minter = MachineTokenMinter(
|
|
133
|
+
web_url=settings.ctx_web_url,
|
|
134
|
+
client_id=settings.ctx_machine_client_id,
|
|
135
|
+
client_secret=settings.ctx_machine_client_secret,
|
|
136
|
+
http_client=self._http,
|
|
137
|
+
# The same client credentials mint the same aud=ctx token, so the
|
|
138
|
+
# redemption path shares the deployment's one cached token instead
|
|
139
|
+
# of minting its own per run/step.
|
|
140
|
+
shared_cache_dir=settings.ctx_scratch_dir,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def redeem(
|
|
144
|
+
self,
|
|
145
|
+
*,
|
|
146
|
+
provider_id: str,
|
|
147
|
+
account_id: str,
|
|
148
|
+
refresh: bool = False,
|
|
149
|
+
) -> ProviderTokenLease:
|
|
150
|
+
response = self._post_redeem(provider_id, account_id, refresh)
|
|
151
|
+
if response.status_code == httpx.codes.UNAUTHORIZED:
|
|
152
|
+
# The machine token may have lapsed / been revoked RS-side. Invalidate
|
|
153
|
+
# it and retry once with a fresh token before surfacing the failure.
|
|
154
|
+
self._minter.invalidate()
|
|
155
|
+
response = self._post_redeem(provider_id, account_id, refresh)
|
|
156
|
+
raise_on_error(response, context="Failed to redeem the provider token")
|
|
157
|
+
return ProviderTokenLease.model_validate(response.json())
|
|
158
|
+
|
|
159
|
+
def _post_redeem(
|
|
160
|
+
self, provider_id: str, account_id: str, refresh: bool
|
|
161
|
+
) -> httpx.Response:
|
|
162
|
+
return self._http.post(
|
|
163
|
+
self._redeem_url,
|
|
164
|
+
json={
|
|
165
|
+
"accountId": account_id,
|
|
166
|
+
"providerId": provider_id,
|
|
167
|
+
"refresh": refresh,
|
|
168
|
+
},
|
|
169
|
+
headers={"authorization": f"Bearer {self._minter.token()}"},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# Re-redeem this long before the stated expiry so a run that outlives one
|
|
174
|
+
# delegated token (a cold-start mail backfill can outlive a single token) never
|
|
175
|
+
# stamps a bearer that lapses mid-request. Matches the intent of azure.identity's
|
|
176
|
+
# own near-expiry refresh on the client_credentials path.
|
|
177
|
+
_DELEGATED_REFRESH_SKEW = timedelta(minutes=5)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class DelegatedTokenProvider:
|
|
181
|
+
"""Caching access-token source for an ``authenticated_account`` binding — the
|
|
182
|
+
delegated sibling of the plugins' ``ClientSecretCredential`` path. Both yield a
|
|
183
|
+
bearer per call; this one redeems the binding's linked account server-side
|
|
184
|
+
through ``/provider-token`` (``ProviderTokenClient``) rather than holding a
|
|
185
|
+
client secret, so a plugin's sync code stays credential-agnostic (the
|
|
186
|
+
write-side MCP spec's R7 "one plugin, one auth" — a dual-tolerant seam).
|
|
187
|
+
|
|
188
|
+
Caches the redeemed lease and re-redeems within ``_DELEGATED_REFRESH_SKEW`` of
|
|
189
|
+
expiry, so a run that outlives one token rotates transparently — parity with
|
|
190
|
+
azure.identity's own get_token caching on the client_credentials path. The
|
|
191
|
+
first redemption is ``refresh=False`` (web heals a stale stored token
|
|
192
|
+
transparently); a near-expiry re-redemption is ``refresh=True`` to force real
|
|
193
|
+
rotation, mirroring the google refresh path.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
auth: BindingAuth_AuthenticatedAccount,
|
|
200
|
+
provider_token_client: ProviderTokenClient,
|
|
201
|
+
) -> None:
|
|
202
|
+
self._auth = auth
|
|
203
|
+
self._client = provider_token_client
|
|
204
|
+
self._lease: ProviderTokenLease | None = None
|
|
205
|
+
|
|
206
|
+
def current_lease(self) -> ProviderTokenLease:
|
|
207
|
+
"""The cached lease — redeemed on first use, re-redeemed within the refresh
|
|
208
|
+
skew of expiry. The shared ``DelegatedTokenCredential`` reads its expiry to
|
|
209
|
+
hand azure a real ``AccessToken.expires_on``; Dataverse's ``__call__`` needs
|
|
210
|
+
only the token."""
|
|
211
|
+
now = datetime.now(timezone.utc)
|
|
212
|
+
lease = self._lease
|
|
213
|
+
if lease is not None and now < _lease_expiry(lease) - _DELEGATED_REFRESH_SKEW:
|
|
214
|
+
return lease
|
|
215
|
+
|
|
216
|
+
lease = self._client.redeem(
|
|
217
|
+
provider_id=self._auth.provider_id,
|
|
218
|
+
account_id=self._auth.account_id,
|
|
219
|
+
refresh=self._lease is not None,
|
|
220
|
+
)
|
|
221
|
+
self._lease = lease
|
|
222
|
+
return lease
|
|
223
|
+
|
|
224
|
+
def __call__(self) -> str:
|
|
225
|
+
return self.current_lease().access_token
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _lease_expiry(lease: ProviderTokenLease) -> datetime:
|
|
229
|
+
# DelegatedTokenProvider manages refresh off the lease's declared expiry; a
|
|
230
|
+
# no-expiry lease (a notion-shaped provider) gives no refresh horizon to
|
|
231
|
+
# manage. The microsoft providers this serves always declare one — fail loud
|
|
232
|
+
# rather than silently ride a token we cannot refresh.
|
|
233
|
+
if lease.access_token_expires_at is None:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
"delegated provider-token lease declares no expiry; "
|
|
236
|
+
"DelegatedTokenProvider cannot manage refresh without one."
|
|
237
|
+
)
|
|
238
|
+
return lease.access_token_expires_at
|