tinkerfin-sqlalchemy 0.1.1__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,25 @@
1
+ """Asynchronous SQL transaction guarantees for storage integrations."""
2
+
3
+ from .capabilities import (
4
+ DatabaseCapabilities,
5
+ SqlDialect,
6
+ database_capabilities,
7
+ engine_dialect,
8
+ mysql_error_code,
9
+ postgresql_error_code,
10
+ sqlite_lock_error,
11
+ )
12
+ from .errors import SqlTransactionError
13
+ from .transaction import SqlTransaction
14
+
15
+ __all__ = [
16
+ "DatabaseCapabilities",
17
+ "SqlDialect",
18
+ "SqlTransaction",
19
+ "SqlTransactionError",
20
+ "database_capabilities",
21
+ "engine_dialect",
22
+ "mysql_error_code",
23
+ "postgresql_error_code",
24
+ "sqlite_lock_error",
25
+ ]
@@ -0,0 +1,164 @@
1
+ """Wait for connection cleanup without losing cancellation or independent failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections.abc import Awaitable
7
+ from dataclasses import dataclass
8
+ from typing import Generic, TypeGuard, TypeVar
9
+
10
+ from .errors import SqlTransactionError
11
+
12
+ ResultT = TypeVar("ResultT")
13
+
14
+
15
+ def _is_group(error: BaseException) -> TypeGuard[BaseExceptionGroup[BaseException]]:
16
+ return isinstance(error, BaseExceptionGroup)
17
+
18
+
19
+ def _contains(error: BaseException, target: BaseException) -> bool:
20
+ pending = [error]
21
+ seen: set[int] = set()
22
+ while pending:
23
+ current = pending.pop()
24
+ if current is target:
25
+ return True
26
+ if id(current) in seen:
27
+ continue
28
+ seen.add(id(current))
29
+ pending.extend(
30
+ item
31
+ for item in (current.__cause__, current.__context__)
32
+ if item is not None
33
+ )
34
+ if _is_group(current):
35
+ pending.extend(current.exceptions)
36
+ if isinstance(current, SqlTransactionError) and current.cause is not None:
37
+ pending.append(current.cause)
38
+ return False
39
+
40
+
41
+ def retain_failure(primary: BaseException, secondary: BaseException) -> None:
42
+ """Keep independent causes without creating a back-reference to the primary."""
43
+
44
+ if _contains(primary, secondary):
45
+ return
46
+ retained: dict[int, BaseException | None] = {id(primary): None}
47
+
48
+ def detach(error: BaseException | None) -> BaseException | None:
49
+ if error is None:
50
+ return None
51
+ if id(error) in retained:
52
+ return retained[id(error)]
53
+ result: BaseException | None = error
54
+ if _is_group(error):
55
+ _, result = error.split(lambda item: item is primary)
56
+ retained[id(error)] = result
57
+ if result is None:
58
+ return None
59
+ result.__cause__ = detach(result.__cause__)
60
+ result.__context__ = detach(result.__context__)
61
+ if isinstance(result, SqlTransactionError):
62
+ result.cause = detach(result.cause)
63
+ if _is_group(result):
64
+ for item in result.exceptions:
65
+ detach(item)
66
+ return result
67
+
68
+ original = detach(primary.__cause__ or primary.__context__)
69
+ primary.__context__ = detach(primary.__context__)
70
+ additional = detach(secondary)
71
+ if additional is None:
72
+ return
73
+ primary.__cause__ = (
74
+ additional
75
+ if original is None
76
+ else original
77
+ if _contains(original, additional)
78
+ else BaseExceptionGroup("SQL transaction failures", [original, additional])
79
+ )
80
+ note = f"SQL cleanup also failed: {type(secondary).__name__}"
81
+ if note not in getattr(primary, "__notes__", ()):
82
+ primary.add_note(note)
83
+
84
+
85
+ def select_failure(primary: BaseException, secondary: BaseException) -> BaseException:
86
+ """Keep process control ahead of cancellation and ordinary failures."""
87
+
88
+ def priority(error: BaseException) -> int:
89
+ if isinstance(error, asyncio.CancelledError):
90
+ return 1
91
+ return 0 if isinstance(error, Exception) else 2
92
+
93
+ if priority(secondary) > priority(primary):
94
+ retain_failure(secondary, primary)
95
+ return secondary
96
+ retain_failure(primary, secondary)
97
+ return primary
98
+
99
+
100
+ def restore_caller_cancellation(
101
+ error: BaseException, previous_cancellations: int
102
+ ) -> BaseException:
103
+ """Preserve cancellation replaced by a driver's failing invalidation listener."""
104
+
105
+ caller = asyncio.current_task()
106
+ if caller is None or caller.cancelling() <= previous_cancellations:
107
+ return error
108
+ # SQLAlchemy Connection._handle_dbapi_exception may replace CancelledError
109
+ # while disposing the interrupted connection. Only a newly requested caller
110
+ # cancellation with matching exception evidence can change the scope outcome.
111
+ pending = [error]
112
+ seen: set[int] = set()
113
+ cancellations: list[asyncio.CancelledError] = []
114
+ while pending:
115
+ current = pending.pop()
116
+ if id(current) in seen:
117
+ continue
118
+ seen.add(id(current))
119
+ if isinstance(current, asyncio.CancelledError):
120
+ cancellations.append(current)
121
+ pending.extend(
122
+ item
123
+ for item in (current.__cause__, current.__context__)
124
+ if item is not None
125
+ )
126
+ if _is_group(current):
127
+ pending.extend(current.exceptions)
128
+ if isinstance(current, SqlTransactionError) and current.cause is not None:
129
+ pending.append(current.cause)
130
+ selected = error
131
+ for cancellation in cancellations:
132
+ selected = select_failure(selected, cancellation)
133
+ return selected
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class _Result(Generic[ResultT]):
138
+ value: ResultT | None = None
139
+ error: BaseException | None = None
140
+
141
+
142
+ async def settle(operation: Awaitable[ResultT]) -> ResultT | None:
143
+ """Join one owned I/O operation before delivering repeated caller cancellation."""
144
+
145
+ async def capture() -> _Result[ResultT]:
146
+ try:
147
+ return _Result(value=await operation)
148
+ except BaseException as error: # noqa: BLE001 - transport process control to the owning caller
149
+ return _Result(error=error)
150
+
151
+ task = asyncio.create_task(capture(), name="tinkerfin-sql-connection-settlement")
152
+ cancellation: asyncio.CancelledError | None = None
153
+ while not task.done():
154
+ try:
155
+ await asyncio.shield(task)
156
+ except asyncio.CancelledError as error:
157
+ cancellation = cancellation or error
158
+ result = task.result()
159
+ error = result.error
160
+ if cancellation is not None:
161
+ error = cancellation if error is None else select_failure(cancellation, error)
162
+ if error is not None:
163
+ raise error
164
+ return result.value
@@ -0,0 +1,109 @@
1
+ """Database capabilities and driver error facts without retry policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ from sqlalchemy.exc import DBAPIError
10
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
11
+
12
+ SqlDialect = Literal["sqlite", "mysql", "postgresql"]
13
+
14
+
15
+ def engine_dialect(engine: AsyncEngine) -> SqlDialect:
16
+ """Validate an async Engine and identify its SQL dialect without database I/O.
17
+
18
+ Args:
19
+ engine: Caller-owned asynchronous SQLAlchemy Engine.
20
+
21
+ Returns:
22
+ The supported SQL dialect; no driver or server connection is opened.
23
+
24
+ Raises:
25
+ TypeError: engine is not an AsyncEngine.
26
+ ValueError: The Engine uses an unsupported SQL dialect.
27
+ """
28
+
29
+ if not isinstance(engine, AsyncEngine):
30
+ raise TypeError("engine must be an AsyncEngine")
31
+ name = engine.dialect.name
32
+ if name == "sqlite" or name == "mysql" or name == "postgresql":
33
+ return name
34
+ raise ValueError("Supported SQL dialects are SQLite, MySQL, and PostgreSQL")
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class DatabaseCapabilities:
39
+ """Locking capabilities of a connected database server.
40
+
41
+ Attributes:
42
+ dialect: Database family selected by the borrowed Engine.
43
+ server_version: Actual initialized SQLAlchemy server version.
44
+ row_locks: Whether SELECT FOR UPDATE acquires row locks.
45
+ skip_locked: Whether locked rows can be skipped when claiming work.
46
+ """
47
+
48
+ dialect: SqlDialect
49
+ server_version: tuple[int, ...]
50
+ row_locks: bool
51
+ skip_locked: bool
52
+
53
+
54
+ def database_capabilities(connection: AsyncConnection) -> DatabaseCapabilities:
55
+ """Read initialized server features from an already opened connection.
56
+
57
+ Args:
58
+ connection: Live connection whose dialect completed server initialization.
59
+
60
+ Returns:
61
+ Verified locking features; SQLite never advertises row locking.
62
+
63
+ Raises:
64
+ ValueError: Server version or database family is unsupported.
65
+ """
66
+
67
+ dialect = connection.dialect
68
+ name = engine_dialect(connection.engine)
69
+ version = dialect.server_version_info
70
+ if not version or not all(isinstance(part, int) for part in version):
71
+ raise ValueError("The database server version is unavailable")
72
+ if name == "mysql" and bool(getattr(dialect, "is_mariadb", False)):
73
+ raise ValueError("MariaDB is not a supported SQL database")
74
+ if name == "mysql" and version < (5, 7):
75
+ raise ValueError("MySQL 5.7 or newer is required")
76
+ if name == "postgresql" and version < (9, 5):
77
+ raise ValueError("PostgreSQL 9.5 or newer is required")
78
+ return DatabaseCapabilities(
79
+ dialect=name,
80
+ server_version=tuple(version),
81
+ row_locks=name != "sqlite",
82
+ skip_locked=name == "postgresql" or (name == "mysql" and version >= (8,)),
83
+ )
84
+
85
+
86
+ def sqlite_lock_error(error: BaseException) -> bool:
87
+ """Identify SQLite BUSY/LOCKED, including extended codes, without text matching."""
88
+
89
+ original = error.orig if isinstance(error, DBAPIError) else error
90
+ if not isinstance(original, sqlite3.Error):
91
+ return False
92
+ code = getattr(original, "sqlite_errorcode", None)
93
+ return isinstance(code, int) and (code & 0xFF) in {5, 6}
94
+
95
+
96
+ def mysql_error_code(error: BaseException) -> int | None:
97
+ """Return a numeric MySQL driver code without deciding whether to retry."""
98
+
99
+ original = error.orig if isinstance(error, DBAPIError) else error
100
+ args = () if original is None else original.args
101
+ return args[0] if args and isinstance(args[0], int) else None
102
+
103
+
104
+ def postgresql_error_code(error: BaseException) -> str | None:
105
+ """Return SQLSTATE from asyncpg or psycopg's SQLAlchemy driver boundary."""
106
+
107
+ original = error.orig if isinstance(error, DBAPIError) else error
108
+ code = getattr(original, "sqlstate", None) or getattr(original, "pgcode", None)
109
+ return code if isinstance(code, str) else None
@@ -0,0 +1,40 @@
1
+ """Failures owned by the shared SQL transaction boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from types import MappingProxyType
7
+
8
+ from sqlalchemy.exc import SQLAlchemyError
9
+
10
+
11
+ class SqlTransactionError(SQLAlchemyError):
12
+ """A transaction cannot establish or release its required database guarantees.
13
+
14
+ SQLAlchemyError inheritance lets a storage integration translate this failure
15
+ together with driver failures. Context is safe for callers; cause is diagnostic
16
+ evidence and must not be serialized into client responses.
17
+ """
18
+
19
+ code = "sqlalchemy.transaction"
20
+
21
+ def __init__(
22
+ self,
23
+ message: str,
24
+ *,
25
+ context: Mapping[str, str | int | bool | None] | None = None,
26
+ cause: BaseException | None = None,
27
+ ) -> None:
28
+ """Snapshot safe context and retain the original diagnostic failure."""
29
+
30
+ self.message = message
31
+ self.context = MappingProxyType(dict(context or {}))
32
+ self.cause = cause
33
+ if cause is not None:
34
+ self.__cause__ = cause
35
+ super().__init__(message)
36
+
37
+ def __str__(self) -> str:
38
+ """Return the safe message without interpreting the code as a SQLAlchemy URL."""
39
+
40
+ return self.message
File without changes
@@ -0,0 +1,556 @@
1
+ """Single-attempt transactions that restore or discard every borrowed connection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import hashlib
7
+ from types import TracebackType
8
+ from typing import Literal
9
+
10
+ from sqlalchemy import text
11
+ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine
12
+ from sqlalchemy.pool import PoolProxiedConnection, StaticPool
13
+
14
+ from ._settlement import restore_caller_cancellation, select_failure, settle
15
+ from .capabilities import database_capabilities, engine_dialect, sqlite_lock_error
16
+ from .errors import SqlTransactionError
17
+
18
+
19
+ class SqlTransaction:
20
+ """Borrow one connection for an atomic write or a consistent read snapshot.
21
+
22
+ A successful write scope commits automatically; read scopes and failed writes
23
+ roll back. The Engine stays caller-owned. SQLite uses explicit BEGIN IMMEDIATE
24
+ for writes and BEGIN for reads. MySQL and PostgreSQL use explicit transactions
25
+ even when the host Engine uses AUTOCOMMIT. Connection settings are restored
26
+ before pool return; restoration or advisory-lock release failure discards the
27
+ physical connection.
28
+
29
+ The scope never retries domain operations. A storage implementation may call
30
+ commit() itself to retain the same transaction after SQLite COMMIT BUSY, and
31
+ inspect the outcome before applying its own retry policy. Use the yielded
32
+ connection for statements; transaction control and connection closure belong to
33
+ this scope. Engines must provide exclusive checkouts; StaticPool is rejected
34
+ because one borrower can otherwise roll back another borrower's work.
35
+ Cancellation during body I/O propagates after cleanup. Connection acquisition,
36
+ commit, rollback, and pool return settle before repeated caller cancellation
37
+ propagates. Driver connection and statement timeouts remain controlled by the
38
+ Engine's owner.
39
+
40
+ Args:
41
+ engine: Borrowed asynchronous SQLite, MySQL, or PostgreSQL Engine.
42
+ read_only: Select one repeatable snapshot instead of a write transaction.
43
+ isolation_level: Optional write isolation, for batches that read one snapshot
44
+ before writing. Read-only scopes require REPEATABLE READ; schema setup
45
+ requires READ COMMITTED. SQLite uses its explicit transaction guarantees.
46
+ sqlite_busy_timeout_ms: Temporary SQLite lock wait in milliseconds, or None
47
+ to retain the host setting. The scope never retries a busy result.
48
+ mysql_lock_wait_timeout_seconds: Temporary MySQL InnoDB row-lock wait.
49
+ schema_lock: Stable database-local name for mutually exclusive schema setup.
50
+ The lock and DDL must use this scope's connection.
51
+ schema_lock_timeout_seconds: Maximum database wait for the schema lock;
52
+ zero rejects an occupied lock immediately.
53
+
54
+ Raises:
55
+ TypeError: An Engine or timing argument has the wrong type.
56
+ ValueError: A timing value, dialect, or read-only option is invalid.
57
+ SqlTransactionError: A scope is reused or its database guarantees fail.
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ engine: AsyncEngine,
63
+ *,
64
+ read_only: bool = False,
65
+ isolation_level: Literal["READ COMMITTED", "REPEATABLE READ"] | None = None,
66
+ sqlite_busy_timeout_ms: int | None = None,
67
+ mysql_lock_wait_timeout_seconds: int | None = None,
68
+ schema_lock: str | None = None,
69
+ schema_lock_timeout_seconds: int = 30,
70
+ ) -> None:
71
+ """Validate one transaction declaration without opening a connection."""
72
+
73
+ self._dialect = engine_dialect(engine)
74
+ if isinstance(engine.sync_engine.pool, StaticPool):
75
+ raise TypeError(
76
+ "SQL transactions require exclusive connection checkouts; "
77
+ "use AsyncAdaptedQueuePool for SQLite in-memory engines"
78
+ )
79
+ if schema_lock_timeout_seconds is None:
80
+ raise TypeError("schema_lock_timeout_seconds must be an integer")
81
+ for name, value, minimum in (
82
+ ("sqlite_busy_timeout_ms", sqlite_busy_timeout_ms, 0),
83
+ ("mysql_lock_wait_timeout_seconds", mysql_lock_wait_timeout_seconds, 1),
84
+ ("schema_lock_timeout_seconds", schema_lock_timeout_seconds, 0),
85
+ ):
86
+ if value is not None:
87
+ if isinstance(value, bool) or not isinstance(value, int):
88
+ raise TypeError(f"{name} must be an integer")
89
+ if value < minimum:
90
+ raise ValueError(f"{name} must be at least {minimum}")
91
+ if not isinstance(read_only, bool):
92
+ raise TypeError("read_only must be a boolean")
93
+ if isolation_level not in {None, "READ COMMITTED", "REPEATABLE READ"}:
94
+ raise ValueError("Unsupported transaction isolation level")
95
+ if read_only and isolation_level == "READ COMMITTED":
96
+ raise ValueError("Read-only scopes require a repeatable snapshot")
97
+ if schema_lock is not None:
98
+ if not isinstance(schema_lock, str):
99
+ raise TypeError("schema_lock must be a string or None")
100
+ if not schema_lock or schema_lock != schema_lock.strip():
101
+ raise ValueError("schema_lock must be a non-empty canonical name")
102
+ schema_lock.encode("utf-8")
103
+ if read_only:
104
+ raise ValueError("schema setup requires a write transaction")
105
+ if isolation_level == "REPEATABLE READ":
106
+ raise ValueError("schema setup requires READ COMMITTED isolation")
107
+ self._engine = engine
108
+ self._read_only = read_only
109
+ self._isolation_level = isolation_level or (
110
+ "REPEATABLE READ" if read_only else "READ COMMITTED"
111
+ )
112
+ self._busy_timeout = sqlite_busy_timeout_ms
113
+ self._mysql_timeout = mysql_lock_wait_timeout_seconds
114
+ self._schema_lock = schema_lock
115
+ self._schema_timeout = schema_lock_timeout_seconds
116
+ self._connection: AsyncConnection | None = None
117
+ self._pooled: PoolProxiedConnection | None = None
118
+ self._old_sqlite_timeout: int | None = None
119
+ self._old_sqlite_query_only: int | None = None
120
+ self._old_mysql_timeout: int | None = None
121
+ self._mysql_schema_lock: str | None = None
122
+ self._used = False
123
+ self._began = False
124
+ self._discard = False
125
+ self._committed = False
126
+ self._rolled_back = False
127
+ self._commit_uncertain = False
128
+ self._cleanup_failed = False
129
+ self._commit_failure: BaseException | None = None
130
+ self._cancellations = 0
131
+
132
+ @property
133
+ def committed(self) -> bool:
134
+ """Whether the database acknowledged this attempt's COMMIT."""
135
+
136
+ return self._committed
137
+
138
+ @property
139
+ def rolled_back(self) -> bool:
140
+ """Whether this attempt ended with an acknowledged ROLLBACK."""
141
+
142
+ return self._rolled_back
143
+
144
+ @property
145
+ def commit_uncertain(self) -> bool:
146
+ """Whether an issued COMMIT failed without proving that it stayed uncommitted."""
147
+
148
+ return self._commit_uncertain
149
+
150
+ @property
151
+ def cleanup_failed(self) -> bool:
152
+ """Whether rollback, settings restoration, or connection return failed.
153
+
154
+ A storage implementation must preserve this failure instead of retrying
155
+ the domain operation, even when physical connection disposal succeeded.
156
+
157
+ Returns:
158
+ True if any connection cleanup step failed.
159
+ """
160
+
161
+ return self._cleanup_failed
162
+
163
+ async def __aenter__(self) -> AsyncConnection:
164
+ """Open and initialize one owned connection checkout before exposing it.
165
+
166
+ Returns:
167
+ The borrowed SQLAlchemy connection inside its active transaction.
168
+
169
+ Raises:
170
+ SqlTransactionError: This declaration was already entered.
171
+ BaseException: Acquisition or initialization fails after cleanup.
172
+ """
173
+
174
+ if self._used:
175
+ raise SqlTransactionError("A SQL transaction declaration is single-use")
176
+ self._used = True
177
+ caller = asyncio.current_task()
178
+ self._cancellations = 0 if caller is None else caller.cancelling()
179
+ connection = self._engine.connect()
180
+ self._connection = connection
181
+ try:
182
+ await settle(connection.start())
183
+ self._pooled = await connection.get_raw_connection()
184
+ database_capabilities(connection)
185
+ await self._begin(connection)
186
+ except BaseException as error:
187
+ # Failed initialization may leave next-transaction settings or a session
188
+ # advisory lock active even when the driver reports no open transaction.
189
+ self._discard = True
190
+ primary = restore_caller_cancellation(error, self._cancellations)
191
+ await self._finish(primary)
192
+ if primary is not error:
193
+ raise primary
194
+ raise
195
+ return connection
196
+
197
+ async def __aexit__(
198
+ self,
199
+ _exc_type: type[BaseException] | None,
200
+ error: BaseException | None,
201
+ _traceback: TracebackType | None,
202
+ ) -> None:
203
+ """Commit successful work and settle all connection ownership before returning."""
204
+
205
+ primary = (
206
+ None
207
+ if error is None
208
+ else restore_caller_cancellation(error, self._cancellations)
209
+ )
210
+ try:
211
+ if primary is None and not self._read_only and not self._rolled_back:
212
+ await self.commit()
213
+ except BaseException as failure: # noqa: BLE001 - pool return still owns every outcome
214
+ primary = failure
215
+ await self._finish(primary)
216
+ if primary is not None and primary is not error:
217
+ raise primary
218
+
219
+ def _require_connection(self) -> AsyncConnection:
220
+ connection = self._connection
221
+ if connection is None or connection.sync_connection is None:
222
+ raise SqlTransactionError("The SQL transaction is not active")
223
+ return connection
224
+
225
+ async def commit(self) -> None:
226
+ """Commit once, retaining a SQLite BUSY transaction for a domain-owned retry.
227
+
228
+ Raises:
229
+ SqlTransactionError: The scope is inactive, read-only, or rolled back.
230
+ BaseException: COMMIT fails or caller cancellation follows its settlement.
231
+ """
232
+
233
+ connection = self._require_connection()
234
+ if self._read_only or self._rolled_back:
235
+ raise SqlTransactionError("This SQL transaction cannot commit")
236
+ if self._committed:
237
+ return
238
+ if self._commit_uncertain:
239
+ assert self._commit_failure is not None
240
+ raise self._commit_failure
241
+
242
+ async def commit_once() -> None:
243
+ try:
244
+ if self._dialect == "sqlite":
245
+ await connection.exec_driver_sql("COMMIT")
246
+ else:
247
+ await connection.commit()
248
+ except BaseException as error:
249
+ self._commit_failure = error
250
+ self._commit_uncertain = not (
251
+ self._dialect == "sqlite"
252
+ and sqlite_lock_error(error)
253
+ and not connection.invalidated
254
+ )
255
+ raise
256
+ self._committed = True
257
+ # Explicit SQL also works under host AUTOCOMMIT. Clear SQLAlchemy's
258
+ # autobegin bookkeeping after the server acknowledged completion.
259
+ await connection.rollback()
260
+
261
+ await settle(commit_once())
262
+
263
+ async def rollback(self) -> None:
264
+ """Acknowledge rollback without returning the connection or selecting a retry."""
265
+
266
+ connection = self._require_connection()
267
+ if self._committed or self._rolled_back:
268
+ return
269
+
270
+ async def rollback_once() -> None:
271
+ if self._began and not connection.invalidated:
272
+ if self._dialect == "sqlite" or (
273
+ self._dialect == "mysql"
274
+ and self._read_only
275
+ and connection.dialect.skip_autocommit_rollback
276
+ ):
277
+ # A host may disable driver rollback under AUTOCOMMIT, but the
278
+ # explicit read transaction still has to end before pool return.
279
+ await connection.exec_driver_sql("ROLLBACK")
280
+ else:
281
+ await connection.rollback()
282
+ self._rolled_back = True
283
+ await connection.rollback()
284
+
285
+ try:
286
+ await settle(rollback_once())
287
+ except BaseException:
288
+ self._cleanup_failed = True
289
+ self._discard = True
290
+ raise
291
+
292
+ async def _begin(self, connection: AsyncConnection) -> None:
293
+ if self._dialect == "sqlite":
294
+ if self._read_only:
295
+ self._old_sqlite_query_only = await self._integer_setting(
296
+ connection, "PRAGMA query_only"
297
+ )
298
+ await connection.exec_driver_sql("PRAGMA query_only = 1")
299
+ await connection.rollback()
300
+ wait = self._busy_timeout
301
+ if self._schema_lock is not None and wait is None:
302
+ wait = self._schema_timeout * 1000
303
+ if wait is not None:
304
+ self._old_sqlite_timeout = await self._integer_setting(
305
+ connection, "PRAGMA busy_timeout"
306
+ )
307
+ await connection.exec_driver_sql(f"PRAGMA busy_timeout = {wait}")
308
+ await connection.rollback()
309
+ await connection.exec_driver_sql(
310
+ "BEGIN" if self._read_only else "BEGIN IMMEDIATE"
311
+ )
312
+ self._began = True
313
+ return
314
+
315
+ if self._dialect == "mysql":
316
+ if self._mysql_timeout is not None:
317
+ self._old_mysql_timeout = await self._integer_setting(
318
+ connection, "SELECT @@SESSION.innodb_lock_wait_timeout"
319
+ )
320
+ await connection.exec_driver_sql(
321
+ f"SET SESSION innodb_lock_wait_timeout = {self._mysql_timeout}"
322
+ )
323
+ await connection.rollback()
324
+ if self._schema_lock is not None:
325
+ database = self._engine.url.database or ""
326
+ digest = hashlib.sha256(
327
+ f"{database}\0{self._schema_lock}".encode()
328
+ ).hexdigest()[:48]
329
+ lock_name = f"tinkerfin:{digest}"
330
+ acquired = await connection.scalar(
331
+ text("SELECT GET_LOCK(:name, :timeout)"),
332
+ {"name": lock_name, "timeout": self._schema_timeout},
333
+ )
334
+ if acquired != 1:
335
+ raise SqlTransactionError(
336
+ "The SQL schema lock could not be acquired"
337
+ )
338
+ self._mysql_schema_lock = lock_name
339
+ await connection.rollback()
340
+ if self._read_only:
341
+ await connection.exec_driver_sql(
342
+ "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ"
343
+ )
344
+ await connection.exec_driver_sql("START TRANSACTION READ ONLY")
345
+ else:
346
+ await connection.execution_options(
347
+ isolation_level=self._isolation_level
348
+ )
349
+ await connection.begin()
350
+ else:
351
+ # asyncpg starts its driver transaction before preparing the first SQL
352
+ # statement (SQLAlchemy 2.0.52 AsyncAdapt_asyncpg_cursor). Configure that
353
+ # transaction rather than issuing a nested raw BEGIN. Pool return resets
354
+ # the connection's isolation to the host Engine's configured default.
355
+ await connection.execution_options(isolation_level=self._isolation_level)
356
+ await connection.begin()
357
+ if self._read_only:
358
+ await connection.exec_driver_sql("SET TRANSACTION READ ONLY")
359
+ self._began = True
360
+
361
+ if self._dialect == "postgresql" and self._schema_lock is not None:
362
+ lock_key = int.from_bytes(
363
+ hashlib.sha256(self._schema_lock.encode("utf-8")).digest()[:8],
364
+ "big",
365
+ signed=True,
366
+ )
367
+ if self._schema_timeout == 0:
368
+ acquired = await connection.scalar(
369
+ text("SELECT pg_try_advisory_xact_lock(:key)"), {"key": lock_key}
370
+ )
371
+ if not acquired:
372
+ raise SqlTransactionError(
373
+ "The SQL schema lock could not be acquired"
374
+ )
375
+ else:
376
+ await connection.execute(
377
+ text("SELECT set_config('lock_timeout', :timeout, true)"),
378
+ {"timeout": f"{self._schema_timeout * 1000}ms"},
379
+ )
380
+ await connection.execute(
381
+ text("SELECT pg_advisory_xact_lock(:key)"), {"key": lock_key}
382
+ )
383
+
384
+ @staticmethod
385
+ async def _integer_setting(connection: AsyncConnection, query: str) -> int:
386
+ value = (await connection.exec_driver_sql(query)).scalar_one()
387
+ if not isinstance(value, int) or value < 0:
388
+ raise SqlTransactionError(
389
+ "The database returned an invalid connection setting"
390
+ )
391
+ await connection.rollback()
392
+ return value
393
+
394
+ async def _invalidate(
395
+ self, connection: AsyncConnection, error: BaseException | None
396
+ ) -> None:
397
+ pooled = self._pooled
398
+ failures: list[BaseException] = []
399
+ try:
400
+ if (
401
+ connection.dialect.driver == "asyncmy"
402
+ and pooled is not None
403
+ and pooled.is_valid
404
+ ):
405
+ # asyncmy marks an interrupted socket unusable. Pool proxy
406
+ # invalidation terminates it outside that failed greenlet.
407
+ pooled.invalidate(error)
408
+ else:
409
+ # aiosqlite needs awaited graceful close to release SQLite locks.
410
+ await connection.invalidate(error)
411
+ except BaseException as failure: # noqa: BLE001 - verify physical disposal even when host events fail
412
+ failures.append(failure)
413
+ if pooled is not None and pooled.is_valid:
414
+ try:
415
+ await self._retire_connection(connection, pooled)
416
+ except BaseException as failure: # noqa: BLE001 - retain every failure from the disposal boundary
417
+ failures.append(failure)
418
+ try:
419
+ await connection.rollback()
420
+ except BaseException as failure: # noqa: BLE001 - clear transaction bookkeeping after driver disposal
421
+ failures.append(failure)
422
+ if failures:
423
+ selected = failures[0]
424
+ for failure in failures[1:]:
425
+ selected = select_failure(selected, failure)
426
+ raise selected
427
+
428
+ async def _retire_connection(
429
+ self, connection: AsyncConnection, pooled: PoolProxiedConnection
430
+ ) -> None:
431
+ """Detach an unsafe live checkout before closing its actual driver connection.
432
+
433
+ SQLAlchemy 2.0.52 _ConnectionRecord.invalidate dispatches host listeners
434
+ before physical close. A listener failure must not return the unsafe socket
435
+ to the pool. Public detach removes pool ownership before its own event, so
436
+ driver disposal can finish even if that event fails as well. The dialect's
437
+ close runs inside run_sync to await native async driver cleanup.
438
+ """
439
+
440
+ dbapi = pooled.dbapi_connection
441
+ assert dbapi is not None
442
+ failures: list[BaseException] = []
443
+ try:
444
+ await connection.run_sync(lambda _sync: pooled.detach())
445
+ except BaseException as failure: # noqa: BLE001 - detach events run after pool ownership is released
446
+ failures.append(failure)
447
+ if not pooled.is_detached:
448
+ raise SqlTransactionError(
449
+ "An unsafe SQL connection could not be detached from its pool",
450
+ cause=failures[0] if failures else None,
451
+ )
452
+ try:
453
+ await connection.run_sync(lambda sync: sync.dialect.do_close(dbapi))
454
+ except BaseException as failure: # noqa: BLE001 - a failed graceful close still requires termination
455
+ failures.append(failure)
456
+ try:
457
+ await connection.run_sync(lambda sync: sync.dialect.do_terminate(dbapi))
458
+ except BaseException as termination_error: # noqa: BLE001 - retain the failed final termination
459
+ failures.append(termination_error)
460
+ finally:
461
+ # A detached proxy has no pool record: invalidate clears its references
462
+ # without invoking the failed pool event or closing the driver again.
463
+ pooled.invalidate()
464
+ if failures:
465
+ selected = failures[0]
466
+ for failure in failures[1:]:
467
+ selected = select_failure(selected, failure)
468
+ raise selected
469
+
470
+ async def _finish(self, primary: BaseException | None) -> None:
471
+ connection = self._connection
472
+ if connection is None:
473
+ return
474
+
475
+ async def cleanup() -> None:
476
+ errors: list[BaseException] = []
477
+ try:
478
+ if connection.sync_connection is None:
479
+ return
480
+ discard = (
481
+ self._discard
482
+ or self._commit_uncertain
483
+ or isinstance(primary, asyncio.CancelledError)
484
+ )
485
+ if not discard and not connection.invalidated:
486
+ try:
487
+ await self.rollback()
488
+ if self._old_sqlite_timeout is not None:
489
+ await connection.exec_driver_sql(
490
+ f"PRAGMA busy_timeout = {self._old_sqlite_timeout}"
491
+ )
492
+ if self._old_sqlite_query_only is not None:
493
+ await connection.exec_driver_sql(
494
+ f"PRAGMA query_only = {self._old_sqlite_query_only}"
495
+ )
496
+ if self._old_mysql_timeout is not None:
497
+ await connection.exec_driver_sql(
498
+ f"SET SESSION innodb_lock_wait_timeout = {self._old_mysql_timeout}"
499
+ )
500
+ if self._mysql_schema_lock is not None:
501
+ released = await connection.scalar(
502
+ text("SELECT RELEASE_LOCK(:name)"),
503
+ {"name": self._mysql_schema_lock},
504
+ )
505
+ if released != 1:
506
+ raise SqlTransactionError(
507
+ "The SQL schema lock could not be released"
508
+ )
509
+ await connection.rollback()
510
+ except BaseException as error: # noqa: BLE001 - failed settings cannot enter the pool
511
+ errors.append(error)
512
+ discard = True
513
+ if discard or connection.invalidated:
514
+ try:
515
+ await self._invalidate(connection, primary)
516
+ except BaseException as error: # noqa: BLE001 - close still has to return the checkout
517
+ errors.append(error)
518
+ finally:
519
+ try:
520
+ if connection.sync_connection is not None:
521
+ await connection.close()
522
+ except BaseException as error: # noqa: BLE001 - preserve all cleanup evidence
523
+ errors.append(error)
524
+ # A failed AsyncConnection.close() is not evidence of pool
525
+ # return. Invalidate the physical connection, then close its
526
+ # already captured invalid proxy to return this checkout. That
527
+ # final proxy close performs no driver I/O after invalidation.
528
+ if not connection.closed:
529
+ try:
530
+ await self._invalidate(connection, error)
531
+ except BaseException as invalidate_error: # noqa: BLE001 - retain cleanup failures together
532
+ errors.append(invalidate_error)
533
+ finally:
534
+ pooled = self._pooled
535
+ if pooled is not None and not pooled.is_valid:
536
+ try:
537
+ pooled.close()
538
+ except BaseException as return_error: # noqa: BLE001 - report a failed checkout return
539
+ errors.append(return_error)
540
+ if errors:
541
+ self._cleanup_failed = True
542
+ selected = errors[0]
543
+ for error in errors[1:]:
544
+ selected = select_failure(selected, error)
545
+ raise selected
546
+
547
+ try:
548
+ await settle(cleanup())
549
+ except BaseException as cleanup_error:
550
+ if primary is None:
551
+ raise
552
+ selected = select_failure(primary, cleanup_error)
553
+ if selected is not primary:
554
+ raise selected
555
+ finally:
556
+ self._connection = None
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: tinkerfin-sqlalchemy
3
+ Version: 0.1.1
4
+ Summary: Async SQL transaction and connection ownership for TinkerFin storage integrations.
5
+ License-Expression: Apache-2.0
6
+ Project-URL: Homepage, https://github.com/tinkerfin-ai/tinkerfin-harness
7
+ Project-URL: Repository, https://github.com/tinkerfin-ai/tinkerfin-harness
8
+ Project-URL: Issues, https://github.com/tinkerfin-ai/tinkerfin-harness/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Typing :: Typed
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: sqlalchemy[asyncio]==2.0.52
20
+ Dynamic: license-file
21
+
22
+ # tinkerfin-sqlalchemy
23
+
24
+ Async transaction support for storage integrations using SQLite, MySQL, or PostgreSQL.
25
+ Application code normally uses a TinkerFin Store and supplies its own SQLAlchemy
26
+ `AsyncEngine`; storage integrations use `SqlTransaction` directly.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ pip install tinkerfin-sqlalchemy
32
+ ```
33
+
34
+ Python 3.11 or newer is required. Install an asynchronous database driver such as
35
+ `aiosqlite`, `asyncmy`, or `asyncpg`.
36
+
37
+ ## Quick Start
38
+
39
+ Install the driver for this in-memory example:
40
+
41
+ ```bash
42
+ pip install aiosqlite
43
+ ```
44
+
45
+ ```python
46
+ import asyncio
47
+
48
+ from sqlalchemy import text
49
+ from sqlalchemy.ext.asyncio import create_async_engine
50
+ from sqlalchemy.pool import AsyncAdaptedQueuePool
51
+
52
+ from tinkerfin_sqlalchemy import SqlTransaction
53
+
54
+
55
+ async def main() -> None:
56
+ engine = create_async_engine(
57
+ "sqlite+aiosqlite:///:memory:",
58
+ poolclass=AsyncAdaptedQueuePool,
59
+ pool_size=1,
60
+ max_overflow=0,
61
+ )
62
+ try:
63
+ async with SqlTransaction(engine, schema_lock="example-balances") as connection:
64
+ await connection.execute(
65
+ text("CREATE TABLE balances (amount INTEGER NOT NULL)")
66
+ )
67
+ await connection.execute(text("INSERT INTO balances (amount) VALUES (0)"))
68
+
69
+ async with SqlTransaction(engine) as connection:
70
+ await connection.execute(text("UPDATE balances SET amount = amount + 1"))
71
+
72
+ async with SqlTransaction(engine, read_only=True) as connection:
73
+ result = await connection.execute(text("SELECT amount FROM balances"))
74
+ print(result.scalar_one())
75
+ finally:
76
+ await engine.dispose()
77
+
78
+
79
+ asyncio.run(main())
80
+ ```
81
+
82
+ Successful writes commit; failed writes roll back. Reads share one database snapshot.
83
+ The Engine remains owned by the caller. Temporary lock settings are restored before
84
+ connections return to the pool; connections that cannot be restored are discarded.
85
+ Cancellation waits for connection cleanup. Driver timeouts remain host configuration.
86
+
87
+ Connections must have exclusive pool checkouts. File-based SQLite engines use this
88
+ by default. For an in-memory SQLite database, configure `AsyncAdaptedQueuePool` with
89
+ `pool_size=1` and `max_overflow=0`; `StaticPool` shares one live connection across
90
+ transactions and is rejected. Discarding an in-memory connection also discards its data.
91
+
92
+ For schema setup, supply a stable `schema_lock` name and run DDL on the yielded
93
+ connection. Integrations keep their own schema, retry policy, and commit confirmation.
94
+ `SqlTransaction` performs one attempt and never replays application work.
95
+
96
+ ## Documentation
97
+
98
+ [TinkerFin documentation](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/docs/en/index.md)
99
+
100
+ ## License
101
+
102
+ [Apache-2.0](https://github.com/tinkerfin-ai/tinkerfin-harness/blob/main/LICENSE)
@@ -0,0 +1,11 @@
1
+ tinkerfin_sqlalchemy/__init__.py,sha256=JTzqME2LhNnbBUX0N2bqEwp3fnZx1zdR-I_BWHyl_5g,583
2
+ tinkerfin_sqlalchemy/_settlement.py,sha256=KlAETDijrMturZKoGXcZR6SPEy7eSjWSMJbEN6rvyV8,5774
3
+ tinkerfin_sqlalchemy/capabilities.py,sha256=GUPwQOzW9demX4gpBNf1XQIP-DSb17CH9z7ByxN-VJU,3965
4
+ tinkerfin_sqlalchemy/errors.py,sha256=z09qckYsRPAsT6MZ1jJnW36zGR4jTokkuniHmCZeiAQ,1260
5
+ tinkerfin_sqlalchemy/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ tinkerfin_sqlalchemy/transaction.py,sha256=5QV3__fVQTEtWAu9UW9ukVpDbX9QnauH7kteQQ-RhGg,25299
7
+ tinkerfin_sqlalchemy-0.1.1.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
8
+ tinkerfin_sqlalchemy-0.1.1.dist-info/METADATA,sha256=44LaROp_HSNToYI797lGAxXQHRoxHeo-n4wfUGDYQOQ,3694
9
+ tinkerfin_sqlalchemy-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ tinkerfin_sqlalchemy-0.1.1.dist-info/top_level.txt,sha256=DHpaqBxnErj85Q0OzNQTk3vuPg9D9ysPFQRKXkOr_IM,21
11
+ tinkerfin_sqlalchemy-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ tinkerfin_sqlalchemy