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.
Files changed (41) hide show
  1. contextbase_shared_plugins-0.0.0a1.dist-info/METADATA +27 -0
  2. contextbase_shared_plugins-0.0.0a1.dist-info/RECORD +41 -0
  3. contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL +4 -0
  4. shared_plugins/__init__.py +8 -0
  5. shared_plugins/automation.py +11 -0
  6. shared_plugins/base_platform.py +105 -0
  7. shared_plugins/bindings.py +260 -0
  8. shared_plugins/dlt.py +89 -0
  9. shared_plugins/env.py +104 -0
  10. shared_plugins/exceptions.py +10 -0
  11. shared_plugins/google_client/__init__.py +1 -0
  12. shared_plugins/google_client/auth.py +108 -0
  13. shared_plugins/google_client/batch_retry.py +308 -0
  14. shared_plugins/google_client/http_errors.py +27 -0
  15. shared_plugins/machine_token.py +419 -0
  16. shared_plugins/microsoft_dataverse/__init__.py +27 -0
  17. shared_plugins/microsoft_dataverse/annotations.py +61 -0
  18. shared_plugins/microsoft_dataverse/auth.py +26 -0
  19. shared_plugins/microsoft_dataverse/binding_config.py +35 -0
  20. shared_plugins/microsoft_dataverse/client.py +468 -0
  21. shared_plugins/microsoft_dataverse/ctx.py +21 -0
  22. shared_plugins/microsoft_dataverse/identifiers.py +62 -0
  23. shared_plugins/microsoft_dataverse/ingress.py +53 -0
  24. shared_plugins/microsoft_dataverse/metadata.py +106 -0
  25. shared_plugins/microsoft_dataverse/runtime_schema.py +332 -0
  26. shared_plugins/microsoft_dataverse/source.py +299 -0
  27. shared_plugins/microsoft_dataverse/tables.py +34 -0
  28. shared_plugins/microsoft_dataverse/translators.py +133 -0
  29. shared_plugins/microsoft_dataverse/types.py +355 -0
  30. shared_plugins/microsoft_graph.py +250 -0
  31. shared_plugins/models.py +91 -0
  32. shared_plugins/naming.py +83 -0
  33. shared_plugins/pg_column_comments.py +59 -0
  34. shared_plugins/provider_token.py +238 -0
  35. shared_plugins/pyairbyte.py +485 -0
  36. shared_plugins/resources.py +179 -0
  37. shared_plugins/scratch.py +127 -0
  38. shared_plugins/sentry.py +117 -0
  39. shared_plugins/sqlalchemy_types.py +225 -0
  40. shared_plugins/sqlite.py +123 -0
  41. shared_plugins/values.py +117 -0
@@ -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