sqlsaber 0.21.0__tar.gz → 0.22.0__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.

Potentially problematic release.


This version of sqlsaber might be problematic. Click here for more details.

Files changed (105) hide show
  1. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/PKG-INFO +1 -1
  2. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/changelog.md +10 -0
  3. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/pyproject.toml +1 -1
  4. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/database/connection.py +105 -12
  5. sqlsaber-0.22.0/tests/test_database/test_timeout.py +124 -0
  6. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/uv.lock +1 -1
  7. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.github/workflows/claude-code-review.yml +0 -0
  8. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.github/workflows/claude.yml +0 -0
  9. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.github/workflows/deploy-docs.yml +0 -0
  10. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.github/workflows/publish.yml +0 -0
  11. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.github/workflows/test.yml +0 -0
  12. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.gitignore +0 -0
  13. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/.python-version +0 -0
  14. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/AGENT.md +0 -0
  15. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/CLAUDE.md +0 -0
  16. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/LICENSE +0 -0
  17. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/README.md +0 -0
  18. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/.gitignore +0 -0
  19. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/.vscode/extensions.json +0 -0
  20. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/.vscode/launch.json +0 -0
  21. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/CLAUDE.md +0 -0
  22. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/astro.config.mjs +0 -0
  23. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/package-lock.json +0 -0
  24. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/package.json +0 -0
  25. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/public/CNAME +0 -0
  26. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/public/favicon.svg +0 -0
  27. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/assets/sqlsaber-hero.svg +0 -0
  28. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/authentication.mdx +0 -0
  29. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/database-setup.mdx +0 -0
  30. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/getting-started.mdx +0 -0
  31. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/memory.mdx +0 -0
  32. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/models.mdx +0 -0
  33. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/queries.mdx +0 -0
  34. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/guides/threads.md +0 -0
  35. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/index.mdx +0 -0
  36. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/installation.mdx +0 -0
  37. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content/docs/reference/commands.md +0 -0
  38. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/content.config.ts +0 -0
  39. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/src/styles/global.css +0 -0
  40. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/docs/tsconfig.json +0 -0
  41. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/legislators.db +0 -0
  42. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/pytest.ini +0 -0
  43. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/sqlsaber.gif +0 -0
  44. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/sqlsaber.svg +0 -0
  45. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/__init__.py +0 -0
  46. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/__main__.py +0 -0
  47. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/__init__.py +0 -0
  48. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/base.py +0 -0
  49. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/mcp.py +0 -0
  50. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/agents/pydantic_ai_agent.py +0 -0
  51. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/__init__.py +0 -0
  52. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/auth.py +0 -0
  53. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/commands.py +0 -0
  54. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/completers.py +0 -0
  55. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/database.py +0 -0
  56. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/display.py +0 -0
  57. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/interactive.py +0 -0
  58. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/memory.py +0 -0
  59. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/models.py +0 -0
  60. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/streaming.py +0 -0
  61. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/cli/threads.py +0 -0
  62. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/__init__.py +0 -0
  63. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/api_keys.py +0 -0
  64. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/auth.py +0 -0
  65. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/database.py +0 -0
  66. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/oauth_flow.py +0 -0
  67. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/oauth_tokens.py +0 -0
  68. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/providers.py +0 -0
  69. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/config/settings.py +0 -0
  70. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/database/__init__.py +0 -0
  71. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/database/resolver.py +0 -0
  72. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/database/schema.py +0 -0
  73. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/mcp/__init__.py +0 -0
  74. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/mcp/mcp.py +0 -0
  75. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/__init__.py +0 -0
  76. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/manager.py +0 -0
  77. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/memory/storage.py +0 -0
  78. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/threads/__init__.py +0 -0
  79. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/threads/storage.py +0 -0
  80. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/__init__.py +0 -0
  81. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/base.py +0 -0
  82. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/enums.py +0 -0
  83. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/instructions.py +0 -0
  84. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/registry.py +0 -0
  85. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/src/sqlsaber/tools/sql_tools.py +0 -0
  86. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/__init__.py +0 -0
  87. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/conftest.py +0 -0
  88. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_cli/__init__.py +0 -0
  89. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_cli/test_auth_reset.py +0 -0
  90. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_cli/test_commands.py +0 -0
  91. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_cli/test_threads.py +0 -0
  92. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_config/__init__.py +0 -0
  93. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_config/test_database.py +0 -0
  94. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_config/test_oauth.py +0 -0
  95. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_config/test_providers.py +0 -0
  96. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_config/test_settings.py +0 -0
  97. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_database/__init__.py +0 -0
  98. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_database/test_connection.py +0 -0
  99. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_database_resolver.py +0 -0
  100. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_threads_storage.py +0 -0
  101. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_tools/__init__.py +0 -0
  102. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_tools/test_base.py +0 -0
  103. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_tools/test_instructions.py +0 -0
  104. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_tools/test_registry.py +0 -0
  105. {sqlsaber-0.21.0 → sqlsaber-0.22.0}/tests/test_tools/test_sql_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sqlsaber
3
- Version: 0.21.0
3
+ Version: 0.22.0
4
4
  Summary: SQLsaber - Open-source agentic SQL assistant
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12
@@ -7,6 +7,16 @@ All notable changes to SQLsaber will be documented here.
7
7
 
8
8
  ### Unreleased
9
9
 
10
+ ### v0.22.0 - 2025-09-15
11
+
12
+ #### Added
13
+
14
+ - Query timeout protection to prevent runaway queries
15
+ - 30-second timeout applied to all database operations by default
16
+ - Both client-side and server-side timeout enforcement where supported (PostgreSQL, MySQL)
17
+ - Per-query timeout override parameter for edge cases
18
+ - Automatic rollback of transactions when queries timeout
19
+
10
20
  ### v0.21.0 - 2025-09-15
11
21
 
12
22
  #### Changed
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "sqlsaber"
3
- version = "0.21.0"
3
+ version = "0.22.0"
4
4
  description = "SQLsaber - Open-source agentic SQL assistant"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,5 +1,6 @@
1
1
  """Database connection management."""
2
2
 
3
+ import asyncio
3
4
  import ssl
4
5
  from abc import ABC, abstractmethod
5
6
  from pathlib import Path
@@ -10,6 +11,17 @@ import aiomysql
10
11
  import aiosqlite
11
12
  import asyncpg
12
13
 
14
+ # Default query timeout to prevent runaway queries
15
+ DEFAULT_QUERY_TIMEOUT = 30.0 # seconds
16
+
17
+
18
+ class QueryTimeoutError(RuntimeError):
19
+ """Exception raised when a query exceeds its timeout."""
20
+
21
+ def __init__(self, seconds: float):
22
+ self.timeout = seconds
23
+ super().__init__(f"Query exceeded timeout of {seconds}s")
24
+
13
25
 
14
26
  class BaseDatabaseConnection(ABC):
15
27
  """Abstract base class for database connections."""
@@ -29,11 +41,18 @@ class BaseDatabaseConnection(ABC):
29
41
  pass
30
42
 
31
43
  @abstractmethod
32
- async def execute_query(self, query: str, *args) -> list[dict[str, Any]]:
44
+ async def execute_query(
45
+ self, query: str, *args, timeout: float | None = None
46
+ ) -> list[dict[str, Any]]:
33
47
  """Execute a query and return results as list of dicts.
34
48
 
35
49
  All queries run in a transaction that is rolled back at the end,
36
50
  ensuring no changes are persisted to the database.
51
+
52
+ Args:
53
+ query: SQL query to execute
54
+ *args: Query parameters
55
+ timeout: Query timeout in seconds (overrides default_timeout)
37
56
  """
38
57
  pass
39
58
 
@@ -111,21 +130,40 @@ class PostgreSQLConnection(BaseDatabaseConnection):
111
130
  await self._pool.close()
112
131
  self._pool = None
113
132
 
114
- async def execute_query(self, query: str, *args) -> list[dict[str, Any]]:
133
+ async def execute_query(
134
+ self, query: str, *args, timeout: float | None = None
135
+ ) -> list[dict[str, Any]]:
115
136
  """Execute a query and return results as list of dicts.
116
137
 
117
138
  All queries run in a transaction that is rolled back at the end,
118
139
  ensuring no changes are persisted to the database.
119
140
  """
141
+ effective_timeout = timeout or DEFAULT_QUERY_TIMEOUT
120
142
  pool = await self.get_pool()
143
+
121
144
  async with pool.acquire() as conn:
122
145
  # Start a transaction that we'll always rollback
123
146
  transaction = conn.transaction()
124
147
  await transaction.start()
125
148
 
126
149
  try:
127
- rows = await conn.fetch(query, *args)
150
+ # Set server-side timeout if specified
151
+ if effective_timeout:
152
+ await conn.execute(
153
+ f"SET LOCAL statement_timeout = {int(effective_timeout * 1000)}"
154
+ )
155
+
156
+ # Execute query with client-side timeout
157
+ if effective_timeout:
158
+ rows = await asyncio.wait_for(
159
+ conn.fetch(query, *args), timeout=effective_timeout
160
+ )
161
+ else:
162
+ rows = await conn.fetch(query, *args)
163
+
128
164
  return [dict(row) for row in rows]
165
+ except asyncio.TimeoutError as exc:
166
+ raise QueryTimeoutError(effective_timeout or 0) from exc
129
167
  finally:
130
168
  # Always rollback to ensure no changes are committed
131
169
  await transaction.rollback()
@@ -216,21 +254,44 @@ class MySQLConnection(BaseDatabaseConnection):
216
254
  await self._pool.wait_closed()
217
255
  self._pool = None
218
256
 
219
- async def execute_query(self, query: str, *args) -> list[dict[str, Any]]:
257
+ async def execute_query(
258
+ self, query: str, *args, timeout: float | None = None
259
+ ) -> list[dict[str, Any]]:
220
260
  """Execute a query and return results as list of dicts.
221
261
 
222
262
  All queries run in a transaction that is rolled back at the end,
223
263
  ensuring no changes are persisted to the database.
224
264
  """
265
+ effective_timeout = timeout or DEFAULT_QUERY_TIMEOUT
225
266
  pool = await self.get_pool()
267
+
226
268
  async with pool.acquire() as conn:
227
269
  async with conn.cursor(aiomysql.DictCursor) as cursor:
228
270
  # Start transaction
229
271
  await conn.begin()
230
272
  try:
231
- await cursor.execute(query, args if args else None)
232
- rows = await cursor.fetchall()
273
+ # Set server-side timeout if specified
274
+ if effective_timeout:
275
+ await cursor.execute(
276
+ f"SET SESSION MAX_EXECUTION_TIME = {int(effective_timeout * 1000)}"
277
+ )
278
+
279
+ # Execute query with client-side timeout
280
+ if effective_timeout:
281
+ await asyncio.wait_for(
282
+ cursor.execute(query, args if args else None),
283
+ timeout=effective_timeout,
284
+ )
285
+ rows = await asyncio.wait_for(
286
+ cursor.fetchall(), timeout=effective_timeout
287
+ )
288
+ else:
289
+ await cursor.execute(query, args if args else None)
290
+ rows = await cursor.fetchall()
291
+
233
292
  return [dict(row) for row in rows]
293
+ except asyncio.TimeoutError as exc:
294
+ raise QueryTimeoutError(effective_timeout or 0) from exc
234
295
  finally:
235
296
  # Always rollback to ensure no changes are committed
236
297
  await conn.rollback()
@@ -252,12 +313,16 @@ class SQLiteConnection(BaseDatabaseConnection):
252
313
  """SQLite connections are created per query, no persistent pool to close."""
253
314
  pass
254
315
 
255
- async def execute_query(self, query: str, *args) -> list[dict[str, Any]]:
316
+ async def execute_query(
317
+ self, query: str, *args, timeout: float | None = None
318
+ ) -> list[dict[str, Any]]:
256
319
  """Execute a query and return results as list of dicts.
257
320
 
258
321
  All queries run in a transaction that is rolled back at the end,
259
322
  ensuring no changes are persisted to the database.
260
323
  """
324
+ effective_timeout = timeout or DEFAULT_QUERY_TIMEOUT
325
+
261
326
  async with aiosqlite.connect(self.database_path) as conn:
262
327
  # Enable row factory for dict-like access
263
328
  conn.row_factory = aiosqlite.Row
@@ -265,9 +330,22 @@ class SQLiteConnection(BaseDatabaseConnection):
265
330
  # Start transaction
266
331
  await conn.execute("BEGIN")
267
332
  try:
268
- cursor = await conn.execute(query, args if args else ())
269
- rows = await cursor.fetchall()
333
+ # Execute query with client-side timeout (SQLite has no server-side timeout)
334
+ if effective_timeout:
335
+ cursor = await asyncio.wait_for(
336
+ conn.execute(query, args if args else ()),
337
+ timeout=effective_timeout,
338
+ )
339
+ rows = await asyncio.wait_for(
340
+ cursor.fetchall(), timeout=effective_timeout
341
+ )
342
+ else:
343
+ cursor = await conn.execute(query, args if args else ())
344
+ rows = await cursor.fetchall()
345
+
270
346
  return [dict(row) for row in rows]
347
+ except asyncio.TimeoutError as exc:
348
+ raise QueryTimeoutError(effective_timeout or 0) from exc
271
349
  finally:
272
350
  # Always rollback to ensure no changes are committed
273
351
  await conn.rollback()
@@ -383,20 +461,35 @@ class CSVConnection(BaseDatabaseConnection):
383
461
  except Exception as e:
384
462
  raise ValueError(f"Error loading CSV file '{self.csv_path}': {str(e)}")
385
463
 
386
- async def execute_query(self, query: str, *args) -> list[dict[str, Any]]:
464
+ async def execute_query(
465
+ self, query: str, *args, timeout: float | None = None
466
+ ) -> list[dict[str, Any]]:
387
467
  """Execute a query and return results as list of dicts.
388
468
 
389
469
  All queries run in a transaction that is rolled back at the end,
390
470
  ensuring no changes are persisted to the database.
391
471
  """
472
+ effective_timeout = timeout or DEFAULT_QUERY_TIMEOUT
392
473
  conn = await self.get_pool()
393
474
 
394
475
  # Start transaction
395
476
  await conn.execute("BEGIN")
396
477
  try:
397
- cursor = await conn.execute(query, args if args else ())
398
- rows = await cursor.fetchall()
478
+ # Execute query with client-side timeout (CSV uses in-memory SQLite)
479
+ if effective_timeout:
480
+ cursor = await asyncio.wait_for(
481
+ conn.execute(query, args if args else ()), timeout=effective_timeout
482
+ )
483
+ rows = await asyncio.wait_for(
484
+ cursor.fetchall(), timeout=effective_timeout
485
+ )
486
+ else:
487
+ cursor = await conn.execute(query, args if args else ())
488
+ rows = await cursor.fetchall()
489
+
399
490
  return [dict(row) for row in rows]
491
+ except asyncio.TimeoutError as exc:
492
+ raise QueryTimeoutError(effective_timeout or 0) from exc
400
493
  finally:
401
494
  # Always rollback to ensure no changes are committed
402
495
  await conn.rollback()
@@ -0,0 +1,124 @@
1
+ """Tests for query timeout functionality."""
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from sqlsaber.database.connection import (
8
+ DEFAULT_QUERY_TIMEOUT,
9
+ DatabaseConnection,
10
+ QueryTimeoutError,
11
+ SQLiteConnection,
12
+ )
13
+
14
+
15
+ class TestQueryTimeout:
16
+ """Test query timeout functionality."""
17
+
18
+ def test_default_query_timeout_constant(self):
19
+ """Test that the default query timeout constant is defined."""
20
+ assert DEFAULT_QUERY_TIMEOUT == 30.0
21
+
22
+ @pytest.mark.asyncio
23
+ async def test_database_connection_creation(self):
24
+ """Test that DatabaseConnection can be created."""
25
+ connection_string = "sqlite:///:memory:"
26
+
27
+ conn = DatabaseConnection(connection_string)
28
+ assert conn.connection_string == connection_string
29
+
30
+ await conn.close()
31
+
32
+ @pytest.mark.asyncio
33
+ async def test_timeout_fallback_logic(self):
34
+ """Test timeout fallback: per-query > hardcoded default."""
35
+ connection_string = "sqlite:///:memory:"
36
+
37
+ # Test with no timeout - should use hardcoded default
38
+ conn1 = SQLiteConnection(connection_string)
39
+ result1 = await conn1.execute_query("SELECT 1 as test")
40
+ assert len(result1) == 1
41
+ await conn1.close()
42
+
43
+ # Test with per-query timeout override
44
+ conn2 = SQLiteConnection(connection_string)
45
+ result2 = await conn2.execute_query("SELECT 2 as test", timeout=60.0)
46
+ assert len(result2) == 1
47
+ await conn2.close()
48
+
49
+ @pytest.mark.asyncio
50
+ async def test_sqlite_timeout_functionality(self):
51
+ """Test timeout functionality with SQLite in-memory database."""
52
+ connection_string = "sqlite:///:memory:"
53
+ timeout = 1.0 # 1 second timeout
54
+
55
+ conn = SQLiteConnection(connection_string)
56
+
57
+ try:
58
+ # This query should complete quickly
59
+ result = await conn.execute_query("SELECT 1 as test", timeout=timeout)
60
+ assert len(result) == 1
61
+ assert result[0]["test"] == 1
62
+
63
+ # Test with no explicit timeout (uses default)
64
+ result2 = await conn.execute_query("SELECT 2 as test")
65
+ assert len(result2) == 1
66
+ assert result2[0]["test"] == 2
67
+
68
+ finally:
69
+ await conn.close()
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_timeout_error_handling(self):
73
+ """Test that timeout errors are properly raised with asyncio.TimeoutError."""
74
+ connection_string = "sqlite:///:memory:"
75
+
76
+ conn = SQLiteConnection(connection_string)
77
+
78
+ try:
79
+ # Create a mock query that artificially delays to trigger timeout
80
+ async def mock_slow_execute_query(query, *args, timeout=None):
81
+ # Simulate a slow query by sleeping longer than timeout
82
+ await asyncio.sleep(0.1) # 100ms, longer than 1ms timeout
83
+ return [{"result": "should not reach here"}]
84
+
85
+ # Replace the execute_query method temporarily
86
+ conn.execute_query = mock_slow_execute_query
87
+
88
+ # This should raise an asyncio.TimeoutError which gets converted to QueryTimeoutError
89
+ with pytest.raises(asyncio.TimeoutError):
90
+ await asyncio.wait_for(conn.execute_query("SELECT 1"), timeout=0.001)
91
+
92
+ finally:
93
+ await conn.close()
94
+
95
+ def test_query_timeout_error_message(self):
96
+ """Test QueryTimeoutError message formatting."""
97
+ timeout = 30.5
98
+ error = QueryTimeoutError(timeout)
99
+
100
+ assert str(error) == "Query exceeded timeout of 30.5s"
101
+ assert error.timeout == 30.5
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_timeout_precedence(self):
105
+ """Test that query-level timeout overrides hardcoded default."""
106
+ connection_string = "sqlite:///:memory:"
107
+ query_timeout = 45.0
108
+
109
+ conn = SQLiteConnection(connection_string)
110
+
111
+ try:
112
+ # The query should use the query-specific timeout
113
+ # We can't easily test the actual timeout without creating a slow query,
114
+ # but we can verify the parameter is accepted
115
+ result = await conn.execute_query("SELECT 1 as test", timeout=query_timeout)
116
+ assert len(result) == 1
117
+ assert result[0]["test"] == 1
118
+
119
+ finally:
120
+ await conn.close()
121
+
122
+
123
+ if __name__ == "__main__":
124
+ pytest.main([__file__])
@@ -1910,7 +1910,7 @@ wheels = [
1910
1910
 
1911
1911
  [[package]]
1912
1912
  name = "sqlsaber"
1913
- version = "0.21.0"
1913
+ version = "0.22.0"
1914
1914
  source = { editable = "." }
1915
1915
  dependencies = [
1916
1916
  { name = "aiomysql" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes