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,27 @@
1
+ Metadata-Version: 2.3
2
+ Name: contextbase-shared-plugins
3
+ Version: 0.0.0a1
4
+ Summary: Shared infrastructure for ContextBase plugins
5
+ Author: Alizain Feerasta
6
+ Author-email: Alizain Feerasta <alizain.feerasta@gmail.com>
7
+ Requires-Dist: contextbase-base-client==0.0.0a1
8
+ Requires-Dist: airbyte
9
+ Requires-Dist: azure-core>=1.30.0
10
+ Requires-Dist: azure-identity>=1.25.1
11
+ Requires-Dist: dagster==1.12.14
12
+ Requires-Dist: dagster-dlt==0.28.14
13
+ Requires-Dist: dlt[postgres]>=1.26.0
14
+ Requires-Dist: google-api-python-client>=2.185.0
15
+ Requires-Dist: google-auth>=2.0.0
16
+ Requires-Dist: httpx>=0.28.1
17
+ Requires-Dist: microsoft-kiota-abstractions>=1.10.1
18
+ Requires-Dist: microsoft-kiota-authentication-azure>=1.10.1
19
+ Requires-Dist: microsoft-kiota-serialization-json>=1.11.7
20
+ Requires-Dist: msgraph-core>=1.3.8
21
+ Requires-Dist: psycopg2-binary
22
+ Requires-Dist: pydantic>=2.12.0
23
+ Requires-Dist: sqlalchemy>=2.0.0
24
+ Requires-Dist: pydantic-settings>=2.13.1
25
+ Requires-Dist: tenacity>=8.2
26
+ Requires-Dist: sentry-sdk>=2.35.0
27
+ Requires-Python: >=3.12, <3.13
@@ -0,0 +1,41 @@
1
+ shared_plugins/__init__.py,sha256=qsWU9-bf0rmqndl4KN1W7sH3XR6uPiQzZNexICQeo4Q,378
2
+ shared_plugins/automation.py,sha256=ZFpRzQUnyH_J_GoSZCrYYWNO6OcsnvbQ_kjFS2BnetA,380
3
+ shared_plugins/base_platform.py,sha256=ASqlHugzwcdaqkatIKAX1CqPvrLYfLralsIpuURg7bU,4225
4
+ shared_plugins/bindings.py,sha256=du7XzCkpNEMEzdH49fEmQsiofvBLCdAOKthzvf-vOKI,7725
5
+ shared_plugins/dlt.py,sha256=T-GtgWLESlhkK3Bv4crWKY4370ZHXkWTSQBddM_BsJg,3151
6
+ shared_plugins/env.py,sha256=TMrMsnM0qjOXuQsE4SmKD1gvpR28iOqt9ATEcP2GYQE,3647
7
+ shared_plugins/exceptions.py,sha256=hV-Dn01TrvmFpMkRoNRy629KLL-Rdh3zi82z9JAodKM,318
8
+ shared_plugins/google_client/__init__.py,sha256=56uWeZViYLtnuD5X5iSiwmbbZVSMSuRhpOhjpPrwrAk,62
9
+ shared_plugins/google_client/auth.py,sha256=7F87U38qVKKtkdgg4-eiuFUdSbd-HdPOzUP4cg3AzgA,3955
10
+ shared_plugins/google_client/batch_retry.py,sha256=sVK-uPXgF4a-ZDWunN_k5PUJYk1FsgvJzLYX3qmKrp8,10167
11
+ shared_plugins/google_client/http_errors.py,sha256=j_8vQ4URdnKYBatrkqSq2GHrRCgyHJZufyZIpvEn3S8,806
12
+ shared_plugins/machine_token.py,sha256=g87cKwpKoxmuU8qLRds-kcAHuYtdp45wrofKwU5xFAU,18243
13
+ shared_plugins/microsoft_dataverse/__init__.py,sha256=xMLs_ICxnVkCfvpUHy-YK7d3ufk6OmUpVtweWLoudS4,840
14
+ shared_plugins/microsoft_dataverse/annotations.py,sha256=fR53FSqMB-vJ9YgtsEPYJAoN7nVhCzqEGVCQ6Mp7qyY,2830
15
+ shared_plugins/microsoft_dataverse/auth.py,sha256=PrMvAgfAT5fZt66zes6ShEzppFGSWCu5PudO0Y4wR8k,627
16
+ shared_plugins/microsoft_dataverse/binding_config.py,sha256=VgrN6Xb3-dvi1IfQpdRHSK754almITY0YU9nCQh2Izk,1302
17
+ shared_plugins/microsoft_dataverse/client.py,sha256=vpcBwiXmtCdTpIOTrhuD3qdnKla-C01JizcBd_4WIXw,17145
18
+ shared_plugins/microsoft_dataverse/ctx.py,sha256=_nZYNPaDA9njvviwgMIdEKc815dnUM_CzwFGhqSMIpM,783
19
+ shared_plugins/microsoft_dataverse/identifiers.py,sha256=OMzQI2vVgL4AvWHMybOyltpjqW2Sr7q_gHU4doJN6iE,2300
20
+ shared_plugins/microsoft_dataverse/ingress.py,sha256=5PlVPxuzbY8whs6FjtTnqCVhFGHZnkzFzSgsf3RlO2U,2276
21
+ shared_plugins/microsoft_dataverse/metadata.py,sha256=NV_JaviYd2JdOtMx1z1sN9ezMxGPWBO1XYt7COzG8s8,4510
22
+ shared_plugins/microsoft_dataverse/runtime_schema.py,sha256=T1tNTX2CqOVGIBIY9YHNuc1dAFG60fD5owEH0224VD4,11554
23
+ shared_plugins/microsoft_dataverse/source.py,sha256=3g2VHsVAvg8pX3vqWydJV41vvawEYsfU62W2SCma4rU,10728
24
+ shared_plugins/microsoft_dataverse/tables.py,sha256=j-20oGgYTUrc6De7_cni7nR9D9XfcHfa6zyOgc_FU1I,1192
25
+ shared_plugins/microsoft_dataverse/translators.py,sha256=4pEDDzESFJH_zebf-qjW3NrX9xqK5UIzexgnh0QCjgM,5329
26
+ shared_plugins/microsoft_dataverse/types.py,sha256=kI9AewGBQ1UQPyxWyfYN5WVj8gWVQQLMt8gz33pRQAE,12416
27
+ shared_plugins/microsoft_graph.py,sha256=Gu_kkqudUl0ygblZS6l98J6QDcGQI4vXnKxHeBT_ZwU,9413
28
+ shared_plugins/models.py,sha256=m4nNrPTmwR1zV2EoUvuwfhS_WjBi05x61_bShOso3j0,2669
29
+ shared_plugins/naming.py,sha256=tE7oANx8SxGjfDzZT0GqDcObzFLs_RrS_B2vw_cdPjU,2208
30
+ shared_plugins/pg_column_comments.py,sha256=HTSC8Og4jPXdBaiEhrAN__k2UOnb3E_6g38teLp2Q7s,2346
31
+ shared_plugins/provider_token.py,sha256=xhef0aBr3wx4iBxY-B438ALzcLMCYaMEWuF6lBZpACc,10482
32
+ shared_plugins/pyairbyte.py,sha256=xh1nLO__XmKBD6nhIL0lpN9o6BfBL3-ROA9ambnOFwg,17111
33
+ shared_plugins/resources.py,sha256=i-pVKSXmSBAjKh6-KfAppAcuGiPt4cerjExM6nxEqvs,5429
34
+ shared_plugins/scratch.py,sha256=oZCJZSB5m80Ez7Ox8Ktl7rKV2vFIJyp8LBkZcXp6wlA,4176
35
+ shared_plugins/sentry.py,sha256=n912Pi0Jkrw-LaTuUKOUozwA3QJq3O1wV4lnT_FGW7o,5367
36
+ shared_plugins/sqlalchemy_types.py,sha256=1e_OxfUQzY377VlV4gidtVroK6FkgdOyMvgZeATxv8E,7123
37
+ shared_plugins/sqlite.py,sha256=Pl3gXNixyQOrz19HgGojux0Ywkxukmub7rkwdkhU3QY,4301
38
+ shared_plugins/values.py,sha256=DhTk62AvmPcfUvCa_n-dXN5zTdu4UN9tJeyjzMhGTl8,3199
39
+ contextbase_shared_plugins-0.0.0a1.dist-info/WHEEL,sha256=i9aSRDivn5iP9LaR1BLQX2GNAuriQWPsFwbbWygTX2k,81
40
+ contextbase_shared_plugins-0.0.0a1.dist-info/METADATA,sha256=s2QdcjgWge0nNhx4cnA2eteug7i7L69jZ8IA7vO2Hx8,1008
41
+ contextbase_shared_plugins-0.0.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.15
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,8 @@
1
+ """Keep this module empty.
2
+
3
+ Every symbol is imported from its canonical submodule (shared_plugins.models,
4
+ shared_plugins.resources, ...). Anything imported here is paid by every
5
+ process that touches any submodule — including stdlib-only ones like
6
+ shared_plugins.sqlite — and the dlt/dagster tree costs seconds per
7
+ interpreter. Enforced by tests/test_package_imports.py.
8
+ """
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ import dagster as dg
4
+
5
+
6
+ def non_overlapping_automation_condition(
7
+ condition: dg.AutomationCondition,
8
+ ) -> dg.AutomationCondition:
9
+ # Cron and missing-based automation can keep requesting work while the
10
+ # current partition is still active unless we add this explicit guard.
11
+ return condition & ~dg.AutomationCondition.in_progress()
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timezone
4
+
5
+ import httpx
6
+ from base_client import (
7
+ BaseAPIClient,
8
+ DagsterAllPlanBinding,
9
+ DagsterBindingPlanAll,
10
+ )
11
+ from base_client.sync_state import SyncStateInputBindingsValue
12
+
13
+ # The binding-state wire-contract version, mirrored from base-schemas'
14
+ # ``BINDING_STATE_VERSION`` (TS). It is a cross-language constant, so it is
15
+ # duplicated in both trees; base-platform validates it as a literal on write.
16
+ BINDING_STATE_VERSION = 1
17
+
18
+
19
+ class BasePlatformClient:
20
+ """Thin daemon-side facade over the generated base-platform SDK
21
+ (``base_client``), bound to the single base this daemon serves.
22
+
23
+ Exposes the binding-plan reads the Dagster defs need
24
+ (``load_binding_plan`` / ``list_active_bindings`` / ``get_binding``) plus
25
+ the per-binding sync-state write the producer pushes up
26
+ (``report_binding_sync_state`` — F2), and carries the per-base ``base_id``
27
+ so callers stay base-agnostic — one Dagster code location serves one base.
28
+ Provider-token (OAuth) redemption is deliberately NOT here: it lives in
29
+ ``google_client/auth.py``, which redeems server-side via web's
30
+ ``/provider-token`` endpoint using the injected ``provider_token`` Dagster
31
+ resource (the credentials keystone).
32
+
33
+ ``auth`` is the per-request credential for the platform calls — the
34
+ ``aud=ctx`` machine Bearer in platform-managed deployments (the twin of the
35
+ TS CLIs' ``ctxAuthHeaders``), or ``None`` in local dev, where the platform
36
+ authorizes via its ``CTX_AUTH_SUB_ID`` trust-env mode and no token is
37
+ needed. ``transport`` swaps the socket for tests; ``client`` overrides the
38
+ SDK client wholesale (duck-typed facade tests), ignoring both.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ platform_url: str,
45
+ base_id: str,
46
+ auth: httpx.Auth | None = None,
47
+ transport: httpx.BaseTransport | None = None,
48
+ client: BaseAPIClient | None = None,
49
+ ) -> None:
50
+ self._base_id = base_id
51
+ self._client = (
52
+ BaseAPIClient(
53
+ base_url=platform_url,
54
+ # Mirrors the SDK's own defaults (60s timeout, follow
55
+ # redirects); built here so auth applies to every request.
56
+ httpx_client=httpx.Client(
57
+ timeout=60.0,
58
+ follow_redirects=True,
59
+ auth=auth,
60
+ transport=transport,
61
+ ),
62
+ )
63
+ if client is None
64
+ else client
65
+ )
66
+
67
+ def load_binding_plan(self) -> DagsterBindingPlanAll:
68
+ plan = self._client.binding_plan.get_binding_plan(self._base_id)
69
+ # Normalize generated_at to UTC so downstream consumers compare
70
+ # timezone-aware timestamps consistently (parity with the prior
71
+ # hand-rolled client).
72
+ return plan.model_copy(
73
+ update={"generated_at": plan.generated_at.astimezone(timezone.utc)}
74
+ )
75
+
76
+ def list_active_bindings(self, plugin_id: str) -> list[DagsterAllPlanBinding]:
77
+ plan = self.load_binding_plan()
78
+ return [binding for binding in plan.bindings if binding.plugin_id == plugin_id]
79
+
80
+ def get_binding(self, plugin_id: str, binding_id: str) -> DagsterAllPlanBinding:
81
+ for binding in self.list_active_bindings(plugin_id):
82
+ if binding.binding_id == binding_id:
83
+ return binding
84
+
85
+ raise RuntimeError(
86
+ f"Binding '{binding_id}' was not found for plugin_id '{plugin_id}'."
87
+ )
88
+
89
+ def report_binding_sync_state(
90
+ self,
91
+ binding_id: str,
92
+ state: SyncStateInputBindingsValue,
93
+ ) -> None:
94
+ """Push one binding's freshness digest to base-platform (F2).
95
+
96
+ The event-driven producer recomputes a single binding per run-status
97
+ transition, so this wraps the SDK's multi-binding ``report_sync_state``
98
+ into a one-entry map and bakes the ``BINDING_STATE_VERSION`` wire
99
+ constant — callers supply only the binding and its digest.
100
+ """
101
+ self._client.sync_state.report_sync_state(
102
+ self._base_id,
103
+ bindings={binding_id: state},
104
+ version=BINDING_STATE_VERSION,
105
+ )
@@ -0,0 +1,260 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.util
5
+ import os
6
+ import sys
7
+ from collections.abc import Callable, Collection, Iterable, Iterator
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+ from typing import Annotated, TypeVar
11
+
12
+ from pydantic import (
13
+ BaseModel,
14
+ BeforeValidator,
15
+ ConfigDict,
16
+ Field,
17
+ StringConstraints,
18
+ ValidationError,
19
+ )
20
+
21
+ from base_client import (
22
+ BindingAuth_ApiKey,
23
+ BindingAuth_AuthenticatedAccount,
24
+ BindingAuth_ClientCredentials,
25
+ BindingAuth_None,
26
+ DagsterAllPlanBinding,
27
+ )
28
+
29
+ from .exceptions import PluginConfigurationError
30
+ from .models import CtxModel
31
+
32
+ RowFilter = Callable[[str, CtxModel], bool]
33
+ RowT = TypeVar("RowT", bound=CtxModel)
34
+
35
+ _UTC_TIMESTAMP_PATTERN = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"
36
+
37
+ NonEmptyText = Annotated[
38
+ str,
39
+ StringConstraints(strip_whitespace=True, min_length=1),
40
+ ]
41
+ UtcTimestampText = Annotated[
42
+ str,
43
+ StringConstraints(
44
+ strip_whitespace=True,
45
+ min_length=1,
46
+ pattern=_UTC_TIMESTAMP_PATTERN,
47
+ ),
48
+ ]
49
+ StrictNonNegativeInt = Annotated[int, Field(strict=True, ge=0)]
50
+ ResolvedPath = Annotated[
51
+ Path, BeforeValidator(lambda value: _resolve_binding_path(value))
52
+ ]
53
+
54
+
55
+ class BaseBindingConfigModel(BaseModel):
56
+ """Canonical base class for every plugin's ``BindingConfig`` and any nested
57
+ sub-models it uses.
58
+
59
+ Declares ``extra="forbid"`` once so plugins never redeclare it and cannot
60
+ accidentally allow unknown keys. Every ``BaseModel`` that lives inside a
61
+ plugin's ``binding_config.py`` inherits from this class, at every depth of
62
+ the config tree.
63
+ """
64
+
65
+ model_config = ConfigDict(extra="forbid")
66
+
67
+
68
+ BindingConfigT = TypeVar("BindingConfigT", bound=BaseBindingConfigModel)
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ResolvedBindingModels:
73
+ active: tuple[str, ...]
74
+ filter: RowFilter | None
75
+
76
+
77
+ def require_authenticated_account(
78
+ binding: DagsterAllPlanBinding,
79
+ ) -> BindingAuth_AuthenticatedAccount:
80
+ auth_payload = binding.auth
81
+ if isinstance(auth_payload, BindingAuth_AuthenticatedAccount):
82
+ return auth_payload
83
+
84
+ raise RuntimeError(
85
+ f"{binding.plugin_id} binding requires auth.type='authenticated_account', got '{auth_payload.type}'."
86
+ )
87
+
88
+
89
+ def require_api_key(binding: DagsterAllPlanBinding) -> BindingAuth_ApiKey:
90
+ auth_payload = binding.auth
91
+ if isinstance(auth_payload, BindingAuth_ApiKey):
92
+ return auth_payload
93
+
94
+ raise RuntimeError(
95
+ f"{binding.plugin_id} binding requires auth.type='api_key', got '{auth_payload.type}'."
96
+ )
97
+
98
+
99
+ def require_client_credentials(
100
+ binding: DagsterAllPlanBinding,
101
+ ) -> BindingAuth_ClientCredentials:
102
+ auth_payload = binding.auth
103
+ if isinstance(auth_payload, BindingAuth_ClientCredentials):
104
+ return auth_payload
105
+
106
+ raise RuntimeError(
107
+ f"{binding.plugin_id} binding requires auth.type='client_credentials', got '{auth_payload.type}'."
108
+ )
109
+
110
+
111
+ def require_binding_auth_none(binding: DagsterAllPlanBinding) -> BindingAuth_None:
112
+ auth_payload = binding.auth
113
+ if isinstance(auth_payload, BindingAuth_None):
114
+ return auth_payload
115
+
116
+ raise RuntimeError(
117
+ f"{binding.plugin_id} binding requires auth.type='none', got '{auth_payload.type}'."
118
+ )
119
+
120
+
121
+ def parse_binding_config(
122
+ binding: DagsterAllPlanBinding,
123
+ config_model: type[BindingConfigT],
124
+ ) -> BindingConfigT:
125
+ try:
126
+ return config_model.model_validate(binding.config)
127
+ except ValidationError as exc:
128
+ raise PluginConfigurationError(
129
+ f"{binding.plugin_id} binding.config is invalid: {exc}"
130
+ ) from exc
131
+
132
+
133
+ def resolve_binding_models(
134
+ binding: DagsterAllPlanBinding,
135
+ *,
136
+ supported_models: Collection[str],
137
+ default_active: Collection[str] = (),
138
+ ) -> ResolvedBindingModels:
139
+ models_payload = binding.models
140
+ raw_active = (
141
+ default_active
142
+ if models_payload is None or models_payload.active is None
143
+ else models_payload.active
144
+ )
145
+ active_models = tuple(dict.fromkeys(raw_active))
146
+ unsupported_models = tuple(
147
+ model_name for model_name in active_models if model_name not in supported_models
148
+ )
149
+ if unsupported_models:
150
+ supported = ", ".join(sorted(supported_models))
151
+ unsupported = ", ".join(unsupported_models)
152
+ raise PluginConfigurationError(
153
+ f"{binding.plugin_id} models.active contains unsupported models [{unsupported}]. "
154
+ f"Supported models: [{supported}]."
155
+ )
156
+
157
+ return ResolvedBindingModels(
158
+ active=active_models,
159
+ filter=_load_row_filter(
160
+ None if models_payload is None else models_payload.filter,
161
+ plugin_id=binding.plugin_id,
162
+ ),
163
+ )
164
+
165
+
166
+ def iter_active_model_rows(
167
+ *,
168
+ model_name: str,
169
+ rows: Iterable[RowT],
170
+ binding_models: ResolvedBindingModels,
171
+ ) -> Iterator[RowT]:
172
+ if model_name not in binding_models.active:
173
+ return
174
+
175
+ for row in rows:
176
+ if _should_keep_model_row(
177
+ model_name=model_name,
178
+ row=row,
179
+ binding_models=binding_models,
180
+ ):
181
+ yield row
182
+
183
+
184
+ def _should_keep_model_row(
185
+ *,
186
+ model_name: str,
187
+ row: RowT,
188
+ binding_models: ResolvedBindingModels,
189
+ ) -> bool:
190
+ row_filter = binding_models.filter
191
+ if row_filter is None:
192
+ return True
193
+
194
+ return row_filter(model_name, row.model_copy(deep=True))
195
+
196
+
197
+ def _load_row_filter(
198
+ filter_ref: str | None,
199
+ *,
200
+ plugin_id: str,
201
+ ) -> RowFilter | None:
202
+ # DEPRECATED + dormant: `models.filter` (base-schemas binding-plan.ts) has no
203
+ # consumer — no binding sets it, so filter_ref is always None here. The machinery
204
+ # is retained, not wired for new use; `models.active` owns stream selection.
205
+ if filter_ref is None:
206
+ return None
207
+
208
+ module_path_raw, separator, attr_name = filter_ref.partition(":")
209
+ if separator != ":" or not module_path_raw or not attr_name:
210
+ raise PluginConfigurationError(
211
+ f"Invalid {plugin_id} models.filter. Expected '<path.py>:<callable>'."
212
+ )
213
+
214
+ module_path = Path(module_path_raw).expanduser()
215
+ if not module_path.is_absolute():
216
+ module_path = (Path.cwd() / module_path).resolve()
217
+ else:
218
+ module_path = module_path.resolve()
219
+
220
+ if not module_path.is_file():
221
+ raise PluginConfigurationError(
222
+ f"{plugin_id} models.filter path does not exist: {module_path}"
223
+ )
224
+
225
+ module_name = _filter_module_name(module_path)
226
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
227
+ if spec is None or spec.loader is None:
228
+ raise PluginConfigurationError(
229
+ f"Unable to load {plugin_id} models.filter module: {module_path}"
230
+ )
231
+
232
+ module = importlib.util.module_from_spec(spec)
233
+ sys.modules[module_name] = module
234
+ spec.loader.exec_module(module)
235
+
236
+ row_filter = getattr(module, attr_name, None)
237
+ if not callable(row_filter):
238
+ raise PluginConfigurationError(
239
+ f"{plugin_id} models.filter '{filter_ref}' does not resolve to a callable"
240
+ )
241
+ return row_filter
242
+
243
+
244
+ def _filter_module_name(module_path: Path) -> str:
245
+ digest = hashlib.sha1(str(module_path).encode("utf-8")).hexdigest()[:12]
246
+ return f"shared_plugins_binding_filter_{digest}"
247
+
248
+
249
+ def _resolve_binding_path(value: object) -> Path:
250
+ if isinstance(value, Path):
251
+ path = value
252
+ elif isinstance(value, str):
253
+ stripped = value.strip()
254
+ if not stripped:
255
+ raise ValueError("Path values must not be empty.")
256
+ path = Path(os.path.expandvars(stripped)).expanduser()
257
+ else:
258
+ raise TypeError(f"Expected path-like value, got {type(value).__name__}")
259
+
260
+ return path.resolve(strict=False)
shared_plugins/dlt.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterator
4
+ from typing import Any
5
+
6
+ import dlt
7
+ from dagster import AssetExecutionContext
8
+ from dagster_dlt import DagsterDltResource
9
+ from dlt.common.schema import Schema
10
+ from dlt.destinations.sql_client import SqlClientBase
11
+ from base_client import DagsterAllPlanBinding
12
+
13
+ from .base_platform import BasePlatformClient
14
+ from .naming import dlt_dataset_name, dlt_pipeline_name
15
+ from .resources import DLT_TRANSLATOR
16
+
17
+ # Side-effect import: patches PostgresClient to emit COMMENT ON COLUMN for
18
+ # dlt column descriptions. It must be active in any process that loads a dlt
19
+ # pipeline into postgres, and every such pipeline is constructed below.
20
+ from . import pg_column_comments as _pg_column_comments # noqa: F401
21
+
22
+ DLT_DESTINATION = "postgres"
23
+
24
+
25
+ def pre_register_source_schema(pipeline: Any, source: Any) -> None:
26
+ """Idempotently register an empty Schema for `source.name` on the
27
+ pipeline. This lets resources call `pipe.sql_client(schema_name=<source>)`
28
+ on cold start; without it, dlt's `_get_schema_or_create` falls through to
29
+ `Schema(pipeline_name)`, which raises `InvalidSchemaName` whenever the
30
+ pipeline_name exceeds dlt's 64-char Schema regex limit. Safe to call on
31
+ warm runs — `has_schema` short-circuits when the schema is already on
32
+ disk or restored from destination state."""
33
+ if not pipeline.schemas.has_schema(source.name):
34
+ pipeline.schemas.save_schema(Schema(source.name))
35
+
36
+
37
+ def resolve_partition_binding(
38
+ *,
39
+ context: AssetExecutionContext,
40
+ base_platform: BasePlatformClient,
41
+ plugin_id: str,
42
+ ) -> DagsterAllPlanBinding:
43
+ partition_key = context.partition_key
44
+ if partition_key is None:
45
+ raise RuntimeError("Missing partition key; expected binding_id partition.")
46
+
47
+ return base_platform.get_binding(plugin_id=plugin_id, binding_id=partition_key)
48
+
49
+
50
+ def run_dlt_pipeline(
51
+ *,
52
+ context: AssetExecutionContext,
53
+ dlt_resource: DagsterDltResource,
54
+ source: Any,
55
+ plugin_id: str,
56
+ binding_id: str,
57
+ job_name: str,
58
+ ) -> Iterator[Any]:
59
+ pipeline = dlt.pipeline(
60
+ pipeline_name=dlt_pipeline_name(plugin_id, binding_id, job_name),
61
+ destination=DLT_DESTINATION,
62
+ dataset_name=dlt_dataset_name(plugin_id),
63
+ progress="log",
64
+ )
65
+ pre_register_source_schema(pipeline, source)
66
+ context.log.info(f"Starting {plugin_id} job={job_name} for binding_id={binding_id}")
67
+ yield from dlt_resource.run(
68
+ context=context,
69
+ dlt_source=source,
70
+ dlt_pipeline=pipeline,
71
+ dagster_dlt_translator=DLT_TRANSLATOR,
72
+ )
73
+ context.log.info(
74
+ f"Completed {plugin_id} job={job_name} for binding_id={binding_id}"
75
+ )
76
+
77
+
78
+ def destination_has_table(
79
+ sql_client: SqlClientBase[Any],
80
+ table_name: str,
81
+ ) -> bool:
82
+ """Check whether a table exists in the dlt destination dataset."""
83
+ query = (
84
+ "SELECT 1 FROM information_schema.tables"
85
+ " WHERE table_schema = current_schema() AND table_name = %s"
86
+ " LIMIT 1"
87
+ )
88
+ with sql_client.execute_query(query, table_name) as cursor:
89
+ return cursor.fetchone() is not None
shared_plugins/env.py ADDED
@@ -0,0 +1,104 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic import Field, field_validator
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+ from sqlalchemy.engine import make_url
8
+
9
+ CTXB_DATABASE_URL_ENV_VAR = "CTXB_DATABASE_URL"
10
+ CTX_PLATFORM_URL_ENV_VAR = "CTX_PLATFORM_URL"
11
+ CTXB_BASE_ID_ENV_VAR = "CTXB_BASE_ID"
12
+ CTXB_SCRATCH_DIR_ENV_VAR = "CTXB_SCRATCH_DIR"
13
+
14
+
15
+ def normalize_required_string(value: object, *, env_var_name: str) -> str:
16
+ """Coerce a required env-var value to a non-blank, stripped string (raising a
17
+ typed/loud error tagged with the var name). Shared across the env-settings
18
+ validators here and ProviderTokenSettings."""
19
+ if not isinstance(value, str):
20
+ raise TypeError(f"{env_var_name} must be a string.")
21
+
22
+ normalized = value.strip()
23
+ if not normalized:
24
+ raise ValueError(f"{env_var_name} cannot be blank.")
25
+ return normalized
26
+
27
+
28
+ def normalize_optional_absolute_path(
29
+ value: object,
30
+ *,
31
+ env_var_name: str,
32
+ ) -> Path | None:
33
+ if value is None:
34
+ return None
35
+
36
+ normalized = normalize_required_string(value, env_var_name=env_var_name)
37
+ candidate = Path(normalized).expanduser()
38
+ if not candidate.is_absolute():
39
+ raise ValueError(f"{env_var_name} must be an absolute path.")
40
+ return candidate.resolve(strict=False)
41
+
42
+
43
+ class SharedPythonSettings(BaseSettings):
44
+ model_config = SettingsConfigDict(extra="ignore")
45
+
46
+ ctx_database_url: str = Field(alias=CTXB_DATABASE_URL_ENV_VAR)
47
+ ctx_platform_url: str | None = Field(default=None, alias=CTX_PLATFORM_URL_ENV_VAR)
48
+ ctx_base_id: str | None = Field(default=None, alias=CTXB_BASE_ID_ENV_VAR)
49
+ ctx_scratch_dir: Path | None = Field(default=None, alias=CTXB_SCRATCH_DIR_ENV_VAR)
50
+
51
+ @field_validator("ctx_database_url", mode="before")
52
+ @classmethod
53
+ def _validate_database_url(cls, value: object) -> str:
54
+ normalized = normalize_required_string(
55
+ value,
56
+ env_var_name=CTXB_DATABASE_URL_ENV_VAR,
57
+ )
58
+ parsed_url = make_url(normalized)
59
+ if not parsed_url.drivername.startswith("postgres"):
60
+ raise ValueError(
61
+ f"{CTXB_DATABASE_URL_ENV_VAR} must use a postgres driver, got '{parsed_url.drivername}'."
62
+ )
63
+ if not parsed_url.database:
64
+ raise ValueError(
65
+ f"{CTXB_DATABASE_URL_ENV_VAR} must include a database name."
66
+ )
67
+ return normalized
68
+
69
+ @field_validator("ctx_platform_url", mode="before")
70
+ @classmethod
71
+ def _validate_platform_url(cls, value: object) -> str | None:
72
+ if value is None:
73
+ return None
74
+ normalized = normalize_required_string(
75
+ value,
76
+ env_var_name=CTX_PLATFORM_URL_ENV_VAR,
77
+ )
78
+ if not normalized.startswith(("http://", "https://")):
79
+ raise ValueError(
80
+ f"{CTX_PLATFORM_URL_ENV_VAR} must use http:// or https://."
81
+ )
82
+ return normalized.rstrip("/")
83
+
84
+ @field_validator("ctx_base_id", mode="before")
85
+ @classmethod
86
+ def _validate_base_id(cls, value: object) -> str | None:
87
+ if value is None:
88
+ return None
89
+ return normalize_required_string(
90
+ value,
91
+ env_var_name=CTXB_BASE_ID_ENV_VAR,
92
+ )
93
+
94
+ @field_validator("ctx_scratch_dir", mode="before")
95
+ @classmethod
96
+ def _validate_scratch_dir(cls, value: object) -> Path | None:
97
+ return normalize_optional_absolute_path(
98
+ value,
99
+ env_var_name=CTXB_SCRATCH_DIR_ENV_VAR,
100
+ )
101
+
102
+
103
+ def load_shared_python_settings() -> SharedPythonSettings:
104
+ return SharedPythonSettings()
@@ -0,0 +1,10 @@
1
+ class PluginError(Exception):
2
+ """Base exception for plugin failures."""
3
+
4
+
5
+ class PluginConfigurationError(PluginError):
6
+ """Raised when plugin configuration is invalid or missing."""
7
+
8
+
9
+ class PluginCursorExpiredError(PluginError):
10
+ """Raised when an incremental cursor is no longer valid and must be reset."""
@@ -0,0 +1 @@
1
+ """Shared Google API client utilities used across plugins."""