zach commited on
Commit
1f58459
·
1 Parent(s): b4a99f1

Update how app is served to accomodate adding middleware to edit metatags in head

Browse files
Files changed (7) hide show
  1. pyproject.toml +3 -1
  2. src/assets/styles.css +3 -3
  3. src/config.py +7 -6
  4. src/constants.py +62 -1
  5. src/main.py +82 -9
  6. src/utils.py +43 -24
  7. uv.lock +36 -0
pyproject.toml CHANGED
@@ -6,7 +6,8 @@ readme = "README.md"
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.18.0",
12
  "greenlet>=2.0.0",
@@ -82,6 +83,7 @@ select = [
82
  "TID",
83
  "W",
84
  ]
 
85
 
86
  [tool.ruff.lint.pycodestyle]
87
  max-line-length = 120
 
6
  requires-python = ">=3.11"
7
  dependencies = [
8
  "anthropic>=0.45.2",
9
+ "asyncpg>=0.28.0",
10
+ "bs4>=0.0.2",
11
  "elevenlabs>=1.50.7",
12
  "gradio>=5.18.0",
13
  "greenlet>=2.0.0",
 
83
  "TID",
84
  "W",
85
  ]
86
+ per-file-ignores = { "src/constants.py" = ["E501"] }
87
 
88
  [tool.ruff.lint.pycodestyle]
89
  max-line-length = 120
src/assets/styles.css CHANGED
@@ -25,7 +25,7 @@ footer.svelte-1byz9vf {
25
  .social-links {
26
  display: flex;
27
  align-items: center;
28
- gap: 8px;
29
  margin: 0px 8px;
30
  overflow: visible !important;
31
  }
@@ -34,8 +34,8 @@ footer.svelte-1byz9vf {
34
  #github-link,
35
  #discord-link {
36
  display: inline-block !important;
37
- width: 32px !important;
38
- height: 32px !important;
39
  background-size: cover !important;
40
  background-position: center !important;
41
  background-repeat: no-repeat !important;
 
25
  .social-links {
26
  display: flex;
27
  align-items: center;
28
+ gap: 12px;
29
  margin: 0px 8px;
30
  overflow: visible !important;
31
  }
 
34
  #github-link,
35
  #discord-link {
36
  display: inline-block !important;
37
+ width: 30px !important;
38
+ height: 30px !important;
39
  background-size: cover !important;
40
  background-position: center !important;
41
  background-repeat: no-repeat !important;
src/config.py CHANGED
@@ -24,7 +24,7 @@ from dotenv import load_dotenv
24
  if TYPE_CHECKING:
25
  from src.integrations import AnthropicConfig, ElevenLabsConfig, HumeConfig
26
 
27
- logger: logging.Logger = logging.getLogger("tts_arena")
28
 
29
 
30
  @dataclass(frozen=True)
@@ -40,11 +40,12 @@ class Config:
40
 
41
  @classmethod
42
  def get(cls) -> "Config":
43
- if cls._config is None:
44
- _config = Config._init()
45
- cls._config = _config
46
- return _config
47
- return cls._config
 
48
 
49
  @staticmethod
50
  def _init():
 
24
  if TYPE_CHECKING:
25
  from src.integrations import AnthropicConfig, ElevenLabsConfig, HumeConfig
26
 
27
+ logger: logging.Logger = logging.getLogger("expressive_tts_arena")
28
 
29
 
30
  @dataclass(frozen=True)
 
40
 
41
  @classmethod
42
  def get(cls) -> "Config":
43
+ if cls._config:
44
+ return cls._config
45
+
46
+ _config = Config._init()
47
+ cls._config = _config
48
+ return _config
49
 
50
  @staticmethod
51
  def _init():
src/constants.py CHANGED
@@ -5,7 +5,7 @@ This module defines global constants used throughout the project.
5
  """
6
 
7
  # Standard Library Imports
8
- from typing import List
9
 
10
  # Third-Party Library Imports
11
  from src.custom_types import ComparisonType, OptionKey, OptionLabel, TTSProviderName
@@ -75,3 +75,64 @@ SAMPLE_CHARACTER_DESCRIPTIONS: dict = {
75
  "sarcasm and self-effacing humor."
76
  ),
77
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  # Standard Library Imports
8
+ from typing import Dict, List
9
 
10
  # Third-Party Library Imports
11
  from src.custom_types import ComparisonType, OptionKey, OptionLabel, TTSProviderName
 
75
  "sarcasm and self-effacing humor."
76
  ),
77
  }
78
+
79
+
80
+ # HTML and social media metadata for the Gradio application
81
+ # These tags define SEO-friendly content and provide rich previews when shared on social platforms
82
+ META_TAGS: List[Dict[str, str]] = [
83
+ # HTML Meta Tags (description)
84
+ {
85
+ 'name': 'description',
86
+ 'content': 'An open-source web application for comparing and evaluating the expressiveness of different text-to-speech models, including Hume AI and ElevenLabs.'
87
+ },
88
+ # Facebook Meta Tags
89
+ {
90
+ 'property': 'og:url',
91
+ 'content': 'https://hume.ai'
92
+ },
93
+ {
94
+ 'property': 'og:type',
95
+ 'content': 'website'
96
+ },
97
+ {
98
+ 'property': 'og:title',
99
+ 'content': 'Expressive TTS Arena'
100
+ },
101
+ {
102
+ 'property': 'og:description',
103
+ 'content': 'An open-source web application for comparing and evaluating the expressiveness of different text-to-speech models, including Hume AI and ElevenLabs.'
104
+ },
105
+ {
106
+ 'property': 'og:image',
107
+ 'content': 'https://opengraph.b-cdn.net/production/images/7990b8b3-f8ef-4ece-afce-70ca30795f5c.png?token=Ge7_YHHoQRRifYmbBOjex67tCoj3ZoPe_ty5ffWm-n8&height=630&width=1200&expires=33277213515'
108
+ },
109
+ # Twitter Meta Tags
110
+ {
111
+ 'name': 'twitter:card',
112
+ 'content': 'summary_large_image'
113
+ },
114
+ {
115
+ 'property': 'twitter:domain',
116
+ 'content': 'hume.ai'
117
+ },
118
+ {
119
+ 'property': 'twitter:url',
120
+ 'content': 'https://hume.ai'
121
+ },
122
+ {
123
+ 'name': 'twitter:creator',
124
+ 'content': '@hume_ai'
125
+ },
126
+ {
127
+ 'name': 'twitter:title',
128
+ 'content': 'Expressive TTS Arena'
129
+ },
130
+ {
131
+ 'name': 'twitter:description',
132
+ 'content': 'An open-source web application for comparing and evaluating the expressiveness of different text-to-speech models, including Hume AI and ElevenLabs.'
133
+ },
134
+ {
135
+ 'name': 'twitter:image',
136
+ 'content': 'https://opengraph.b-cdn.net/production/images/7990b8b3-f8ef-4ece-afce-70ca30795f5c.png?token=Ge7_YHHoQRRifYmbBOjex67tCoj3ZoPe_ty5ffWm-n8&height=630&width=1200&expires=33277213515'
137
+ }
138
+ ]
src/main.py CHANGED
@@ -6,28 +6,101 @@ This module is the entry point for the app. It loads configuration and starts th
6
 
7
  # Standard Library Imports
8
  import asyncio
 
 
 
 
 
 
 
9
 
10
- # Local Application Imports
11
- from src.app import App
12
  from src.config import Config, logger
 
13
  from src.database import init_db
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  async def main():
17
  """
18
  Asynchronous main function to initialize the application.
19
  """
20
- config = Config.get()
21
  logger.info("Launching TTS Arena Gradio app...")
 
22
  db_session_maker = init_db(config)
23
- app = App(config, db_session_maker)
24
- demo = app.build_gradio_interface()
25
- demo.launch(
26
- server_name="0.0.0.0",
27
- allowed_paths=[str(config.audio_dir)],
28
- ssl_verify= False,
 
 
 
 
 
 
29
  )
30
 
 
 
 
 
 
31
 
32
  if __name__ == "__main__":
33
  asyncio.run(main())
 
6
 
7
  # Standard Library Imports
8
  import asyncio
9
+ from typing import Awaitable, Callable
10
+
11
+ # Third-Party Library Imports
12
+ import gradio as gr
13
+ from fastapi import FastAPI, Request
14
+ from fastapi.responses import Response
15
+ from starlette.middleware.base import BaseHTTPMiddleware
16
 
 
 
17
  from src.config import Config, logger
18
+ from src.constants import META_TAGS
19
  from src.database import init_db
20
 
21
+ # Local Application Imports
22
+ from src.frontend import Frontend
23
+ from src.utils import update_meta_tags
24
+
25
+
26
+ class ResponseModifierMiddleware(BaseHTTPMiddleware):
27
+ """
28
+ FastAPI middleware that safely intercepts and modifies the HTML response from the root endpoint
29
+ to inject custom meta tags into the document head.
30
+
31
+ This middleware specifically targets the root path ('/') and leaves all other endpoint
32
+ responses unmodified. It uses BeautifulSoup to properly parse and modify the HTML,
33
+ ensuring that JavaScript functionality remains intact.
34
+ """
35
+ async def dispatch(
36
+ self,
37
+ request: Request,
38
+ call_next: Callable[[Request], Awaitable[Response]]
39
+ ) -> Response:
40
+ # Process the request and get the response
41
+ response = await call_next(request)
42
+
43
+ # Only intercept responses from the root endpoint and HTML content
44
+ if request.url.path == "/" and response.headers.get("content-type", "").startswith("text/html"):
45
+ # Get the response body
46
+ response_body = b""
47
+ async for chunk in response.body_iterator:
48
+ response_body += chunk
49
+
50
+ try:
51
+ # Decode, modify, and re-encode the content
52
+ content = response_body.decode("utf-8")
53
+ modified_content = update_meta_tags(content, META_TAGS).encode("utf-8")
54
+
55
+ # Update content-length header to reflect modified content size
56
+ headers = dict(response.headers)
57
+ headers["content-length"] = str(len(modified_content))
58
+
59
+ # Create a new response with the modified content
60
+ return Response(
61
+ content=modified_content,
62
+ status_code=response.status_code,
63
+ headers=headers,
64
+ media_type=response.media_type
65
+ )
66
+ except Exception:
67
+ # If there's an error, return the original response
68
+ return Response(
69
+ content=response_body,
70
+ status_code=response.status_code,
71
+ headers=dict(response.headers),
72
+ media_type=response.media_type
73
+ )
74
+
75
+ return response
76
+
77
 
78
  async def main():
79
  """
80
  Asynchronous main function to initialize the application.
81
  """
 
82
  logger.info("Launching TTS Arena Gradio app...")
83
+ config = Config.get()
84
  db_session_maker = init_db(config)
85
+
86
+ frontend = Frontend(config, db_session_maker)
87
+ demo = frontend.build_gradio_interface()
88
+
89
+ app = FastAPI()
90
+ app.add_middleware(ResponseModifierMiddleware)
91
+
92
+ gr.mount_gradio_app(
93
+ app=app,
94
+ blocks=demo,
95
+ path="/",
96
+ allowed_paths=[str(config.audio_dir)]
97
  )
98
 
99
+ import uvicorn
100
+ config = uvicorn.Config(app, host="0.0.0.0", port=7860, log_level="info")
101
+ server = uvicorn.Server(config)
102
+ await server.serve()
103
+
104
 
105
  if __name__ == "__main__":
106
  asyncio.run(main())
src/utils.py CHANGED
@@ -12,9 +12,10 @@ import os
12
  import random
13
  import time
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
@@ -256,9 +257,7 @@ def create_shuffled_tts_options(option_a: Option, option_b: Option) -> OptionMap
256
  }
257
 
258
 
259
- def determine_selected_option(
260
- selected_option_button: str,
261
- ) -> Tuple[OptionKey, OptionKey]:
262
  """
263
  Determines the selected option and the alternative option based on the user's selection.
264
 
@@ -315,11 +314,7 @@ def _log_voting_results(voting_results: VotingResults) -> None:
315
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
316
 
317
 
318
- async def _persist_vote(
319
- db_session_maker: AsyncDBSessionMaker,
320
- voting_results: VotingResults,
321
- config: Config
322
- ) -> None:
323
  """
324
  Asynchronously persist a vote record in the database and handle potential failures.
325
  Designed to work safely in a background task context.
@@ -349,8 +344,7 @@ async def _persist_vote(
349
  _log_voting_results(voting_results)
350
  except Exception as e:
351
  # Log the error with traceback in production, without traceback in dev
352
- logger.error(f"Failed to create vote record: {e}",
353
- exc_info=(config.app_env == "prod"))
354
  _log_voting_results(voting_results)
355
  finally:
356
  # Always ensure the session is closed
@@ -403,9 +397,8 @@ async def submit_voting_results(
403
 
404
  await _persist_vote(db_session_maker, voting_results, config)
405
 
 
406
  except Exception as e:
407
- # Catch all exceptions at the top level of the background task
408
- # to prevent unhandled exceptions in background tasks
409
  logger.error(f"Background task error in submit_voting_results: {e}", exc_info=True)
410
 
411
 
@@ -421,19 +414,45 @@ def validate_env_var(var_name: str) -> str:
421
 
422
  Raises:
423
  ValueError: If the environment variable is not set.
424
-
425
- Examples:
426
- >>> import os
427
- >>> os.environ["EXAMPLE_VAR"] = "example_value"
428
- >>> validate_env_var("EXAMPLE_VAR")
429
- 'example_value'
430
-
431
- >>> validate_env_var("MISSING_VAR")
432
- Traceback (most recent call last):
433
- ...
434
- ValueError: MISSING_VAR is not set. Please ensure it is defined in your environment variables.
435
  """
436
  value = os.environ.get(var_name, "")
437
  if not value:
438
  raise ValueError(f"{var_name} is not set. Please ensure it is defined in your environment variables.")
439
  return value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  import random
13
  import time
14
  from pathlib import Path
15
+ from typing import Dict, List, Tuple, cast
16
 
17
  # Third-Party Library Imports
18
+ from bs4 import BeautifulSoup
19
  from sqlalchemy.ext.asyncio import AsyncSession
20
 
21
  # Local Application Imports
 
257
  }
258
 
259
 
260
+ def determine_selected_option(selected_option_button: str) -> Tuple[OptionKey, OptionKey]:
 
 
261
  """
262
  Determines the selected option and the alternative option based on the user's selection.
263
 
 
314
  logger.info("Voting results:\n%s", json.dumps(voting_results, indent=4))
315
 
316
 
317
+ async def _persist_vote(db_session_maker: AsyncDBSessionMaker, voting_results: VotingResults, config: Config) -> None:
 
 
 
 
318
  """
319
  Asynchronously persist a vote record in the database and handle potential failures.
320
  Designed to work safely in a background task context.
 
344
  _log_voting_results(voting_results)
345
  except Exception as e:
346
  # Log the error with traceback in production, without traceback in dev
347
+ logger.error(f"Failed to create vote record: {e}", exc_info=(config.app_env == "prod"))
 
348
  _log_voting_results(voting_results)
349
  finally:
350
  # Always ensure the session is closed
 
397
 
398
  await _persist_vote(db_session_maker, voting_results, config)
399
 
400
+ # Catch exceptions at the top level of the background task to prevent unhandled exceptions in background tasks
401
  except Exception as e:
 
 
402
  logger.error(f"Background task error in submit_voting_results: {e}", exc_info=True)
403
 
404
 
 
414
 
415
  Raises:
416
  ValueError: If the environment variable is not set.
 
 
 
 
 
 
 
 
 
 
 
417
  """
418
  value = os.environ.get(var_name, "")
419
  if not value:
420
  raise ValueError(f"{var_name} is not set. Please ensure it is defined in your environment variables.")
421
  return value
422
+
423
+
424
+ def update_meta_tags(html_content: str, meta_tags: List[Dict[str, str]]) -> str:
425
+ """
426
+ Safely updates the HTML content by adding or replacing meta tags in the head section
427
+ without affecting other elements, especially scripts and event handlers.
428
+
429
+ Args:
430
+ html_content: The original HTML content as a string
431
+ meta_tags: A list of dictionaries with meta tag attributes to add
432
+
433
+ Returns:
434
+ The modified HTML content with updated meta tags
435
+ """
436
+ # Parse the HTML
437
+ soup = BeautifulSoup(html_content, 'html.parser')
438
+ head = soup.head
439
+
440
+ # Remove existing meta tags that would conflict with our new ones
441
+ for meta_tag in meta_tags:
442
+ # Determine if we're looking for 'name' or 'property' attribute
443
+ attr_type = 'name' if 'name' in meta_tag else 'property'
444
+ attr_value = meta_tag.get(attr_type)
445
+
446
+ # Find and remove existing meta tags with the same name/property
447
+ existing_tags = head.find_all('meta', attrs={attr_type: attr_value})
448
+ for tag in existing_tags:
449
+ tag.decompose()
450
+
451
+ # Add the new meta tags to the head section
452
+ for meta_info in meta_tags:
453
+ new_meta = soup.new_tag('meta')
454
+ for attr, value in meta_info.items():
455
+ new_meta[attr] = value
456
+ head.append(new_meta)
457
+
458
+ return str(soup)
uv.lock CHANGED
@@ -131,6 +131,31 @@ wheels = [
131
  { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918 },
132
  ]
133
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  [[package]]
135
  name = "certifi"
136
  version = "2025.1.31"
@@ -269,6 +294,7 @@ source = { virtual = "." }
269
  dependencies = [
270
  { name = "anthropic" },
271
  { name = "asyncpg" },
 
272
  { name = "elevenlabs" },
273
  { name = "gradio" },
274
  { name = "greenlet" },
@@ -293,6 +319,7 @@ dev = [
293
  requires-dist = [
294
  { name = "anthropic", specifier = ">=0.45.2" },
295
  { name = "asyncpg", specifier = ">=0.28.0" },
 
296
  { name = "elevenlabs", specifier = ">=1.50.7" },
297
  { name = "gradio", specifier = ">=5.18.0" },
298
  { name = "greenlet", specifier = ">=2.0.0" },
@@ -1224,6 +1251,15 @@ wheels = [
1224
  { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
1225
  ]
1226
 
 
 
 
 
 
 
 
 
 
1227
  [[package]]
1228
  name = "sqlalchemy"
1229
  version = "2.0.38"
 
131
  { url = "https://files.pythonhosted.org/packages/5d/35/be73b6015511aa0173ec595fc579133b797ad532996f2998fd6b8d1bbe6b/audioop_lts-0.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:78bfb3703388c780edf900be66e07de5a3d4105ca8e8720c5c4d67927e0b15d0", size = 23918 },
132
  ]
133
 
134
+ [[package]]
135
+ name = "beautifulsoup4"
136
+ version = "4.13.3"
137
+ source = { registry = "https://pypi.org/simple" }
138
+ dependencies = [
139
+ { name = "soupsieve" },
140
+ { name = "typing-extensions" },
141
+ ]
142
+ sdist = { url = "https://files.pythonhosted.org/packages/f0/3c/adaf39ce1fb4afdd21b611e3d530b183bb7759c9b673d60db0e347fd4439/beautifulsoup4-4.13.3.tar.gz", hash = "sha256:1bd32405dacc920b42b83ba01644747ed77456a65760e285fbc47633ceddaf8b", size = 619516 }
143
+ wheels = [
144
+ { url = "https://files.pythonhosted.org/packages/f9/49/6abb616eb3cbab6a7cca303dc02fdf3836de2e0b834bf966a7f5271a34d8/beautifulsoup4-4.13.3-py3-none-any.whl", hash = "sha256:99045d7d3f08f91f0d656bc9b7efbae189426cd913d830294a15eefa0ea4df16", size = 186015 },
145
+ ]
146
+
147
+ [[package]]
148
+ name = "bs4"
149
+ version = "0.0.2"
150
+ source = { registry = "https://pypi.org/simple" }
151
+ dependencies = [
152
+ { name = "beautifulsoup4" },
153
+ ]
154
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/aa/4acaf814ff901145da37332e05bb510452ebed97bc9602695059dd46ef39/bs4-0.0.2.tar.gz", hash = "sha256:a48685c58f50fe127722417bae83fe6badf500d54b55f7e39ffe43b798653925", size = 698 }
155
+ wheels = [
156
+ { url = "https://files.pythonhosted.org/packages/51/bb/bf7aab772a159614954d84aa832c129624ba6c32faa559dfb200a534e50b/bs4-0.0.2-py2.py3-none-any.whl", hash = "sha256:abf8742c0805ef7f662dce4b51cca104cffe52b835238afc169142ab9b3fbccc", size = 1189 },
157
+ ]
158
+
159
  [[package]]
160
  name = "certifi"
161
  version = "2025.1.31"
 
294
  dependencies = [
295
  { name = "anthropic" },
296
  { name = "asyncpg" },
297
+ { name = "bs4" },
298
  { name = "elevenlabs" },
299
  { name = "gradio" },
300
  { name = "greenlet" },
 
319
  requires-dist = [
320
  { name = "anthropic", specifier = ">=0.45.2" },
321
  { name = "asyncpg", specifier = ">=0.28.0" },
322
+ { name = "bs4", specifier = ">=0.0.2" },
323
  { name = "elevenlabs", specifier = ">=1.50.7" },
324
  { name = "gradio", specifier = ">=5.18.0" },
325
  { name = "greenlet", specifier = ">=2.0.0" },
 
1251
  { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
1252
  ]
1253
 
1254
+ [[package]]
1255
+ name = "soupsieve"
1256
+ version = "2.6"
1257
+ source = { registry = "https://pypi.org/simple" }
1258
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 }
1259
+ wheels = [
1260
+ { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 },
1261
+ ]
1262
+
1263
  [[package]]
1264
  name = "sqlalchemy"
1265
  version = "2.0.38"