zach commited on
Commit
40403f3
·
1 Parent(s): 96f91bb

Update database layer and application logic for db interaction to be async

Browse files
pyproject.toml CHANGED
@@ -6,9 +6,11 @@ readme = "README.md"
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "anthropic>=0.45.2",
 
9
  "elevenlabs>=1.50.7",
10
  "gradio>=5.15.0",
11
- "psycopg2>=2.9.0",
 
12
  "python-dotenv>=1.0.1",
13
  "requests>=2.32.3",
14
  "sqlalchemy>=2.0.0",
 
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "anthropic>=0.45.2",
9
+ "asyncpg>=0.28.0",
10
  "elevenlabs>=1.50.7",
11
  "gradio>=5.15.0",
12
+ "greenlet>=2.0.0",
13
+ "httpx>=0.24.1",
14
  "python-dotenv>=1.0.1",
15
  "requests>=2.32.3",
16
  "sqlalchemy>=2.0.0",
src/app.py CHANGED
@@ -9,6 +9,8 @@ Users can compare the outputs and vote for their favorite in an interactive UI.
9
  """
10
 
11
  # Standard Library Imports
 
 
12
  import time
13
  from concurrent.futures import ThreadPoolExecutor
14
  from typing import Tuple
@@ -20,7 +22,7 @@ import gradio as gr
20
  from src import constants
21
  from src.config import Config, logger
22
  from src.custom_types import Option, OptionMap
23
- from src.database.database import DBSessionMaker
24
  from src.integrations import (
25
  AnthropicError,
26
  ElevenLabsError,
@@ -41,9 +43,9 @@ from src.utils import (
41
 
42
  class App:
43
  config: Config
44
- db_session_maker: DBSessionMaker
45
 
46
- def __init__(self, config: Config, db_session_maker: DBSessionMaker):
47
  self.config = config
48
  self.db_session_maker = db_session_maker
49
 
@@ -188,6 +190,47 @@ class App:
188
  logger.error(f"Unexpected error during TTS generation: {e}")
189
  raise gr.Error("An unexpected error occurred. Please try again later.")
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  def _vote(
192
  self,
193
  vote_submitted: bool,
@@ -203,19 +246,14 @@ class App:
203
  Args:
204
  vote_submitted (bool): True if a vote was already submitted.
205
  option_map (OptionMap): A dictionary mapping option labels to their details.
206
- Expected structure:
207
- {
208
- 'Option A': 'Hume AI',
209
- 'Option B': 'ElevenLabs',
210
- }
211
  clicked_option_button (str): The button that was clicked.
212
 
213
  Returns:
214
  A tuple of:
215
- - A boolean indicating if the vote was accepted.
216
- - A dict update for the selected vote button (showing provider and trophy emoji).
217
- - A dict update for the unselected vote button (showing provider).
218
- - A dict update for enabling vote interactions.
219
  """
220
  if not option_map or vote_submitted:
221
  return gr.skip(), gr.skip(), gr.skip(), gr.skip()
@@ -224,16 +262,19 @@ class App:
224
  selected_provider = option_map[selected_option]["provider"]
225
  other_provider = option_map[other_option]["provider"]
226
 
227
- # Report voting results to be persisted to results DB.
228
- submit_voting_results(
229
- option_map,
230
- selected_option,
231
- text_modified,
232
- character_description,
233
- text,
234
- self.db_session_maker,
235
- self.config,
 
 
236
  )
 
237
 
238
  # Build button text, displaying the provider and voice name, appending the trophy emoji to the selected option.
239
  selected_label = f"{selected_provider} {constants.TROPHY_EMOJI}"
 
9
  """
10
 
11
  # Standard Library Imports
12
+ import asyncio
13
+ import threading
14
  import time
15
  from concurrent.futures import ThreadPoolExecutor
16
  from typing import Tuple
 
22
  from src import constants
23
  from src.config import Config, logger
24
  from src.custom_types import Option, OptionMap
25
+ from src.database.database import AsyncDBSessionMaker
26
  from src.integrations import (
27
  AnthropicError,
28
  ElevenLabsError,
 
43
 
44
  class App:
45
  config: Config
46
+ db_session_maker: AsyncDBSessionMaker
47
 
48
+ def __init__(self, config: Config, db_session_maker: AsyncDBSessionMaker):
49
  self.config = config
50
  self.db_session_maker = db_session_maker
51
 
 
190
  logger.error(f"Unexpected error during TTS generation: {e}")
191
  raise gr.Error("An unexpected error occurred. Please try again later.")
192
 
193
+
194
+ def _background_submit_vote(
195
+ self,
196
+ option_map: OptionMap,
197
+ selected_option: constants.OptionKey,
198
+ text_modified: bool,
199
+ character_description: str,
200
+ text: str,
201
+ ) -> None:
202
+ """
203
+ Runs the vote submission in a background thread.
204
+ Creates a new event loop and runs the async submit_voting_results function in it.
205
+
206
+ Args:
207
+ Same as submit_voting_results
208
+
209
+ Returns:
210
+ None
211
+ """
212
+ try:
213
+ # Create a new event loop for this thread
214
+ loop = asyncio.new_event_loop()
215
+ asyncio.set_event_loop(loop)
216
+
217
+ # Run the async function in the new loop
218
+ loop.run_until_complete(submit_voting_results(
219
+ option_map,
220
+ selected_option,
221
+ text_modified,
222
+ character_description,
223
+ text,
224
+ self.db_session_maker,
225
+ self.config,
226
+ ))
227
+ except Exception as e:
228
+ logger.error(f"Error in background vote submission thread: {e}", exc_info=True)
229
+ finally:
230
+ # Close the loop when done
231
+ loop.close()
232
+
233
+
234
  def _vote(
235
  self,
236
  vote_submitted: bool,
 
246
  Args:
247
  vote_submitted (bool): True if a vote was already submitted.
248
  option_map (OptionMap): A dictionary mapping option labels to their details.
 
 
 
 
 
249
  clicked_option_button (str): The button that was clicked.
250
 
251
  Returns:
252
  A tuple of:
253
+ - A boolean indicating if the vote was accepted.
254
+ - A dict update for the selected vote button (showing provider and trophy emoji).
255
+ - A dict update for the unselected vote button (showing provider).
256
+ - A dict update for enabling vote interactions.
257
  """
258
  if not option_map or vote_submitted:
259
  return gr.skip(), gr.skip(), gr.skip(), gr.skip()
 
262
  selected_provider = option_map[selected_option]["provider"]
263
  other_provider = option_map[other_option]["provider"]
264
 
265
+ # Start a background thread for the database operation
266
+ thread = threading.Thread(
267
+ target=self._background_submit_vote,
268
+ args=(
269
+ option_map,
270
+ selected_option,
271
+ text_modified,
272
+ character_description,
273
+ text,
274
+ ),
275
+ daemon=True
276
  )
277
+ thread.start()
278
 
279
  # Build button text, displaying the provider and voice name, appending the trophy emoji to the selected option.
280
  selected_label = f"{selected_provider} {constants.TROPHY_EMOJI}"
src/database/__init__.py CHANGED
@@ -1,9 +1,9 @@
1
  from .crud import create_vote
2
- from .database import Base, DBSessionMaker, engine, init_db
3
 
4
  __all__ = [
 
5
  "Base",
6
- "DBSessionMaker",
7
  "create_vote",
8
  "engine",
9
  "init_db",
 
1
  from .crud import create_vote
2
+ from .database import AsyncDBSessionMaker, Base, engine, init_db
3
 
4
  __all__ = [
5
+ "AsyncDBSessionMaker",
6
  "Base",
 
7
  "create_vote",
8
  "engine",
9
  "init_db",
src/database/crud.py CHANGED
@@ -6,19 +6,19 @@ Since vote records are never updated or deleted, only functions to create and re
6
  """
7
 
8
  # Third-Party Library Imports
9
- from sqlalchemy.orm import Session
10
 
11
  # Local Application Imports
12
  from src.custom_types import VotingResults
13
  from src.database.models import VoteResult
14
 
15
 
16
- def create_vote(db: Session, vote_data: VotingResults) -> VoteResult:
17
  """
18
  Create a new vote record in the database based on the given VotingResults data.
19
 
20
  Args:
21
- db (Session): The SQLAlchemy database session.
22
  vote_data (VotingResults): The vote data to persist.
23
 
24
  Returns:
@@ -38,9 +38,9 @@ def create_vote(db: Session, vote_data: VotingResults) -> VoteResult:
38
  )
39
  db.add(vote)
40
  try:
41
- db.commit()
42
  except Exception as e:
43
- db.rollback()
44
  raise e
45
- db.refresh(vote)
46
  return vote
 
6
  """
7
 
8
  # Third-Party Library Imports
9
+ from sqlalchemy.ext.asyncio import AsyncSession
10
 
11
  # Local Application Imports
12
  from src.custom_types import VotingResults
13
  from src.database.models import VoteResult
14
 
15
 
16
+ async def create_vote(db: AsyncSession, vote_data: VotingResults) -> VoteResult:
17
  """
18
  Create a new vote record in the database based on the given VotingResults data.
19
 
20
  Args:
21
+ db (AsyncSession): The SQLAlchemy async database session.
22
  vote_data (VotingResults): The vote data to persist.
23
 
24
  Returns:
 
38
  )
39
  db.add(vote)
40
  try:
41
+ await db.commit()
42
  except Exception as e:
43
+ await db.rollback()
44
  raise e
45
+ await db.refresh(vote)
46
  return vote
src/database/database.py CHANGED
@@ -12,8 +12,8 @@ If no DATABASE_URL environment variable is set, then create a dummy database to
12
  from typing import Callable, Optional
13
 
14
  # Third-Party Library Imports
15
- from sqlalchemy import Engine, create_engine
16
- from sqlalchemy.orm import DeclarativeBase, sessionmaker
17
 
18
  # Local Application Imports
19
  from src.config import Config
@@ -24,44 +24,44 @@ class Base(DeclarativeBase):
24
  pass
25
 
26
 
27
- engine: Optional[Engine] = None
28
 
29
 
30
- class DummySession:
31
  is_dummy = True # Flag to indicate this is a dummy session.
32
 
33
- def __enter__(self):
34
  return self
35
 
36
- def __exit__(self, exc_type, exc_value, traceback):
37
  pass
38
 
39
- def add(self, _instance, _warn=True):
40
  # No-op: simply ignore adding the instance.
41
  pass
42
 
43
- def commit(self):
44
  # Raise an exception to simulate failure when attempting a write.
45
  raise RuntimeError("DummySession does not support commit operations.")
46
 
47
- def refresh(self, _instance):
48
  # Raise an exception to simulate failure when attempting to refresh.
49
  raise RuntimeError("DummySession does not support refresh operations.")
50
 
51
- def rollback(self):
52
  # No-op: there's nothing to roll back.
53
  pass
54
 
55
- def close(self):
56
  # No-op: nothing to close.
57
  pass
58
 
59
 
60
- # DBSessionMaker is either a sessionmaker instance or a callable that returns a DummySession.
61
- DBSessionMaker = sessionmaker | Callable[[], DummySession]
62
 
63
 
64
- def init_db(config: Config) -> DBSessionMaker:
65
  """
66
  Initialize the database engine and return a session factory based on the provided configuration.
67
 
@@ -72,27 +72,37 @@ def init_db(config: Config) -> DBSessionMaker:
72
  config (Config): The application configuration.
73
 
74
  Returns:
75
- DBSessionMaker: A sessionmaker bound to the engine, or a dummy session factory.
76
  """
77
  # ruff doesn't like setting global variables, but this is practical here
78
  global engine # noqa
79
 
 
 
 
 
 
 
 
80
  if config.app_env == "prod":
81
  # In production, a valid DATABASE_URL is required.
82
  if not config.database_url:
83
  raise ValueError("DATABASE_URL must be set in production!")
84
- engine = create_engine(config.database_url)
85
- return sessionmaker(bind=engine)
 
86
 
87
  # In development, if a DATABASE_URL is provided, use it.
88
  if config.database_url:
89
- engine = create_engine(config.database_url)
90
- return sessionmaker(bind=engine)
 
91
 
92
  # No DATABASE_URL is provided; use a DummySession that does nothing.
93
  engine = None
94
 
95
- def dummy_session_factory() -> DummySession:
96
- return DummySession()
 
 
97
 
98
- return dummy_session_factory
 
12
  from typing import Callable, Optional
13
 
14
  # Third-Party Library Imports
15
+ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
16
+ from sqlalchemy.orm import DeclarativeBase
17
 
18
  # Local Application Imports
19
  from src.config import Config
 
24
  pass
25
 
26
 
27
+ engine: Optional[AsyncEngine] = None
28
 
29
 
30
+ class AsyncDummySession:
31
  is_dummy = True # Flag to indicate this is a dummy session.
32
 
33
+ async def __enter__(self):
34
  return self
35
 
36
+ async def __exit__(self, exc_type, exc_value, traceback):
37
  pass
38
 
39
+ async def add(self, _instance, _warn=True):
40
  # No-op: simply ignore adding the instance.
41
  pass
42
 
43
+ async def commit(self):
44
  # Raise an exception to simulate failure when attempting a write.
45
  raise RuntimeError("DummySession does not support commit operations.")
46
 
47
+ async def refresh(self, _instance):
48
  # Raise an exception to simulate failure when attempting to refresh.
49
  raise RuntimeError("DummySession does not support refresh operations.")
50
 
51
+ async def rollback(self):
52
  # No-op: there's nothing to roll back.
53
  pass
54
 
55
+ async def close(self):
56
  # No-op: nothing to close.
57
  pass
58
 
59
 
60
+ # AsyncDBSessionMaker is either a async_sessionmaker instance or a callable that returns a AsyncDummySession.
61
+ AsyncDBSessionMaker = async_sessionmaker | Callable[[], AsyncDummySession]
62
 
63
 
64
+ def init_db(config: Config) -> AsyncDBSessionMaker:
65
  """
66
  Initialize the database engine and return a session factory based on the provided configuration.
67
 
 
72
  config (Config): The application configuration.
73
 
74
  Returns:
75
+ AsyncDBSessionMaker: A sessionmaker bound to the engine, or a dummy session factory.
76
  """
77
  # ruff doesn't like setting global variables, but this is practical here
78
  global engine # noqa
79
 
80
+ # Convert standard PostgreSQL URL to async format
81
+ def convert_to_async_url(url: str) -> str:
82
+ # Convert postgresql:// to postgresql+asyncpg://
83
+ if url.startswith('postgresql://'):
84
+ return url.replace('postgresql://', 'postgresql+asyncpg://', 1)
85
+ return url
86
+
87
  if config.app_env == "prod":
88
  # In production, a valid DATABASE_URL is required.
89
  if not config.database_url:
90
  raise ValueError("DATABASE_URL must be set in production!")
91
+ async_db_url = convert_to_async_url(config.database_url)
92
+ engine = create_async_engine(async_db_url)
93
+ return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
94
 
95
  # In development, if a DATABASE_URL is provided, use it.
96
  if config.database_url:
97
+ async_db_url = convert_to_async_url(config.database_url)
98
+ engine = create_async_engine(async_db_url)
99
+ return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
100
 
101
  # No DATABASE_URL is provided; use a DummySession that does nothing.
102
  engine = None
103
 
104
+ def async_dummy_session_factory() -> AsyncDummySession:
105
+ return AsyncDummySession()
106
+
107
+ return async_dummy_session_factory
108
 
 
src/integrations/anthropic_api.py CHANGED
@@ -44,8 +44,6 @@ Your absolute priority is delivering complete, untruncated responses within stri
44
  words. NEVER include newlines in the output
45
  - Make sure that all responses are complete thoughts, not fragments, and have clear beginnings and endings
46
  - The text must sound human-like, prosodic, expressive, conversational. Avoid generic AI-like words like "delve".
47
- - Use the utterances "uh", "um", "hm", "woah", or "like" for expressivity in conversational text. Use these naturally
48
- within the sentence. Never use them at the very end of a sentence.
49
  - Avoid any short utterances at the end of the sentence - like ", hm?" or "oh" at the end. Avoid these short, isolated
50
  utterances because they are difficult for our TTS system to speak.
51
  - Avoid words that are overly long, very rare, or difficult to pronounce. For example, avoid "eureka", or "schnell",
 
44
  words. NEVER include newlines in the output
45
  - Make sure that all responses are complete thoughts, not fragments, and have clear beginnings and endings
46
  - The text must sound human-like, prosodic, expressive, conversational. Avoid generic AI-like words like "delve".
 
 
47
  - Avoid any short utterances at the end of the sentence - like ", hm?" or "oh" at the end. Avoid these short, isolated
48
  utterances because they are difficult for our TTS system to speak.
49
  - Avoid words that are overly long, very rare, or difficult to pronounce. For example, avoid "eureka", or "schnell",
src/scripts/init_db.py CHANGED
@@ -2,19 +2,55 @@
2
  init_db.py
3
 
4
  This script initializes the database by creating all tables defined in the ORM models.
5
- Run this script once to create your tables in the PostgreSQL database.
 
6
  """
7
 
 
 
 
8
  # Local Application Imports
9
- from src.config import logger
10
  from src.database.database import engine
11
  from src.database.models import Base
12
 
13
 
14
- def init_db():
15
- Base.metadata.create_all(bind=engine)
16
- logger.info("Database tables created successfully.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  if __name__ == "__main__":
20
- init_db()
 
2
  init_db.py
3
 
4
  This script initializes the database by creating all tables defined in the ORM models.
5
+ It uses async SQLAlchemy operations to create tables in the PostgreSQL database.
6
+ Run this script once to set up your database schema.
7
  """
8
 
9
+ # Standard Library Imports
10
+ import asyncio
11
+
12
  # Local Application Imports
13
+ from src.config import Config, logger
14
  from src.database.database import engine
15
  from src.database.models import Base
16
 
17
 
18
+ async def init_db_async():
19
+ """
20
+ Asynchronously create all database tables defined in the ORM models.
21
+
22
+ This function connects to the database using the configured async engine
23
+ and creates all tables that are mapped to SQLAlchemy models derived from
24
+ the Base class. It uses SQLAlchemy's create_all method with the async
25
+ engine context.
26
+
27
+ Returns:
28
+ None
29
+ """
30
+ async with engine.begin() as conn:
31
+ # In SQLAlchemy 2.0 with async, we use the connection directly
32
+ await conn.run_sync(Base.metadata.create_all)
33
+
34
+ logger.info("Database tables created successfully using async SQLAlchemy.")
35
+
36
+
37
+ def main():
38
+ """
39
+ Main entry point for the database initialization script.
40
+
41
+ This function creates the configuration, ensures the async engine is
42
+ initialized, and runs the async initialization function within an
43
+ event loop.
44
+
45
+ Returns:
46
+ None
47
+ """
48
+ # Make sure config is loaded first to initialize the engine
49
+ Config.get()
50
+
51
+ # Run the async initialization function
52
+ asyncio.run(init_db_async())
53
 
54
 
55
  if __name__ == "__main__":
56
+ main()
src/scripts/test_db.py CHANGED
@@ -2,11 +2,12 @@
2
  test_db.py
3
 
4
  This script verifies the database connection for the Expressive TTS Arena project.
5
- It attempts to connect to the PostgreSQL database using SQLAlchemy and executes a simple query.
 
6
 
7
  Functionality:
8
  - Loads the database connection from `database.py`.
9
- - Attempts to establish a connection to the database.
10
  - Executes a test query (`SELECT 1`) to confirm connectivity.
11
  - Prints a success message if the connection is valid.
12
  - Prints an error message if the connection fails.
@@ -22,33 +23,73 @@ Troubleshooting:
22
  - Ensure the `.env` file contains a valid `DATABASE_URL`.
23
  - Check that the database server is running and accessible.
24
  - Verify PostgreSQL credentials and network settings.
25
-
26
  """
27
 
28
  # Standard Library Imports
 
29
  import sys
30
 
31
  # Third-Party Library Imports
32
  from sqlalchemy import text
33
- from sqlalchemy.exc import OperationalError
34
 
35
  # Local Application Imports
36
- from src.config import logger
37
- from src.database import engine
 
 
 
 
 
38
 
 
 
 
39
 
40
- def main() -> None:
 
 
41
  if engine is None:
42
  logger.error("No valid database engine configured.")
43
- sys.exit(1)
44
 
45
  try:
46
- with engine.connect() as conn:
47
- conn.execute(text("SELECT 1"))
48
- logger.info("Database connection successful!")
49
- except OperationalError as e:
50
- logger.error(f"Database connection failed: {e}")
51
- sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
 
53
 
54
  if __name__ == "__main__":
 
2
  test_db.py
3
 
4
  This script verifies the database connection for the Expressive TTS Arena project.
5
+ It attempts to connect to the PostgreSQL database using async SQLAlchemy and executes
6
+ a simple query to confirm connectivity.
7
 
8
  Functionality:
9
  - Loads the database connection from `database.py`.
10
+ - Attempts to establish an async connection to the database.
11
  - Executes a test query (`SELECT 1`) to confirm connectivity.
12
  - Prints a success message if the connection is valid.
13
  - Prints an error message if the connection fails.
 
23
  - Ensure the `.env` file contains a valid `DATABASE_URL`.
24
  - Check that the database server is running and accessible.
25
  - Verify PostgreSQL credentials and network settings.
 
26
  """
27
 
28
  # Standard Library Imports
29
+ import asyncio
30
  import sys
31
 
32
  # Third-Party Library Imports
33
  from sqlalchemy import text
 
34
 
35
  # Local Application Imports
36
+ from src.config import Config, logger
37
+ from src.database.database import engine, init_db
38
+
39
+
40
+ async def test_connection_async():
41
+ """
42
+ Asynchronously test the database connection.
43
 
44
+ This function attempts to connect to the database using the configured
45
+ async engine and execute a simple SELECT query. It logs success or failure
46
+ messages accordingly.
47
 
48
+ Returns:
49
+ bool: True if the connection was successful, False otherwise.
50
+ """
51
  if engine is None:
52
  logger.error("No valid database engine configured.")
53
+ return False
54
 
55
  try:
56
+ # Create a new async session
57
+ async with engine.connect() as conn:
58
+ # Execute a simple query to verify connectivity
59
+ result = await conn.execute(text("SELECT 1"))
60
+ # Fetch the result to make sure the query completes
61
+ await result.fetchone()
62
+
63
+ logger.info("Async database connection successful!")
64
+ return True
65
+
66
+ except Exception as e:
67
+ logger.error(f"Async database connection failed: {e}")
68
+ return False
69
+
70
+
71
+ def main():
72
+ """
73
+ Main entry point for the database connection test script.
74
+
75
+ This function creates the configuration, initializes the database engine,
76
+ and runs the async test function within an event loop. It exits with an
77
+ appropriate system exit code based on the test result.
78
+
79
+ Returns:
80
+ None
81
+ """
82
+ # Make sure config is loaded first to initialize the engine
83
+ config = Config.get()
84
+
85
+ # Initialize the database engine
86
+ init_db(config)
87
+
88
+ # Run the async test function
89
+ success = asyncio.run(test_connection_async())
90
+
91
+ # Exit with an appropriate status code
92
+ sys.exit(0 if success else 1)
93
 
94
 
95
  if __name__ == "__main__":
src/utils.py CHANGED
@@ -14,7 +14,8 @@ import time
14
  from pathlib import Path
15
  from typing import Tuple, cast
16
 
17
- from sqlalchemy.orm import Session
 
18
 
19
  # Local Application Imports
20
  from src import constants
@@ -28,7 +29,7 @@ from src.custom_types import (
28
  VotingResults,
29
  )
30
  from src.database import crud
31
- from src.database.database import DBSessionMaker
32
 
33
 
34
  def truncate_text(text: str, max_length: int = 50) -> str:
@@ -301,107 +302,98 @@ def _log_voting_results(voting_results: VotingResults) -> None:
301
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
302
 
303
 
304
- def _handle_vote_failure(
305
- e: Exception,
306
  voting_results: VotingResults,
307
- is_dummy_db_session: bool,
308
- config: Config,
309
  ) -> None:
310
  """
311
- Handles logging when creating a vote record fails.
312
-
313
- In production (or in dev with a real session):
314
- - Logs the error (with full traceback in prod) and the voting results.
315
- - In production, re-raises the exception.
316
-
317
- In development with a dummy session:
318
- - Only logs the voting results.
319
- """
320
-
321
- if config.app_env == "prod" or (config.app_env == "dev" and not is_dummy_db_session):
322
- logger.error("Failed to create vote record: %s", e, exc_info=(config.app_env == "prod"))
323
- _log_voting_results(voting_results)
324
- if config.app_env == "prod":
325
- raise e
326
- else:
327
- # Dev mode with a dummy session: only log the voting results.
328
- _log_voting_results(voting_results)
329
-
330
-
331
- def _persist_vote(db_session_maker: DBSessionMaker, voting_results: VotingResults, config: Config) -> None:
332
- """
333
- Persist a vote record in the database and handle potential failures.
334
-
335
- This function obtains a database session using the provided session maker and attempts
336
- to create a vote record using the specified voting results. If the session is identified
337
- as a dummy session, it logs a success message and outputs the voting results. If an error
338
- occurs during vote creation, the function delegates error handling to _handle_vote_failure.
339
- On successful vote creation, it logs the success and, when running in a development environment,
340
- logs the full voting results for debugging purposes. In all cases, the database session is
341
- properly closed after the operation.
342
 
343
  Args:
344
- db_session_maker (DBSessionMaker): A callable that returns a new database session.
345
  voting_results (VotingResults): A dictionary containing the details of the vote to persist.
346
  config (Config): The application configuration, used to determine environment-specific behavior.
 
 
 
347
  """
 
 
 
348
 
349
- db = db_session_maker()
350
- is_dummy_db_session = getattr(db, "is_dummy", False)
351
- if is_dummy_db_session:
352
  logger.info("Vote record created successfully.")
353
  _log_voting_results(voting_results)
 
 
 
354
  try:
355
- crud.create_vote(cast(Session, db), voting_results)
356
- except Exception as e:
357
- _handle_vote_failure(e, voting_results, is_dummy_db_session, config)
358
- else:
359
  logger.info("Vote record created successfully.")
360
  if config.app_env == "dev":
361
  _log_voting_results(voting_results)
 
 
 
 
 
362
  finally:
363
- db.close()
 
364
 
365
 
366
- def submit_voting_results(
367
  option_map: OptionMap,
368
  selected_option: OptionKey,
369
  text_modified: bool,
370
  character_description: str,
371
  text: str,
372
- db_session_maker: DBSessionMaker,
373
  config: Config,
374
  ) -> None:
375
  """
376
- Constructs the voting results dictionary from the provided inputs,
377
- logs it, persists a new vote record in the database, and returns the record.
378
 
379
  Args:
380
  option_map (OptionMap): Mapping of comparison data and TTS options.
381
- selected_option (str): The option selected by the user.
382
- text_modified (bool): Indicates whether the text was modified.
383
- character_description (str): Description of the voice/character.
384
- text (str): The text associated with the TTS generation.
385
- """
 
386
 
387
- provider_a: TTSProviderName = option_map[constants.OPTION_A_KEY]["provider"]
388
- provider_b: TTSProviderName = option_map[constants.OPTION_B_KEY]["provider"]
389
- comparison_type: ComparisonType = _determine_comparison_type(provider_a, provider_b)
390
-
391
- voting_results: VotingResults = {
392
- "comparison_type": comparison_type,
393
- "winning_provider": option_map[selected_option]["provider"],
394
- "winning_option": selected_option,
395
- "option_a_provider": provider_a,
396
- "option_b_provider": provider_b,
397
- "option_a_generation_id": option_map[constants.OPTION_A_KEY]["generation_id"],
398
- "option_b_generation_id": option_map[constants.OPTION_B_KEY]["generation_id"],
399
- "character_description": character_description,
400
- "text": text,
401
- "is_custom_text": text_modified,
402
- }
 
 
 
 
 
 
 
403
 
404
- _persist_vote(db_session_maker, voting_results, config)
 
 
 
405
 
406
 
407
  def validate_env_var(var_name: str) -> str:
 
14
  from pathlib import Path
15
  from typing import Tuple, cast
16
 
17
+ # Third-Party Library Imports
18
+ from sqlalchemy.ext.asyncio import AsyncSession
19
 
20
  # Local Application Imports
21
  from src import constants
 
29
  VotingResults,
30
  )
31
  from src.database import crud
32
+ from src.database.database import AsyncDBSessionMaker
33
 
34
 
35
  def truncate_text(text: str, max_length: int = 50) -> str:
 
302
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
303
 
304
 
305
+ async def _persist_vote(
306
+ db_session_maker: AsyncDBSessionMaker,
307
  voting_results: VotingResults,
308
+ config: Config
 
309
  ) -> None:
310
  """
311
+ Asynchronously persist a vote record in the database and handle potential failures.
312
+ Designed to work safely in a background task context.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
 
314
  Args:
315
+ db_session_maker (AsyncDBSessionMaker): A callable that returns a new async database session.
316
  voting_results (VotingResults): A dictionary containing the details of the vote to persist.
317
  config (Config): The application configuration, used to determine environment-specific behavior.
318
+
319
+ Returns:
320
+ None
321
  """
322
+ # Create session
323
+ session = db_session_maker()
324
+ is_dummy_session = getattr(session, "is_dummy", False)
325
 
326
+ if is_dummy_session:
 
 
327
  logger.info("Vote record created successfully.")
328
  _log_voting_results(voting_results)
329
+ await session.close()
330
+ return
331
+
332
  try:
333
+ await crud.create_vote(cast(AsyncSession, session), voting_results)
 
 
 
334
  logger.info("Vote record created successfully.")
335
  if config.app_env == "dev":
336
  _log_voting_results(voting_results)
337
+ except Exception as e:
338
+ # Log the error with traceback in production, without traceback in dev
339
+ logger.error(f"Failed to create vote record: {e}",
340
+ exc_info=(config.app_env == "prod"))
341
+ _log_voting_results(voting_results)
342
  finally:
343
+ # Always ensure the session is closed
344
+ await session.close()
345
 
346
 
347
+ async def submit_voting_results(
348
  option_map: OptionMap,
349
  selected_option: OptionKey,
350
  text_modified: bool,
351
  character_description: str,
352
  text: str,
353
+ db_session_maker: AsyncDBSessionMaker,
354
  config: Config,
355
  ) -> None:
356
  """
357
+ Asynchronously constructs the voting results dictionary and persists a new vote record.
358
+ Designed to run as a background task, handling all exceptions internally.
359
 
360
  Args:
361
  option_map (OptionMap): Mapping of comparison data and TTS options.
362
+ selected_option (OptionKey): The option selected by the user.
363
+ text_modified (bool): Indicates whether the text was modified from the original generated text.
364
+ character_description (str): Description of the voice/character used for TTS generation.
365
+ text (str): The text that was synthesized into speech.
366
+ db_session_maker (AsyncDBSessionMaker): Factory function for creating async database sessions.
367
+ config (Config): Application configuration containing environment settings.
368
 
369
+ Returns:
370
+ None
371
+ """
372
+ try:
373
+ provider_a: TTSProviderName = option_map[constants.OPTION_A_KEY]["provider"]
374
+ provider_b: TTSProviderName = option_map[constants.OPTION_B_KEY]["provider"]
375
+
376
+ comparison_type: ComparisonType = _determine_comparison_type(provider_a, provider_b)
377
+
378
+ voting_results: VotingResults = {
379
+ "comparison_type": comparison_type,
380
+ "winning_provider": option_map[selected_option]["provider"],
381
+ "winning_option": selected_option,
382
+ "option_a_provider": provider_a,
383
+ "option_b_provider": provider_b,
384
+ "option_a_generation_id": option_map[constants.OPTION_A_KEY]["generation_id"],
385
+ "option_b_generation_id": option_map[constants.OPTION_B_KEY]["generation_id"],
386
+ "character_description": character_description,
387
+ "text": text,
388
+ "is_custom_text": text_modified,
389
+ }
390
+
391
+ await _persist_vote(db_session_maker, voting_results, config)
392
 
393
+ except Exception as e:
394
+ # Catch all exceptions at the top level of the background task
395
+ # to prevent unhandled exceptions in background tasks
396
+ logger.error(f"Background task error in submit_voting_results: {e}", exc_info=True)
397
 
398
 
399
  def validate_env_var(var_name: str) -> str:
uv.lock CHANGED
@@ -56,6 +56,38 @@ wheels = [
56
  { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
57
  ]
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  [[package]]
60
  name = "audioop-lts"
61
  version = "0.2.1"
@@ -224,9 +256,11 @@ version = "0.1.0"
224
  source = { virtual = "." }
225
  dependencies = [
226
  { name = "anthropic" },
 
227
  { name = "elevenlabs" },
228
  { name = "gradio" },
229
- { name = "psycopg2" },
 
230
  { name = "python-dotenv" },
231
  { name = "requests" },
232
  { name = "sqlalchemy" },
@@ -247,9 +281,11 @@ dev = [
247
  [package.metadata]
248
  requires-dist = [
249
  { name = "anthropic", specifier = ">=0.45.2" },
 
250
  { name = "elevenlabs", specifier = ">=1.50.7" },
251
  { name = "gradio", specifier = ">=5.15.0" },
252
- { name = "psycopg2", specifier = ">=2.9.0" },
 
253
  { name = "python-dotenv", specifier = ">=1.0.1" },
254
  { name = "requests", specifier = ">=2.32.3" },
255
  { name = "sqlalchemy", specifier = ">=2.0.0" },
@@ -872,19 +908,6 @@ wheels = [
872
  { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 },
873
  ]
874
 
875
- [[package]]
876
- name = "psycopg2"
877
- version = "2.9.10"
878
- source = { registry = "https://pypi.org/simple" }
879
- sdist = { url = "https://files.pythonhosted.org/packages/62/51/2007ea29e605957a17ac6357115d0c1a1b60c8c984951c19419b3474cdfd/psycopg2-2.9.10.tar.gz", hash = "sha256:12ec0b40b0273f95296233e8750441339298e6a572f7039da5b260e3c8b60e11", size = 385672 }
880
- wheels = [
881
- { url = "https://files.pythonhosted.org/packages/20/a2/c51ca3e667c34e7852157b665e3d49418e68182081060231d514dd823225/psycopg2-2.9.10-cp311-cp311-win32.whl", hash = "sha256:47c4f9875125344f4c2b870e41b6aad585901318068acd01de93f3677a6522c2", size = 1024538 },
882
- { url = "https://files.pythonhosted.org/packages/33/39/5a9a229bb5414abeb86e33b8fc8143ab0aecce5a7f698a53e31367d30caa/psycopg2-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:0435034157049f6846e95103bd8f5a668788dd913a7c30162ca9503fdf542cb4", size = 1163736 },
883
- { url = "https://files.pythonhosted.org/packages/3d/16/4623fad6076448df21c1a870c93a9774ad8a7b4dd1660223b59082dd8fec/psycopg2-2.9.10-cp312-cp312-win32.whl", hash = "sha256:65a63d7ab0e067e2cdb3cf266de39663203d38d6a8ed97f5ca0cb315c73fe067", size = 1025113 },
884
- { url = "https://files.pythonhosted.org/packages/66/de/baed128ae0fc07460d9399d82e631ea31a1f171c0c4ae18f9808ac6759e3/psycopg2-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:4a579d6243da40a7b3182e0430493dbd55950c493d8c68f4eec0b302f6bbf20e", size = 1163951 },
885
- { url = "https://files.pythonhosted.org/packages/ae/49/a6cfc94a9c483b1fa401fbcb23aca7892f60c7269c5ffa2ac408364f80dc/psycopg2-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:91fd603a2155da8d0cfcdbf8ab24a2d54bca72795b90d2a3ed2b6da8d979dee2", size = 2569060 },
886
- ]
887
-
888
  [[package]]
889
  name = "pydantic"
890
  version = "2.10.6"
 
56
  { url = "https://files.pythonhosted.org/packages/46/eb/e7f063ad1fec6b3178a3cd82d1a3c4de82cccf283fc42746168188e1cdd5/anyio-4.8.0-py3-none-any.whl", hash = "sha256:b5011f270ab5eb0abf13385f851315585cc37ef330dd88e27ec3d34d651fd47a", size = 96041 },
57
  ]
58
 
59
+ [[package]]
60
+ name = "asyncpg"
61
+ version = "0.30.0"
62
+ source = { registry = "https://pypi.org/simple" }
63
+ sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 }
64
+ wheels = [
65
+ { url = "https://files.pythonhosted.org/packages/4c/0e/f5d708add0d0b97446c402db7e8dd4c4183c13edaabe8a8500b411e7b495/asyncpg-0.30.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5e0511ad3dec5f6b4f7a9e063591d407eee66b88c14e2ea636f187da1dcfff6a", size = 674506 },
66
+ { url = "https://files.pythonhosted.org/packages/6a/a0/67ec9a75cb24a1d99f97b8437c8d56da40e6f6bd23b04e2f4ea5d5ad82ac/asyncpg-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:915aeb9f79316b43c3207363af12d0e6fd10776641a7de8a01212afd95bdf0ed", size = 645922 },
67
+ { url = "https://files.pythonhosted.org/packages/5c/d9/a7584f24174bd86ff1053b14bb841f9e714380c672f61c906eb01d8ec433/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c198a00cce9506fcd0bf219a799f38ac7a237745e1d27f0e1f66d3707c84a5a", size = 3079565 },
68
+ { url = "https://files.pythonhosted.org/packages/a0/d7/a4c0f9660e333114bdb04d1a9ac70db690dd4ae003f34f691139a5cbdae3/asyncpg-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3326e6d7381799e9735ca2ec9fd7be4d5fef5dcbc3cb555d8a463d8460607956", size = 3109962 },
69
+ { url = "https://files.pythonhosted.org/packages/3c/21/199fd16b5a981b1575923cbb5d9cf916fdc936b377e0423099f209e7e73d/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:51da377487e249e35bd0859661f6ee2b81db11ad1f4fc036194bc9cb2ead5056", size = 3064791 },
70
+ { url = "https://files.pythonhosted.org/packages/77/52/0004809b3427534a0c9139c08c87b515f1c77a8376a50ae29f001e53962f/asyncpg-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bc6d84136f9c4d24d358f3b02be4b6ba358abd09f80737d1ac7c444f36108454", size = 3188696 },
71
+ { url = "https://files.pythonhosted.org/packages/52/cb/fbad941cd466117be58b774a3f1cc9ecc659af625f028b163b1e646a55fe/asyncpg-0.30.0-cp311-cp311-win32.whl", hash = "sha256:574156480df14f64c2d76450a3f3aaaf26105869cad3865041156b38459e935d", size = 567358 },
72
+ { url = "https://files.pythonhosted.org/packages/3c/0a/0a32307cf166d50e1ad120d9b81a33a948a1a5463ebfa5a96cc5606c0863/asyncpg-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:3356637f0bd830407b5597317b3cb3571387ae52ddc3bca6233682be88bbbc1f", size = 629375 },
73
+ { url = "https://files.pythonhosted.org/packages/4b/64/9d3e887bb7b01535fdbc45fbd5f0a8447539833b97ee69ecdbb7a79d0cb4/asyncpg-0.30.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c902a60b52e506d38d7e80e0dd5399f657220f24635fee368117b8b5fce1142e", size = 673162 },
74
+ { url = "https://files.pythonhosted.org/packages/6e/eb/8b236663f06984f212a087b3e849731f917ab80f84450e943900e8ca4052/asyncpg-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:aca1548e43bbb9f0f627a04666fedaca23db0a31a84136ad1f868cb15deb6e3a", size = 637025 },
75
+ { url = "https://files.pythonhosted.org/packages/cc/57/2dc240bb263d58786cfaa60920779af6e8d32da63ab9ffc09f8312bd7a14/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c2a2ef565400234a633da0eafdce27e843836256d40705d83ab7ec42074efb3", size = 3496243 },
76
+ { url = "https://files.pythonhosted.org/packages/f4/40/0ae9d061d278b10713ea9021ef6b703ec44698fe32178715a501ac696c6b/asyncpg-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1292b84ee06ac8a2ad8e51c7475aa309245874b61333d97411aab835c4a2f737", size = 3575059 },
77
+ { url = "https://files.pythonhosted.org/packages/c3/75/d6b895a35a2c6506952247640178e5f768eeb28b2e20299b6a6f1d743ba0/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5712350388d0cd0615caec629ad53c81e506b1abaaf8d14c93f54b35e3595a", size = 3473596 },
78
+ { url = "https://files.pythonhosted.org/packages/c8/e7/3693392d3e168ab0aebb2d361431375bd22ffc7b4a586a0fc060d519fae7/asyncpg-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db9891e2d76e6f425746c5d2da01921e9a16b5a71a1c905b13f30e12a257c4af", size = 3641632 },
79
+ { url = "https://files.pythonhosted.org/packages/32/ea/15670cea95745bba3f0352341db55f506a820b21c619ee66b7d12ea7867d/asyncpg-0.30.0-cp312-cp312-win32.whl", hash = "sha256:68d71a1be3d83d0570049cd1654a9bdfe506e794ecc98ad0873304a9f35e411e", size = 560186 },
80
+ { url = "https://files.pythonhosted.org/packages/7e/6b/fe1fad5cee79ca5f5c27aed7bd95baee529c1bf8a387435c8ba4fe53d5c1/asyncpg-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:9a0292c6af5c500523949155ec17b7fe01a00ace33b68a476d6b5059f9630305", size = 621064 },
81
+ { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 },
82
+ { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 },
83
+ { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 },
84
+ { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 },
85
+ { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 },
86
+ { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 },
87
+ { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 },
88
+ { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 },
89
+ ]
90
+
91
  [[package]]
92
  name = "audioop-lts"
93
  version = "0.2.1"
 
256
  source = { virtual = "." }
257
  dependencies = [
258
  { name = "anthropic" },
259
+ { name = "asyncpg" },
260
  { name = "elevenlabs" },
261
  { name = "gradio" },
262
+ { name = "greenlet" },
263
+ { name = "httpx" },
264
  { name = "python-dotenv" },
265
  { name = "requests" },
266
  { name = "sqlalchemy" },
 
281
  [package.metadata]
282
  requires-dist = [
283
  { name = "anthropic", specifier = ">=0.45.2" },
284
+ { name = "asyncpg", specifier = ">=0.28.0" },
285
  { name = "elevenlabs", specifier = ">=1.50.7" },
286
  { name = "gradio", specifier = ">=5.15.0" },
287
+ { name = "greenlet", specifier = ">=2.0.0" },
288
+ { name = "httpx", specifier = ">=0.24.1" },
289
  { name = "python-dotenv", specifier = ">=1.0.1" },
290
  { name = "requests", specifier = ">=2.32.3" },
291
  { name = "sqlalchemy", specifier = ">=2.0.0" },
 
908
  { url = "https://files.pythonhosted.org/packages/43/b3/df14c580d82b9627d173ceea305ba898dca135feb360b6d84019d0803d3b/pre_commit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d29e7cb346295bcc1cc75fc3e92e343495e3ea0196c9ec6ba53f49f10ab6ae7b", size = 220560 },
909
  ]
910
 
 
 
 
 
 
 
 
 
 
 
 
 
 
911
  [[package]]
912
  name = "pydantic"
913
  version = "2.10.6"