onestep-sql 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ from onestep.resource_registry import ResourceRegistry
4
+
5
+ from . import mysql, postgres
6
+ from .mysql import register_resources as _register_mysql
7
+ from .postgres import register_resources as _register_postgres
8
+
9
+ __all__ = ["register_resources", "mysql", "postgres"]
10
+
11
+
12
+ def register_resources(registry: ResourceRegistry) -> None:
13
+ """Register every onestep-sql resource type (MySQL + PostgreSQL).
14
+
15
+ This is the single entry point for the canonical ``onestep-sql``
16
+ distribution (declared as ``sql`` in the ``onestep.resources`` group).
17
+ It delegates to the per-backend registrars without changing any of the
18
+ 14 existing YAML type names, their catalog roles, or their fields.
19
+ """
20
+ _register_mysql(registry)
21
+ _register_postgres(registry)
@@ -0,0 +1,26 @@
1
+ """Internal shared SQL behaviour for the canonical ``onestep-sql`` package.
2
+
3
+ Phase 2 of the ``onestep-mysql`` / ``onestep-postgres`` consolidation
4
+ (issue #133, design in PR #134) keeps exactly one copy of the machinery that
5
+ previously lived in parallel in both backend subpackages (as monitored by the
6
+ now-retired ``scripts/check_plugin_drift.py``):
7
+
8
+ * :mod:`onestep_sql._shared.state_sqlalchemy` — the SQLAlchemy state/cursor
9
+ stores; backends subclass them and override only the asyncio driver mapping
10
+ (``mysql+asyncmy`` vs ``postgresql+psycopg``) and install hint;
11
+ * :mod:`onestep_sql._shared.table_sink_policy` — the table-sink
12
+ ``update_columns`` write policies (``overwrite`` / ``skip_null`` /
13
+ ``backfill``), the ``_normalize_update_columns`` validator and the
14
+ ``_update_payload`` / ``_coerce_json_values`` mixin;
15
+ * :mod:`onestep_sql._shared.state_keys` — the default incremental state-key
16
+ derivation feeding the at-least-once cursor state;
17
+ * :mod:`onestep_sql._shared.resilience` — the secret-token collection, message
18
+ redaction, redacted error-cause base class and the
19
+ ``ConnectorOperationError`` factory; the dialect-specific SQLAlchemy error
20
+ classification tables stay in each backend's ``resilience.py``.
21
+
22
+ This package is **not** public API. Backend capabilities never move here:
23
+ MySQL binlog CDC stays in ``onestep_sql.mysql`` and the PostgreSQL tracked
24
+ execution modules stay in ``onestep_sql.postgres``. Dual-backend contract
25
+ tests live in ``tests/contract/test_onestep_sql_shared.py``.
26
+ """
@@ -0,0 +1,147 @@
1
+ """Shared secret-redaction scaffolding for the onestep-sql backends.
2
+
3
+ Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
4
+ exactly one copy of the resilience machinery that previously existed in
5
+ parallel in both backend ``resilience.py`` modules and differed only in the
6
+ backend name (see the retired ``scripts/check_plugin_drift.py``):
7
+
8
+ * the secret-token collection used to scrub error causes
9
+ (:func:`collect_sensitive_tokens`);
10
+ * the message redaction itself (:func:`redact_message`);
11
+ * the redacted error-cause base class (:class:`SQLErrorCause`) that backends
12
+ subclass with their own ``backend`` label so ``str(cause)`` keeps its
13
+ per-database prefix;
14
+ * the :class:`~onestep.resilience.ConnectorOperationError` factory
15
+ (:func:`as_connector_operation_error`).
16
+
17
+ The SQLAlchemy error-classification tables stay in each backend's
18
+ ``resilience.py`` because they encode genuinely different server error
19
+ messages (``server has gone away`` vs ``server closed the connection``,
20
+ ``deadlock found`` vs ``deadlock detected`` and so on).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from collections.abc import Callable, Mapping
26
+ from dataclasses import dataclass
27
+ from typing import ClassVar
28
+ from urllib.parse import urlsplit
29
+
30
+ from onestep.resilience import (
31
+ ConnectorErrorKind,
32
+ ConnectorOperation,
33
+ ConnectorOperationError,
34
+ )
35
+
36
+ REDACTED = "<redacted>"
37
+ MAX_MESSAGE_LENGTH = 500
38
+ SECRET_OPTION_KEYS = frozenset(
39
+ {
40
+ "password",
41
+ "passwd",
42
+ "pwd",
43
+ "secret",
44
+ "token",
45
+ "access_token",
46
+ "api_key",
47
+ "apikey",
48
+ "credentials",
49
+ "authorization",
50
+ }
51
+ )
52
+
53
+
54
+ def collect_sensitive_tokens(*config_values: object) -> list[str]:
55
+ """Collect secret substrings that may surface in SQL backend error messages.
56
+
57
+ SQLAlchemy masks the password in its rendered URL, but the underlying DBAPI
58
+ ``orig`` exception can still echo credentials (e.g. after echoing the full
59
+ connection string). Tokens are derived from connector config (raw DSN +
60
+ parsed userinfo + known secret option values) and used to scrub error
61
+ causes before they leave the plugin.
62
+ """
63
+ tokens: list[str] = []
64
+ seen: set[str] = set()
65
+
66
+ def add(value: object) -> None:
67
+ if value is None:
68
+ return
69
+ text = str(value)
70
+ if text and text not in seen:
71
+ seen.add(text)
72
+ tokens.append(text)
73
+
74
+ def collect_mapping(value: Mapping[object, object]) -> None:
75
+ for key, item in value.items():
76
+ if str(key).lower() in SECRET_OPTION_KEYS:
77
+ add(item)
78
+ elif isinstance(item, Mapping):
79
+ collect_mapping(item)
80
+
81
+ for value in config_values:
82
+ if isinstance(value, Mapping):
83
+ collect_mapping(value)
84
+ continue
85
+ add(value)
86
+ try:
87
+ parsed = urlsplit(str(value))
88
+ except ValueError:
89
+ continue
90
+ username = parsed.username
91
+ password = parsed.password
92
+ if username and password:
93
+ add(f"{username}:{password}")
94
+ add(f"{username}:{password}@")
95
+ add(password)
96
+ return tokens
97
+
98
+
99
+ def redact_message(message: str, tokens: list[str]) -> str:
100
+ redacted = message
101
+ for token in sorted(tokens, key=len, reverse=True):
102
+ if token:
103
+ redacted = redacted.replace(token, REDACTED)
104
+ return redacted[:MAX_MESSAGE_LENGTH]
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class SQLErrorCause(Exception):
109
+ """Redacted cause attached to normalized SQL connector errors.
110
+
111
+ Backends subclass this with their own ``backend`` label so the rendered
112
+ message keeps its per-database prefix (``mysql error: ...`` /
113
+ ``postgres error: ...``) while the dataclass shape, freezing and identity
114
+ semantics are implemented once here.
115
+ """
116
+
117
+ message: str
118
+
119
+ backend: ClassVar[str] = "sql"
120
+
121
+ def __str__(self) -> str:
122
+ return f"{self.backend} error: {self.message}"
123
+
124
+
125
+ def as_connector_operation_error(
126
+ *,
127
+ backend: str,
128
+ operation: ConnectorOperation,
129
+ exc: BaseException,
130
+ classify: Callable[[BaseException], ConnectorErrorKind | None],
131
+ redacted_cause: Callable[..., Exception],
132
+ source_name: str | None = None,
133
+ retry_delay_s: float | None = None,
134
+ secrets: list[str] | None = None,
135
+ ) -> ConnectorOperationError | None:
136
+ """Normalize ``exc`` using the backend's classifier and redacted cause."""
137
+ kind = classify(exc)
138
+ if kind is None:
139
+ return None
140
+ return ConnectorOperationError(
141
+ backend=backend,
142
+ operation=operation,
143
+ kind=kind,
144
+ source_name=source_name,
145
+ retry_delay_s=retry_delay_s,
146
+ cause=redacted_cause(exc, secrets=secrets),
147
+ )
@@ -0,0 +1,31 @@
1
+ """Shared default incremental state-key derivation for the onestep-sql backends.
2
+
3
+ Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
4
+ exactly one copy of ``_default_incremental_state_key``, which previously
5
+ existed in parallel in both backend ``connector.py`` modules (see the retired
6
+ ``scripts/check_plugin_drift.py``). The derived key feeds the at-least-once
7
+ cursor state, so its format is stable contract: changing it would orphan
8
+ existing persisted cursors.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from collections.abc import Sequence
15
+
16
+
17
+ def _default_incremental_state_key(
18
+ *,
19
+ table: str,
20
+ cursor: Sequence[str],
21
+ key: str,
22
+ where: str | None,
23
+ ) -> str:
24
+ normalized_where = " ".join((where or "").split())
25
+ if normalized_where:
26
+ where_fragment = normalized_where
27
+ if len(where_fragment) > 64:
28
+ where_fragment = f"sha1:{hashlib.sha1(where_fragment.encode('utf-8')).hexdigest()}"
29
+ else:
30
+ where_fragment = "-"
31
+ return f"{table}:{','.join(cursor)}:key={key}:where={where_fragment}"
@@ -0,0 +1,209 @@
1
+ """Shared SQLAlchemy state/cursor stores for the onestep-sql backends.
2
+
3
+ Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
4
+ exactly one copy of the store implementation that previously lived in parallel
5
+ in both backend ``state_sqlalchemy.py`` modules (see the retired
6
+ ``scripts/check_plugin_drift.py``). Dual-backend contract tests in
7
+ ``tests/contract/test_onestep_sql_shared.py`` pin the behaviour for both
8
+ backends.
9
+
10
+ Only two things are genuinely per-database and stay in the backend adapters:
11
+
12
+ * the asyncio driver mapping (``mysql+asyncmy`` vs ``postgresql+psycopg``) —
13
+ backends override :meth:`SQLAlchemyStateStore._resolve_async_driver`;
14
+ * the installation hint in the missing-SQLAlchemy error message — backends
15
+ override ``_install_hint``.
16
+
17
+ Backends subclass :class:`SQLAlchemyStateStore` /
18
+ :class:`SQLAlchemyCursorStore` so their public class identities stay inside
19
+ ``onestep_sql.mysql`` / ``onestep_sql.postgres`` while the behaviour is
20
+ implemented here once.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ from datetime import datetime, timezone
28
+ from typing import Any
29
+
30
+ try:
31
+ import sqlalchemy as sa
32
+ from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
33
+ except ImportError: # pragma: no cover - exercised when optional deps are missing
34
+ sa = None
35
+ AsyncEngine = None
36
+ create_async_engine = None
37
+
38
+
39
+ def sqlite_async_driver(drivername: str) -> str | None:
40
+ """Return the asyncio sqlite driver for sqlite URLs, else ``None``."""
41
+ if drivername in ("sqlite", "sqlite+pysqlite"):
42
+ return "sqlite+aiosqlite"
43
+ return None
44
+
45
+
46
+ def async_dsn(dsn: str, *, resolve_driver: Any) -> str:
47
+ """Return a SQLAlchemy URL backed by an asyncio-compatible dialect.
48
+
49
+ ``resolve_driver`` maps the URL's current drivername to the
50
+ asyncio-compatible drivername to use, or ``None`` to keep the DSN
51
+ unchanged. The per-database mappings live in the backend adapters.
52
+ """
53
+ if sa is None:
54
+ return dsn
55
+ url = sa.engine.make_url(dsn)
56
+ async_drivername = resolve_driver(url.drivername)
57
+ if async_drivername is None:
58
+ return dsn
59
+ return url.set(drivername=async_drivername).render_as_string(hide_password=False)
60
+
61
+
62
+ class SQLAlchemyStateStore:
63
+ _install_hint = "Install onestep-sql."
64
+
65
+ @staticmethod
66
+ def _resolve_async_driver(drivername: str) -> str | None:
67
+ return sqlite_async_driver(drivername)
68
+
69
+ def __init__(
70
+ self,
71
+ *,
72
+ dsn: str | None = None,
73
+ engine: Any | None = None,
74
+ table: str = "onestep_state",
75
+ key_column: str = "state_key",
76
+ value_column: str = "state_value",
77
+ updated_at_column: str = "updated_at",
78
+ auto_create: bool = True,
79
+ **engine_options: Any,
80
+ ) -> None:
81
+ if create_async_engine is None or sa is None:
82
+ raise RuntimeError(f"SQLAlchemyStateStore requires SQLAlchemy. {self._install_hint}")
83
+ if engine is None and dsn is None:
84
+ raise ValueError("dsn or engine is required")
85
+ if engine is not None and dsn is not None:
86
+ raise ValueError("pass either dsn or engine, not both")
87
+ engine_options.setdefault("pool_pre_ping", True)
88
+ self.engine = engine or create_async_engine(
89
+ async_dsn(dsn, resolve_driver=self._resolve_async_driver), **engine_options
90
+ )
91
+ self._owns_engine = engine is None
92
+ self.table_name = table
93
+ self.key_column_name = key_column
94
+ self.value_column_name = value_column
95
+ self.updated_at_column_name = updated_at_column
96
+ self.auto_create = auto_create
97
+ self._metadata = sa.MetaData()
98
+ self._table = sa.Table(
99
+ table,
100
+ self._metadata,
101
+ sa.Column(key_column, sa.String(255), primary_key=True),
102
+ sa.Column(value_column, sa.Text(), nullable=False),
103
+ sa.Column(updated_at_column, sa.DateTime(timezone=True), nullable=False),
104
+ )
105
+ self._ready = False
106
+ self._ready_lock: asyncio.Lock | None = None
107
+
108
+ async def load(self, key: str) -> Any | None:
109
+ await self._ensure_ready()
110
+ key_column = self._table.c[self.key_column_name]
111
+ value_column = self._table.c[self.value_column_name]
112
+ async with self.engine.begin() as conn:
113
+ row = (
114
+ await conn.execute(sa.select(value_column).where(key_column == key))
115
+ ).scalar_one_or_none()
116
+ if row is None:
117
+ return None
118
+ return json.loads(row)
119
+
120
+ async def save(self, key: str, value: Any) -> None:
121
+ await self._ensure_ready()
122
+ key_column = self._table.c[self.key_column_name]
123
+ payload = {
124
+ self.key_column_name: key,
125
+ self.value_column_name: json.dumps(value, ensure_ascii=False),
126
+ self.updated_at_column_name: datetime.now(timezone.utc),
127
+ }
128
+ async with self.engine.begin() as conn:
129
+ exists = (
130
+ await conn.execute(sa.select(key_column).where(key_column == key))
131
+ ).scalar_one_or_none()
132
+ if exists is None:
133
+ await conn.execute(sa.insert(self._table).values(**payload))
134
+ return
135
+ await conn.execute(
136
+ sa.update(self._table)
137
+ .where(key_column == key)
138
+ .values(
139
+ **{
140
+ self.value_column_name: payload[self.value_column_name],
141
+ self.updated_at_column_name: payload[self.updated_at_column_name],
142
+ }
143
+ )
144
+ )
145
+
146
+ async def delete(self, key: str) -> None:
147
+ await self._ensure_ready()
148
+ key_column = self._table.c[self.key_column_name]
149
+ async with self.engine.begin() as conn:
150
+ await conn.execute(sa.delete(self._table).where(key_column == key))
151
+
152
+ async def close(self) -> None:
153
+ if self._owns_engine:
154
+ await self.engine.dispose()
155
+
156
+ async def _ensure_ready(self) -> None:
157
+ if self._ready or not self.auto_create:
158
+ return
159
+ lock = self._ready_lock
160
+ if lock is None:
161
+ lock = asyncio.Lock()
162
+ self._ready_lock = lock
163
+ async with lock:
164
+ if self._ready:
165
+ return
166
+ async with self.engine.begin() as conn:
167
+ await conn.run_sync(
168
+ lambda sync_conn: self._metadata.create_all(
169
+ sync_conn, tables=[self._table], checkfirst=True
170
+ )
171
+ )
172
+ self._ready = True
173
+
174
+
175
+ class SQLAlchemyCursorStore(SQLAlchemyStateStore):
176
+ _DATETIME_TYPE_KEY = "__onestep_cursor_type__"
177
+ _DATETIME_TYPE_VALUE = "datetime"
178
+ _DATETIME_VALUE_KEY = "value"
179
+
180
+ async def load(self, key: str) -> Any | None:
181
+ value = await super().load(key)
182
+ if not isinstance(value, list):
183
+ return value
184
+ return [self._decode_cursor_component(component) for component in value]
185
+
186
+ async def save(self, key: str, value: Any) -> None:
187
+ if isinstance(value, (list, tuple)):
188
+ value = [self._encode_cursor_component(component) for component in value]
189
+ await super().save(key, value)
190
+
191
+ @classmethod
192
+ def _encode_cursor_component(cls, value: Any) -> Any:
193
+ if isinstance(value, datetime):
194
+ return {
195
+ cls._DATETIME_TYPE_KEY: cls._DATETIME_TYPE_VALUE,
196
+ cls._DATETIME_VALUE_KEY: value.isoformat(),
197
+ }
198
+ return value
199
+
200
+ @classmethod
201
+ def _decode_cursor_component(cls, value: Any) -> Any:
202
+ if (
203
+ isinstance(value, dict)
204
+ and set(value) == {cls._DATETIME_TYPE_KEY, cls._DATETIME_VALUE_KEY}
205
+ and value[cls._DATETIME_TYPE_KEY] == cls._DATETIME_TYPE_VALUE
206
+ and isinstance(value[cls._DATETIME_VALUE_KEY], str)
207
+ ):
208
+ return datetime.fromisoformat(value[cls._DATETIME_VALUE_KEY])
209
+ return value
@@ -0,0 +1,119 @@
1
+ """Shared table-sink update-policy machinery for the onestep-sql backends.
2
+
3
+ Phase 2 of the mysql/postgres consolidation (issue #133, design §3.1) keeps
4
+ exactly one copy of the update-policy helpers that previously lived in
5
+ parallel in both backend ``connector.py`` modules (see the retired
6
+ ``scripts/check_plugin_drift.py``):
7
+
8
+ * ``_UPDATE_COLUMN_POLICIES`` / ``_normalize_update_columns`` — validation of
9
+ the ``update_columns`` write policies (``overwrite`` / ``skip_null`` /
10
+ ``backfill``) against keys and ``update_expr``;
11
+ * :class:`TableSinkUpdatePolicy` — the ``_update_payload`` column-write
12
+ policy and ``_coerce_json_values`` JSON serialization mixin used by both
13
+ ``TableSink`` (mysql) and ``PostgresTableSink``.
14
+
15
+ Everything that genuinely differs between the backends stays in their
16
+ ``connector.py``: constructor validation order, ``_build_statement`` SQL
17
+ dialect branches (``ON DUPLICATE KEY UPDATE`` vs ``ON CONFLICT DO UPDATE``)
18
+ and the ``_send`` logging.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from collections.abc import Mapping, Sequence
25
+ from typing import Any
26
+
27
+ try:
28
+ import sqlalchemy as sa
29
+ except ImportError: # pragma: no cover - exercised when optional deps are missing
30
+ sa = None
31
+
32
+ _UPDATE_COLUMN_POLICIES = frozenset({"overwrite", "skip_null", "backfill"})
33
+
34
+
35
+ def _normalize_update_columns(
36
+ update_columns: Sequence[str | Mapping[str, str]] | None,
37
+ *,
38
+ keys: tuple[str, ...],
39
+ update_expr: Mapping[str, str] | None = None,
40
+ ) -> tuple[tuple[str, ...] | None, dict[str, str]]:
41
+ if update_columns is None:
42
+ return None, {}
43
+ names: list[str] = []
44
+ policies: dict[str, str] = {}
45
+ for entry in update_columns:
46
+ if isinstance(entry, str):
47
+ if not entry:
48
+ raise ValueError("update_columns entries must be non-empty")
49
+ name, policy = entry, "overwrite"
50
+ elif isinstance(entry, Mapping):
51
+ unknown_keys = set(entry) - {"name", "policy"}
52
+ if unknown_keys:
53
+ raise ValueError(f"unknown update_columns entry keys: {', '.join(sorted(unknown_keys))}")
54
+ name = entry.get("name")
55
+ policy = entry.get("policy", "overwrite")
56
+ if not isinstance(name, str) or not name:
57
+ raise ValueError("update_columns entry requires a non-empty 'name'")
58
+ if policy not in _UPDATE_COLUMN_POLICIES:
59
+ raise ValueError(
60
+ "update_columns policy must be one of 'overwrite', 'skip_null' or 'backfill', "
61
+ f"got {policy!r}"
62
+ )
63
+ if name in keys:
64
+ raise ValueError(f"update_columns policy cannot apply to key column {name!r}")
65
+ else:
66
+ raise TypeError("update_columns entries must be strings or mappings")
67
+ if name in policies:
68
+ raise ValueError(f"duplicate update column {name!r}")
69
+ names.append(name)
70
+ policies[name] = policy
71
+ update_expr_keys = set(update_expr) if update_expr else set()
72
+ conflicting = sorted(set(policies) & update_expr_keys)
73
+ if conflicting:
74
+ raise ValueError(f"update_columns policy conflicts with update_expr for: {', '.join(conflicting)}")
75
+ return tuple(names), policies
76
+
77
+
78
+ class TableSinkUpdatePolicy:
79
+ """Mixin implementing the shared SQL table-sink column-write policy.
80
+
81
+ Host sinks must set these instance attributes (both existing backends
82
+ already do): ``keys``, ``update_columns``, ``column_policies``,
83
+ ``update_expr`` and ``serialize_json``.
84
+ """
85
+
86
+ def _update_payload(self, payload: dict[str, Any], table: sa.Table) -> tuple[dict[str, Any], bool]:
87
+ if self.update_columns is not None:
88
+ candidates = {key: value for key, value in payload.items() if key in self.update_columns}
89
+ else:
90
+ candidates = {key: value for key, value in payload.items() if key not in self.keys}
91
+ update_payload: dict[str, Any] = {}
92
+ skipped = False
93
+ for column, value in candidates.items():
94
+ policy = self.column_policies.get(column, "overwrite")
95
+ if policy == "skip_null" and value is None:
96
+ skipped = True
97
+ continue
98
+ if policy == "backfill":
99
+ update_payload[column] = sa.func.coalesce(table.columns[column], value)
100
+ else:
101
+ update_payload[column] = value
102
+ for column, expr in self.update_expr.items():
103
+ update_payload[column] = sa.literal_column(expr)
104
+ return update_payload, skipped
105
+
106
+ def _coerce_json_values(self, payload: dict[str, Any], table: sa.Table) -> dict[str, Any]:
107
+ if self.serialize_json == "never":
108
+ return payload
109
+ coerced = dict(payload)
110
+ for column_name, value in list(payload.items()):
111
+ if not isinstance(value, (list, dict)):
112
+ continue
113
+ column = table.columns.get(column_name)
114
+ if column is None:
115
+ continue
116
+ is_json_column = isinstance(column.type, sa.JSON)
117
+ if self.serialize_json == "always" or not is_json_column:
118
+ coerced[column_name] = json.dumps(value, ensure_ascii=False)
119
+ return coerced
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version as _package_version
4
+
5
+ from .connector import (
6
+ BinlogDelivery,
7
+ BinlogSource,
8
+ IncrementalDelivery,
9
+ IncrementalTableSource,
10
+ MySQLConnector,
11
+ TableQueueDelivery,
12
+ TableQueueSource,
13
+ TableSink,
14
+ )
15
+ from .resources import register_resources
16
+ from .resilience import classify_sqlalchemy_error
17
+ from .state_sqlalchemy import SQLAlchemyCursorStore, SQLAlchemyStateStore
18
+
19
+ try:
20
+ __version__ = _package_version("onestep-sql")
21
+ except PackageNotFoundError: # pragma: no cover - local source tree before install
22
+ __version__ = "dev"
23
+
24
+ register = register_resources
25
+
26
+ __all__ = [
27
+ "BinlogDelivery",
28
+ "BinlogSource",
29
+ "IncrementalDelivery",
30
+ "IncrementalTableSource",
31
+ "MySQLConnector",
32
+ "SQLAlchemyCursorStore",
33
+ "SQLAlchemyStateStore",
34
+ "TableQueueDelivery",
35
+ "TableQueueSource",
36
+ "TableSink",
37
+ "__version__",
38
+ "classify_sqlalchemy_error",
39
+ "register",
40
+ "register_resources",
41
+ ]