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
|
@@ -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,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."""
|