dagster-postgres 0.29.2__tar.gz → 0.29.3__tar.gz

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 (56) hide show
  1. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/.gitignore +1 -0
  2. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/PKG-INFO +12 -2
  3. dagster_postgres-0.29.3/dagster_postgres/auth.py +195 -0
  4. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/event_log/event_log.py +17 -6
  5. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/run_storage/run_storage.py +14 -5
  6. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/schedule_storage/schedule_storage.py +17 -6
  7. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/storage.py +20 -7
  8. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/utils.py +51 -6
  9. dagster_postgres-0.29.3/dagster_postgres/version.py +1 -0
  10. dagster_postgres-0.29.3/dagster_postgres_tests/test_auth.py +229 -0
  11. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/pyproject.toml +16 -2
  12. dagster_postgres-0.29.2/dagster_postgres/version.py +0 -1
  13. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/.coveragerc +0 -0
  14. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/LICENSE +0 -0
  15. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/README.md +0 -0
  16. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/conftest.py +0 -0
  17. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/__init__.py +0 -0
  18. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/alembic/alembic.ini +0 -0
  19. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/event_log/__init__.py +0 -0
  20. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/py.typed +0 -0
  21. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/run_storage/__init__.py +0 -0
  22. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/schedule_storage/__init__.py +0 -0
  23. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/test_fixtures/__init__.py +0 -0
  24. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres/test_fixtures/docker-compose.yml +0 -0
  25. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/__init__.py +0 -0
  26. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/__init__.py +0 -0
  27. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/dagster.yaml +0 -0
  28. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_10_0_wipe_schedules/postgres/pg_dump.txt +0 -0
  29. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_10_6_add_bulk_actions_table/postgres/pg_dump.txt +0 -0
  30. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_11_0_pre_asset_details/postgres/pg_dump.txt +0 -0
  31. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_11_16_pre_add_mode_column/postgres/pg_dump.txt +0 -0
  32. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_12_0_pre_asset_index_cols/postgres/pg_dump.txt +0 -0
  33. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_13_12_pre_start_time_end_time/postgres/pg_dump.txt +0 -0
  34. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_instigators_table/postgres/pg_dump.txt +0 -0
  35. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_post_schema_pre_data_migration/postgres/pg_dump.txt +0 -0
  36. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_14_6_schedule_migration_table/postgres/pg_dump.txt +0 -0
  37. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_6_6/postgres/pg_dump.txt +0 -0
  38. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_7_6_pre_add_pipeline_snapshot/postgres/pg_dump.txt +0 -0
  39. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_7_6_pre_event_log_migration/postgres/pg_dump.txt +0 -0
  40. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_8_0_scheduler_update/postgres/pg_dump.txt +0 -0
  41. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_9_22_pre_asset_partition/postgres/pg_dump.txt +0 -0
  42. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_0_9_22_pre_run_partition/postgres/pg_dump.txt +0 -0
  43. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_1_0_12_pre_add_asset_event_tags_table/postgres/pg_dump.txt +0 -0
  44. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_1_0_17_pre_add_cached_status_data_column/postgres/pg_dump.txt +0 -0
  45. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_1_1_22_pre_primary_key/postgres/pg_dump.txt +0 -0
  46. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_1_8_12_pre_add_backfill_id_column_to_runs_table/postgres/pg_dump.txt +0 -0
  47. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/snapshot_1_9_3_add_run_tags_run_id_idx/postgres/pg_dump.txt +0 -0
  48. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/compat_tests/test_back_compat.py +0 -0
  49. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/conftest.py +0 -0
  50. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_daemon_cursor_storage.py +0 -0
  51. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_event_log.py +0 -0
  52. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_instance.py +0 -0
  53. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_run_storage.py +0 -0
  54. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_schedule_storage.py +0 -0
  55. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/dagster_postgres_tests/test_version.py +0 -0
  56. {dagster_postgres-0.29.2 → dagster_postgres-0.29.3}/tox.ini +0 -0
@@ -27,6 +27,7 @@ wheels/
27
27
  *.egg
28
28
 
29
29
  !docs/docs/guides/build
30
+ !examples/docs_snippets/docs_snippets/guides/build
30
31
 
31
32
  # PyInstaller
32
33
  # Usually these files are written by a python script from a template
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dagster-postgres
3
- Version: 0.29.2
3
+ Version: 0.29.3
4
4
  Summary: A Dagster integration for postgres
5
5
  Project-URL: Homepage, https://github.com/dagster-io/dagster/tree/master/python_modules/libraries/dagster-postgres
6
6
  Author-email: Dagster Labs <hello@dagsterlabs.com>
@@ -13,6 +13,16 @@ Classifier: Programming Language :: Python :: 3.12
13
13
  Classifier: Programming Language :: Python :: 3.13
14
14
  Classifier: Programming Language :: Python :: 3.14
15
15
  Requires-Python: <3.15,>=3.10
16
- Requires-Dist: dagster==1.13.2
16
+ Requires-Dist: dagster==1.13.3
17
17
  Requires-Dist: psycopg2-binary
18
+ Provides-Extra: aws
19
+ Requires-Dist: boto3>=1.26.0; extra == 'aws'
20
+ Provides-Extra: azure
21
+ Requires-Dist: azure-identity>=1.12.0; extra == 'azure'
22
+ Provides-Extra: gcp
23
+ Requires-Dist: google-auth>=2.0.0; extra == 'gcp'
18
24
  Provides-Extra: test
25
+ Provides-Extra: wif
26
+ Requires-Dist: azure-identity>=1.12.0; extra == 'wif'
27
+ Requires-Dist: boto3>=1.26.0; extra == 'wif'
28
+ Requires-Dist: google-auth>=2.0.0; extra == 'wif'
@@ -0,0 +1,195 @@
1
+ import abc
2
+ import logging
3
+ import threading
4
+ import time
5
+ from typing import Any
6
+
7
+ import sqlalchemy
8
+ from sqlalchemy import event
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class PgTokenProvider(abc.ABC):
14
+ """Abstract base for providers that supply short-lived database access tokens.
15
+
16
+ Subclasses implement ``_fetch_token`` to obtain a fresh token from their
17
+ respective cloud provider. Caching and thread-safety are handled by the
18
+ base class so that callers can simply invoke ``get_token()``.
19
+ """
20
+
21
+ def __init__(self) -> None:
22
+ self._cached_token: str | None = None
23
+ self._token_expiry: float = 0.0 # epoch seconds
24
+ self._lock = threading.Lock()
25
+
26
+ @abc.abstractmethod
27
+ def _fetch_token(self) -> tuple[str, float]:
28
+ """Return ``(token, expiry_epoch_seconds)``.
29
+
30
+ If the exact expiry is unknown, implementations should return
31
+ ``time.time() + 3600`` as a conservative default (1 hour).
32
+ """
33
+ ...
34
+
35
+ def get_token(self) -> str:
36
+ """Return a valid token, refreshing if expired or about to expire."""
37
+ # Refresh 5 minutes before expiry to avoid races
38
+ with self._lock:
39
+ if self._cached_token is None or time.time() >= (self._token_expiry - 300):
40
+ logger.debug("Refreshing PostgreSQL auth token")
41
+ self._cached_token, self._token_expiry = self._fetch_token()
42
+ return self._cached_token
43
+
44
+
45
+ class AzureWifTokenProvider(PgTokenProvider):
46
+ """Azure Workload Identity Federation token provider.
47
+
48
+ Uses ``DefaultAzureCredential`` which automatically picks up the
49
+ ``AZURE_CLIENT_ID``, ``AZURE_TENANT_ID``, and
50
+ ``AZURE_FEDERATED_TOKEN_FILE`` environment variables set by the Azure
51
+ Workload Identity webhook on AKS pods.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ scope: str = "https://ossrdbms-aad.database.windows.net/.default",
57
+ ) -> None:
58
+ super().__init__()
59
+ self._scope = scope
60
+ self._credential: Any = None
61
+
62
+ def _fetch_token(self) -> tuple[str, float]:
63
+ if self._credential is None:
64
+ try:
65
+ from azure.identity import DefaultAzureCredential # type: ignore[import-untyped]
66
+ except ImportError:
67
+ raise ImportError(
68
+ "azure-identity is required for Azure WIF auth. "
69
+ "Install with: pip install dagster-postgres[azure]"
70
+ )
71
+ self._credential = DefaultAzureCredential()
72
+ token = self._credential.get_token(self._scope)
73
+ return token.token, token.expires_on
74
+
75
+
76
+ class GcpWifTokenProvider(PgTokenProvider):
77
+ """GCP Workload Identity Federation token provider.
78
+
79
+ Uses Application Default Credentials (ADC), which automatically picks up
80
+ the projected service account token on GKE pods.
81
+ """
82
+
83
+ def __init__(self) -> None:
84
+ super().__init__()
85
+ self._credentials: Any = None
86
+ self._request: Any = None
87
+
88
+ def _fetch_token(self) -> tuple[str, float]:
89
+ if self._credentials is None:
90
+ try:
91
+ import google.auth # type: ignore[import-untyped]
92
+ import google.auth.transport.requests # type: ignore[import-untyped]
93
+ except ImportError:
94
+ raise ImportError(
95
+ "google-auth is required for GCP WIF auth. "
96
+ "Install with: pip install dagster-postgres[gcp]"
97
+ )
98
+ self._credentials, _ = google.auth.default(
99
+ scopes=["https://www.googleapis.com/auth/cloud-platform"],
100
+ )
101
+ self._request = google.auth.transport.requests.Request()
102
+ self._credentials.refresh(self._request)
103
+ expiry = (
104
+ self._credentials.expiry.timestamp()
105
+ if self._credentials.expiry
106
+ else (time.time() + 3600)
107
+ )
108
+ return self._credentials.token, expiry
109
+
110
+
111
+ class AwsWifTokenProvider(PgTokenProvider):
112
+ """AWS IAM / IRSA token provider for RDS.
113
+
114
+ Uses ``boto3`` to generate an RDS auth token. Requires the hostname,
115
+ port, username, and AWS region from the connection config.
116
+ """
117
+
118
+ def __init__(
119
+ self,
120
+ hostname: str,
121
+ port: int,
122
+ username: str,
123
+ region: str,
124
+ ) -> None:
125
+ super().__init__()
126
+ self._hostname = hostname
127
+ self._port = port
128
+ self._username = username
129
+ self._region = region
130
+ self._client: Any = None
131
+
132
+ def _fetch_token(self) -> tuple[str, float]:
133
+ if self._client is None:
134
+ try:
135
+ import boto3 # type: ignore[import-untyped]
136
+ except ImportError:
137
+ raise ImportError(
138
+ "boto3 is required for AWS WIF auth. "
139
+ "Install with: pip install dagster-postgres[aws]"
140
+ )
141
+ self._client = boto3.client("rds", region_name=self._region)
142
+ token = self._client.generate_db_auth_token(
143
+ DBHostname=self._hostname,
144
+ Port=self._port,
145
+ DBUsername=self._username,
146
+ )
147
+ # RDS auth tokens are valid for 15 minutes
148
+ return token, time.time() + 900
149
+
150
+
151
+ def create_token_provider(
152
+ auth_config: dict[str, Any],
153
+ db_config: dict[str, Any] | None = None,
154
+ ) -> PgTokenProvider:
155
+ """Create a token provider from the ``auth_provider`` config block."""
156
+ if "azure_wif" in auth_config:
157
+ azure_cfg = auth_config["azure_wif"]
158
+ return AzureWifTokenProvider(
159
+ scope=azure_cfg.get("scope", "https://ossrdbms-aad.database.windows.net/.default"),
160
+ )
161
+ elif "gcp_wif" in auth_config:
162
+ return GcpWifTokenProvider()
163
+ elif "aws_wif" in auth_config:
164
+ aws_cfg = auth_config["aws_wif"]
165
+ if db_config is None:
166
+ raise ValueError(
167
+ "AWS WIF requires postgres_db config (not postgres_url) to"
168
+ " extract hostname/port/username"
169
+ )
170
+ return AwsWifTokenProvider(
171
+ hostname=db_config["hostname"],
172
+ port=db_config.get("port", 5432),
173
+ username=db_config["username"],
174
+ region=aws_cfg["region"],
175
+ )
176
+ else:
177
+ raise ValueError(f"Unknown auth_provider config: {auth_config}")
178
+
179
+
180
+ def register_do_connect_hook(
181
+ engine: sqlalchemy.engine.Engine,
182
+ token_provider: PgTokenProvider,
183
+ ) -> None:
184
+ """Register a ``do_connect`` event listener that injects a fresh token as
185
+ the password before each DBAPI connection.
186
+ """
187
+
188
+ @event.listens_for(engine, "do_connect")
189
+ def _inject_token(
190
+ dialect: Any,
191
+ conn_rec: Any,
192
+ cargs: list[Any],
193
+ cparams: dict[str, Any],
194
+ ) -> None:
195
+ cparams["password"] = token_provider.get_token()
@@ -1,6 +1,6 @@
1
1
  from collections.abc import Iterator, Mapping, Sequence
2
2
  from contextlib import contextmanager
3
- from typing import Any, ContextManager, cast # noqa: UP035
3
+ from typing import TYPE_CHECKING, Any, ContextManager, cast # noqa: UP035
4
4
 
5
5
  import dagster._check as check
6
6
  import sqlalchemy as db
@@ -36,6 +36,8 @@ from sqlalchemy.engine import Connection
36
36
 
37
37
  from dagster_postgres.utils import (
38
38
  create_pg_connection,
39
+ create_pg_engine,
40
+ get_token_provider_from_config,
39
41
  pg_alembic_config,
40
42
  pg_url_from_config,
41
43
  retry_pg_connection_fn,
@@ -43,6 +45,9 @@ from dagster_postgres.utils import (
43
45
  set_pg_statement_timeout,
44
46
  )
45
47
 
48
+ if TYPE_CHECKING:
49
+ from dagster_postgres.auth import PgTokenProvider
50
+
46
51
  CHANNEL_NAME = "run_events"
47
52
 
48
53
 
@@ -56,7 +61,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
56
61
  To use Postgres for all of the components of your instance storage, you can add the following
57
62
  block to your ``dagster.yaml``:
58
63
 
59
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg.yaml
64
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
60
65
  :caption: dagster.yaml
61
66
  :lines: 1-8
62
67
  :language: YAML
@@ -65,7 +70,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
65
70
  configuring your event log storage to use Postgres, you can add a block such as the following
66
71
  to your ``dagster.yaml``:
67
72
 
68
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg-legacy.yaml
73
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
69
74
  :caption: dagster.yaml
70
75
  :lines: 12-21
71
76
  :language: YAML
@@ -80,16 +85,21 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
80
85
  postgres_url: str,
81
86
  should_autocreate_tables: bool = True,
82
87
  inst_data: ConfigurableClassData | None = None,
88
+ token_provider: "PgTokenProvider | None" = None,
83
89
  ):
84
90
  self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
85
91
  self.postgres_url = check.str_param(postgres_url, "postgres_url")
86
92
  self.should_autocreate_tables = check.bool_param(
87
93
  should_autocreate_tables, "should_autocreate_tables"
88
94
  )
95
+ self._token_provider = token_provider
89
96
 
90
97
  # Default to not holding any connections open to prevent accumulating connections per DagsterInstance
91
- self._engine = create_engine(
92
- self.postgres_url, isolation_level="AUTOCOMMIT", poolclass=db_pool.NullPool
98
+ self._engine = create_pg_engine(
99
+ self.postgres_url,
100
+ self._token_provider,
101
+ isolation_level="AUTOCOMMIT",
102
+ poolclass=db_pool.NullPool,
93
103
  )
94
104
  self._event_watcher: SqlPollingEventWatcher | None = None
95
105
 
@@ -125,7 +135,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
125
135
  existing_options = self._engine.url.query.get("options")
126
136
  if existing_options:
127
137
  kwargs["connect_args"] = {"options": existing_options}
128
- self._engine = create_engine(self.postgres_url, **kwargs)
138
+ self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
129
139
  event.listen(
130
140
  self._engine,
131
141
  "connect",
@@ -153,6 +163,7 @@ class PostgresEventLogStorage(SqlEventLogStorage, ConfigurableClass):
153
163
  inst_data=inst_data,
154
164
  postgres_url=pg_url_from_config(config_value),
155
165
  should_autocreate_tables=config_value.get("should_autocreate_tables", True),
166
+ token_provider=get_token_provider_from_config(config_value),
156
167
  )
157
168
 
158
169
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  import zlib
2
2
  from collections.abc import Mapping
3
- from typing import ContextManager # noqa: UP035
3
+ from typing import TYPE_CHECKING, ContextManager # noqa: UP035
4
4
 
5
5
  import dagster._check as check
6
6
  import sqlalchemy as db
@@ -31,6 +31,8 @@ from sqlalchemy.engine import Connection
31
31
 
32
32
  from dagster_postgres.utils import (
33
33
  create_pg_connection,
34
+ create_pg_engine,
35
+ get_token_provider_from_config,
34
36
  pg_alembic_config,
35
37
  pg_url_from_config,
36
38
  retry_pg_connection_fn,
@@ -38,6 +40,9 @@ from dagster_postgres.utils import (
38
40
  set_pg_statement_timeout,
39
41
  )
40
42
 
43
+ if TYPE_CHECKING:
44
+ from dagster_postgres.auth import PgTokenProvider
45
+
41
46
 
42
47
  class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
43
48
  """Postgres-backed run storage.
@@ -49,7 +54,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
49
54
  To use Postgres for all of the components of your instance storage, you can add the following
50
55
  block to your ``dagster.yaml``:
51
56
 
52
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg.yaml
57
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
53
58
  :caption: dagster.yaml
54
59
  :lines: 1-8
55
60
  :language: YAML
@@ -58,7 +63,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
58
63
  configuring your run storage to use Postgres, you can add a block such as the following
59
64
  to your ``dagster.yaml``:
60
65
 
61
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg-legacy.yaml
66
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
62
67
  :caption: dagster.yaml
63
68
  :lines: 1-10
64
69
  :language: YAML
@@ -72,16 +77,19 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
72
77
  postgres_url: str,
73
78
  should_autocreate_tables: bool = True,
74
79
  inst_data: ConfigurableClassData | None = None,
80
+ token_provider: "PgTokenProvider | None" = None,
75
81
  ):
76
82
  self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
77
83
  self.postgres_url = postgres_url
78
84
  self.should_autocreate_tables = check.bool_param(
79
85
  should_autocreate_tables, "should_autocreate_tables"
80
86
  )
87
+ self._token_provider = token_provider
81
88
 
82
89
  # Default to not holding any connections open to prevent accumulating connections per DagsterInstance
83
- self._engine = create_engine(
90
+ self._engine = create_pg_engine(
84
91
  self.postgres_url,
92
+ self._token_provider,
85
93
  isolation_level="AUTOCOMMIT",
86
94
  poolclass=db_pool.NullPool,
87
95
  )
@@ -121,7 +129,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
121
129
  existing_options = self._engine.url.query.get("options")
122
130
  if existing_options:
123
131
  kwargs["connect_args"] = {"options": existing_options}
124
- self._engine = create_engine(self.postgres_url, **kwargs)
132
+ self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
125
133
  event.listen(
126
134
  self._engine,
127
135
  "connect",
@@ -144,6 +152,7 @@ class PostgresRunStorage(SqlRunStorage, ConfigurableClass):
144
152
  inst_data=inst_data,
145
153
  postgres_url=pg_url_from_config(config_value),
146
154
  should_autocreate_tables=config_value.get("should_autocreate_tables", True),
155
+ token_provider=get_token_provider_from_config(config_value),
147
156
  )
148
157
 
149
158
  @staticmethod
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Sequence
2
- from typing import ContextManager # noqa: UP035
2
+ from typing import TYPE_CHECKING, ContextManager # noqa: UP035
3
3
 
4
4
  import dagster._check as check
5
5
  import sqlalchemy as db
@@ -31,6 +31,8 @@ from sqlalchemy.engine import Connection
31
31
 
32
32
  from dagster_postgres.utils import (
33
33
  create_pg_connection,
34
+ create_pg_engine,
35
+ get_token_provider_from_config,
34
36
  pg_alembic_config,
35
37
  pg_url_from_config,
36
38
  retry_pg_connection_fn,
@@ -38,6 +40,9 @@ from dagster_postgres.utils import (
38
40
  set_pg_statement_timeout,
39
41
  )
40
42
 
43
+ if TYPE_CHECKING:
44
+ from dagster_postgres.auth import PgTokenProvider
45
+
41
46
 
42
47
  class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
43
48
  """Postgres-backed run storage.
@@ -49,7 +54,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
49
54
  To use Postgres for all of the components of your instance storage, you can add the following
50
55
  block to your ``dagster.yaml``:
51
56
 
52
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg.yaml
57
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
53
58
  :caption: dagster.yaml
54
59
  :lines: 1-8
55
60
  :language: YAML
@@ -58,7 +63,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
58
63
  configuring your schedule storage to use Postgres, you can add a block such as the following
59
64
  to your ``dagster.yaml``:
60
65
 
61
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg-legacy.yaml
66
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg-legacy.yaml
62
67
  :caption: dagster.yaml
63
68
  :lines: 23-32
64
69
  :language: YAML
@@ -72,16 +77,21 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
72
77
  postgres_url: str,
73
78
  should_autocreate_tables: bool = True,
74
79
  inst_data: ConfigurableClassData | None = None,
80
+ token_provider: "PgTokenProvider | None" = None,
75
81
  ):
76
82
  self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
77
83
  self.postgres_url = postgres_url
78
84
  self.should_autocreate_tables = check.bool_param(
79
85
  should_autocreate_tables, "should_autocreate_tables"
80
86
  )
87
+ self._token_provider = token_provider
81
88
 
82
89
  # Default to not holding any connections open to prevent accumulating connections per DagsterInstance
83
- self._engine = create_engine(
84
- self.postgres_url, isolation_level="AUTOCOMMIT", poolclass=db_pool.NullPool
90
+ self._engine = create_pg_engine(
91
+ self.postgres_url,
92
+ self._token_provider,
93
+ isolation_level="AUTOCOMMIT",
94
+ poolclass=db_pool.NullPool,
85
95
  )
86
96
 
87
97
  # Stamp and create tables if the main table does not exist (we can't check alembic
@@ -117,7 +127,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
117
127
  existing_options = self._engine.url.query.get("options")
118
128
  if existing_options:
119
129
  kwargs["connect_args"] = {"options": existing_options}
120
- self._engine = create_engine(self.postgres_url, **kwargs)
130
+ self._engine = create_pg_engine(self.postgres_url, self._token_provider, **kwargs)
121
131
  event.listen(
122
132
  self._engine,
123
133
  "connect",
@@ -140,6 +150,7 @@ class PostgresScheduleStorage(SqlScheduleStorage, ConfigurableClass):
140
150
  inst_data=inst_data,
141
151
  postgres_url=pg_url_from_config(config_value),
142
152
  should_autocreate_tables=config_value.get("should_autocreate_tables", True),
153
+ token_provider=get_token_provider_from_config(config_value),
143
154
  )
144
155
 
145
156
  @staticmethod
@@ -1,3 +1,5 @@
1
+ from typing import TYPE_CHECKING
2
+
1
3
  from dagster import _check as check
2
4
  from dagster._config.config_schema import UserConfigSchema
3
5
  from dagster._core.storage.base_storage import DagsterStorage
@@ -10,7 +12,10 @@ from dagster._serdes import ConfigurableClass, ConfigurableClassData
10
12
  from dagster_postgres.event_log import PostgresEventLogStorage
11
13
  from dagster_postgres.run_storage import PostgresRunStorage
12
14
  from dagster_postgres.schedule_storage import PostgresScheduleStorage
13
- from dagster_postgres.utils import pg_url_from_config
15
+ from dagster_postgres.utils import get_token_provider_from_config, pg_url_from_config
16
+
17
+ if TYPE_CHECKING:
18
+ from dagster_postgres.auth import PgTokenProvider
14
19
 
15
20
 
16
21
  class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
@@ -23,7 +28,7 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
23
28
  To use Postgres for storage, you can add a block such as the following to your
24
29
  ``dagster.yaml``:
25
30
 
26
- .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deploying/dagster-pg.yaml
31
+ .. literalinclude:: ../../../../../../examples/docs_snippets/docs_snippets/deployment/oss/dagster-pg.yaml
27
32
  :caption: dagster.yaml
28
33
  :lines: 1-8
29
34
  :language: YAML
@@ -34,18 +39,25 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
34
39
 
35
40
  def __init__(
36
41
  self,
37
- postgres_url,
38
- should_autocreate_tables=True,
42
+ postgres_url: str,
43
+ should_autocreate_tables: bool = True,
39
44
  inst_data: ConfigurableClassData | None = None,
45
+ token_provider: "PgTokenProvider | None" = None,
40
46
  ):
41
47
  self.postgres_url = postgres_url
42
48
  self.should_autocreate_tables = check.bool_param(
43
49
  should_autocreate_tables, "should_autocreate_tables"
44
50
  )
45
51
  self._inst_data = check.opt_inst_param(inst_data, "inst_data", ConfigurableClassData)
46
- self._run_storage = PostgresRunStorage(postgres_url, should_autocreate_tables)
47
- self._event_log_storage = PostgresEventLogStorage(postgres_url, should_autocreate_tables)
48
- self._schedule_storage = PostgresScheduleStorage(postgres_url, should_autocreate_tables)
52
+ self._run_storage = PostgresRunStorage(
53
+ postgres_url, should_autocreate_tables, token_provider=token_provider
54
+ )
55
+ self._event_log_storage = PostgresEventLogStorage(
56
+ postgres_url, should_autocreate_tables, token_provider=token_provider
57
+ )
58
+ self._schedule_storage = PostgresScheduleStorage(
59
+ postgres_url, should_autocreate_tables, token_provider=token_provider
60
+ )
49
61
  super().__init__()
50
62
 
51
63
  @property
@@ -64,6 +76,7 @@ class DagsterPostgresStorage(DagsterStorage, ConfigurableClass):
64
76
  inst_data=inst_data,
65
77
  postgres_url=pg_url_from_config(config_value),
66
78
  should_autocreate_tables=config_value.get("should_autocreate_tables", True),
79
+ token_provider=get_token_provider_from_config(config_value),
67
80
  )
68
81
 
69
82
  @property
@@ -2,7 +2,7 @@ import logging
2
2
  import time
3
3
  from collections.abc import Callable, Iterator, Mapping
4
4
  from contextlib import contextmanager
5
- from typing import Any, TypeVar
5
+ from typing import TYPE_CHECKING, Any, TypeVar
6
6
  from urllib.parse import quote, urlencode
7
7
 
8
8
  import alembic.config
@@ -13,9 +13,12 @@ from dagster._core.definitions.policy import Backoff, Jitter, calculate_delay
13
13
 
14
14
  # re-export
15
15
  from dagster._core.storage.config import pg_config as pg_config
16
- from dagster._core.storage.sql import get_alembic_config
16
+ from dagster._core.storage.sql import create_engine, get_alembic_config
17
17
  from sqlalchemy.engine import Connection
18
18
 
19
+ if TYPE_CHECKING:
20
+ from dagster_postgres.auth import PgTokenProvider
21
+
19
22
  T = TypeVar("T")
20
23
 
21
24
 
@@ -41,14 +44,18 @@ def pg_url_from_config(config_value: Mapping[str, Any]) -> str:
41
44
 
42
45
  def get_conn_string(
43
46
  username: str,
44
- password: str,
45
- hostname: str,
46
- db_name: str,
47
+ password: str = "",
48
+ hostname: str = "",
49
+ db_name: str = "",
47
50
  port: str = "5432",
48
51
  params: Mapping[str, object] | None = None,
49
52
  scheme: str = "postgresql",
50
53
  ) -> str:
51
- uri = f"{scheme}://{quote(username)}:{quote(password)}@{hostname}:{port}/{db_name}"
54
+ if password:
55
+ userinfo = f"{quote(username)}:{quote(password)}"
56
+ else:
57
+ userinfo = quote(username)
58
+ uri = f"{scheme}://{userinfo}@{hostname}:{port}/{db_name}"
52
59
 
53
60
  if params:
54
61
  query_string = f"{urlencode(params, quote_via=quote)}"
@@ -157,3 +164,41 @@ def set_pg_statement_timeout(conn: Any, millis: int):
157
164
  with conn:
158
165
  with conn.cursor() as curs:
159
166
  curs.execute(f"SET statement_timeout = {millis};")
167
+
168
+
169
+ def create_pg_engine(
170
+ postgres_url: str,
171
+ token_provider: "PgTokenProvider | None" = None,
172
+ **engine_kwargs: Any,
173
+ ) -> sqlalchemy.engine.Engine:
174
+ """Create a SQLAlchemy engine, optionally with WIF token injection via
175
+ the ``do_connect`` event hook.
176
+ """
177
+ engine = create_engine(postgres_url, **engine_kwargs)
178
+ if token_provider is not None:
179
+ from dagster_postgres.auth import register_do_connect_hook
180
+
181
+ register_do_connect_hook(engine, token_provider)
182
+ return engine
183
+
184
+
185
+ def get_token_provider_from_config(
186
+ config_value: Mapping[str, Any],
187
+ ) -> "PgTokenProvider | None":
188
+ """Extract and build a token provider from config, or return ``None`` for
189
+ password auth.
190
+ """
191
+ auth_config = config_value.get("auth_provider")
192
+ if auth_config is None:
193
+ return None
194
+
195
+ check.invariant(
196
+ "postgres_url" not in config_value or not config_value["postgres_url"],
197
+ "auth_provider cannot be used with postgres_url. Use postgres_db instead"
198
+ " so that tokens can be injected at the DBAPI connection level.",
199
+ )
200
+
201
+ from dagster_postgres.auth import create_token_provider
202
+
203
+ db_config = config_value.get("postgres_db")
204
+ return create_token_provider(auth_config, db_config)
@@ -0,0 +1 @@
1
+ __version__ = "0.29.3"
@@ -0,0 +1,229 @@
1
+ import threading
2
+ import time
3
+ from unittest.mock import patch
4
+
5
+ import pytest
6
+ import sqlalchemy
7
+ import sqlalchemy.exc
8
+ import sqlalchemy.pool
9
+ from dagster_postgres.auth import (
10
+ AwsWifTokenProvider,
11
+ AzureWifTokenProvider,
12
+ GcpWifTokenProvider,
13
+ PgTokenProvider,
14
+ create_token_provider,
15
+ register_do_connect_hook,
16
+ )
17
+ from dagster_postgres.utils import get_token_provider_from_config
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Helpers
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class FakeTokenProvider(PgTokenProvider):
25
+ """A concrete token provider that returns a fixed token for testing."""
26
+
27
+ def __init__(self, token: str = "fake-token", ttl: float = 3600) -> None:
28
+ super().__init__()
29
+ self._token = token
30
+ self._ttl = ttl
31
+ self.fetch_count = 0
32
+
33
+ def _fetch_token(self) -> tuple[str, float]:
34
+ self.fetch_count += 1
35
+ return self._token, time.time() + self._ttl
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Token caching
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ class TestTokenCaching:
44
+ def test_caches_token_within_ttl(self) -> None:
45
+ provider = FakeTokenProvider(token="tok-1", ttl=3600)
46
+ assert provider.get_token() == "tok-1"
47
+ assert provider.get_token() == "tok-1"
48
+ assert provider.fetch_count == 1
49
+
50
+ def test_refreshes_on_expiry(self) -> None:
51
+ provider = FakeTokenProvider(token="tok-1", ttl=0)
52
+ # ttl=0 means the token is already expired on creation
53
+ tok1 = provider.get_token()
54
+ assert tok1 == "tok-1"
55
+ assert provider.fetch_count == 1
56
+
57
+ # Second call should refresh because token is already past expiry
58
+ tok2 = provider.get_token()
59
+ assert tok2 == "tok-1"
60
+ assert provider.fetch_count == 2
61
+
62
+ def test_refreshes_within_margin(self) -> None:
63
+ # Token expires in 4 minutes — within the 5-minute refresh margin
64
+ provider = FakeTokenProvider(token="tok-1", ttl=240)
65
+ provider.get_token()
66
+ assert provider.fetch_count == 1
67
+
68
+ # Should refresh because 240s < 300s margin
69
+ provider.get_token()
70
+ assert provider.fetch_count == 2
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Thread safety
75
+ # ---------------------------------------------------------------------------
76
+
77
+
78
+ class TestThreadSafety:
79
+ def test_concurrent_get_token(self) -> None:
80
+ provider = FakeTokenProvider(token="tok-1", ttl=3600)
81
+ results: list[str] = []
82
+ errors: list[Exception] = []
83
+
84
+ def worker() -> None:
85
+ try:
86
+ results.append(provider.get_token())
87
+ except Exception as e:
88
+ errors.append(e)
89
+
90
+ threads = [threading.Thread(target=worker) for _ in range(20)]
91
+ for t in threads:
92
+ t.start()
93
+ for t in threads:
94
+ t.join()
95
+
96
+ assert not errors
97
+ assert all(r == "tok-1" for r in results)
98
+ # May have fetched more than once due to race at startup, but should be bounded
99
+ assert provider.fetch_count >= 1
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # do_connect hook
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ class TestDoConnectHook:
108
+ def test_hook_injects_token_on_connect(self) -> None:
109
+ """Verify that register_do_connect_hook causes the provider's token
110
+ to be used as the password when psycopg2.connect is called.
111
+ """
112
+ provider = FakeTokenProvider(token="injected-password", ttl=3600)
113
+ engine = sqlalchemy.create_engine(
114
+ "postgresql://user:dummy@localhost:5432/db",
115
+ poolclass=sqlalchemy.pool.NullPool,
116
+ )
117
+ register_do_connect_hook(engine, provider)
118
+
119
+ # Patch psycopg2.connect to capture the password that SQLAlchemy passes
120
+ captured_kwargs: dict[str, object] = {}
121
+ with patch("psycopg2.connect") as mock_connect:
122
+
123
+ def capture_connect(*args: object, **kwargs: object) -> None:
124
+ captured_kwargs.update(kwargs)
125
+ raise sqlalchemy.exc.OperationalError("test", {}, Exception("skip"))
126
+
127
+ mock_connect.side_effect = capture_connect
128
+
129
+ # Attempting to connect will invoke do_connect, which injects the token,
130
+ # then psycopg2.connect is called with the modified password.
131
+ try:
132
+ with engine.connect():
133
+ pass
134
+ except Exception:
135
+ pass
136
+
137
+ # The do_connect hook should have replaced "dummy" with the token
138
+ assert captured_kwargs.get("password") == "injected-password"
139
+ assert provider.fetch_count == 1
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Factory function
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ class TestCreateTokenProvider:
148
+ def test_azure_wif(self) -> None:
149
+ provider = create_token_provider({"azure_wif": {"scope": "custom-scope"}})
150
+ assert isinstance(provider, AzureWifTokenProvider)
151
+ assert provider._scope == "custom-scope" # noqa: SLF001
152
+
153
+ def test_azure_wif_default_scope(self) -> None:
154
+ provider = create_token_provider({"azure_wif": {}})
155
+ assert isinstance(provider, AzureWifTokenProvider)
156
+ assert "ossrdbms-aad" in provider._scope # noqa: SLF001
157
+
158
+ def test_gcp_wif(self) -> None:
159
+ provider = create_token_provider({"gcp_wif": {}})
160
+ assert isinstance(provider, GcpWifTokenProvider)
161
+
162
+ def test_aws_wif(self) -> None:
163
+ db_config = {"hostname": "my-host", "port": 5432, "username": "myuser"}
164
+ provider = create_token_provider({"aws_wif": {"region": "us-east-1"}}, db_config)
165
+ assert isinstance(provider, AwsWifTokenProvider)
166
+ assert provider._region == "us-east-1" # noqa: SLF001
167
+ assert provider._hostname == "my-host" # noqa: SLF001
168
+
169
+ def test_aws_wif_requires_db_config(self) -> None:
170
+ with pytest.raises(ValueError, match="AWS WIF requires postgres_db"):
171
+ create_token_provider({"aws_wif": {"region": "us-east-1"}})
172
+
173
+ def test_unknown_provider(self) -> None:
174
+ with pytest.raises(ValueError, match="Unknown auth_provider"):
175
+ create_token_provider({"unknown_wif": {}})
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Config extraction
180
+ # ---------------------------------------------------------------------------
181
+
182
+
183
+ class TestGetTokenProviderFromConfig:
184
+ def test_returns_none_without_auth_provider(self) -> None:
185
+ config = {
186
+ "postgres_db": {"username": "u", "password": "p", "hostname": "h", "db_name": "d"}
187
+ }
188
+ assert get_token_provider_from_config(config) is None
189
+
190
+ def test_returns_provider_with_auth_provider(self) -> None:
191
+ config = {
192
+ "postgres_db": {"username": "u", "hostname": "h", "db_name": "d"},
193
+ "auth_provider": {"azure_wif": {}},
194
+ }
195
+ provider = get_token_provider_from_config(config)
196
+ assert isinstance(provider, AzureWifTokenProvider)
197
+
198
+ def test_rejects_auth_provider_with_postgres_url(self) -> None:
199
+ config = {
200
+ "postgres_url": "postgresql://user:pass@host/db",
201
+ "auth_provider": {"azure_wif": {}},
202
+ }
203
+ with pytest.raises(Exception, match="auth_provider cannot be used with postgres_url"):
204
+ get_token_provider_from_config(config)
205
+
206
+
207
+ # ---------------------------------------------------------------------------
208
+ # Cloud provider import errors
209
+ # ---------------------------------------------------------------------------
210
+
211
+
212
+ class TestImportErrors:
213
+ def test_azure_missing_import(self) -> None:
214
+ provider = AzureWifTokenProvider()
215
+ with patch.dict("sys.modules", {"azure.identity": None, "azure": None}):
216
+ with pytest.raises(ImportError, match="azure-identity"):
217
+ provider._fetch_token() # noqa: SLF001
218
+
219
+ def test_gcp_missing_import(self) -> None:
220
+ provider = GcpWifTokenProvider()
221
+ with patch.dict("sys.modules", {"google.auth": None, "google": None}):
222
+ with pytest.raises(ImportError, match="google-auth"):
223
+ provider._fetch_token() # noqa: SLF001
224
+
225
+ def test_aws_missing_import(self) -> None:
226
+ provider = AwsWifTokenProvider(hostname="h", port=5432, username="u", region="us-east-1")
227
+ with patch.dict("sys.modules", {"boto3": None}):
228
+ with pytest.raises(ImportError, match="boto3"):
229
+ provider._fetch_token() # noqa: SLF001
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "dagster-postgres"
9
- version = "0.29.2"
9
+ version = "0.29.3"
10
10
  description = "A Dagster integration for postgres"
11
11
  authors = [
12
12
  { name = "Dagster Labs", email = "hello@dagsterlabs.com" },
@@ -22,11 +22,25 @@ classifiers = [
22
22
  "Operating System :: OS Independent",
23
23
  ]
24
24
  dependencies = [
25
- "dagster==1.13.2",
25
+ "dagster==1.13.3",
26
26
  "psycopg2-binary",
27
27
  ]
28
28
 
29
29
  [project.optional-dependencies]
30
+ azure = [
31
+ "azure-identity>=1.12.0",
32
+ ]
33
+ gcp = [
34
+ "google-auth>=2.0.0",
35
+ ]
36
+ aws = [
37
+ "boto3>=1.26.0",
38
+ ]
39
+ wif = [
40
+ "azure-identity>=1.12.0",
41
+ "google-auth>=2.0.0",
42
+ "boto3>=1.26.0",
43
+ ]
30
44
  test = []
31
45
 
32
46
  [project.urls]
@@ -1 +0,0 @@
1
- __version__ = "0.29.2"