chainlit 2.0.dev2__tar.gz → 2.0rc1__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 chainlit might be problematic. Click here for more details.
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/PKG-INFO +3 -3
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/__init__.py +12 -4
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/action.py +4 -2
- chainlit-2.0.dev2/chainlit/auth.py → chainlit-2.0rc1/chainlit/auth/__init__.py +20 -34
- chainlit-2.0rc1/chainlit/auth/cookie.py +124 -0
- chainlit-2.0rc1/chainlit/auth/jwt.py +37 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/cache.py +2 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/callbacks.py +51 -6
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/chat_context.py +2 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/chat_settings.py +3 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/cli/__init__.py +14 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/config.py +32 -15
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/context.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/copilot/dist/index.js +559 -261
- chainlit-2.0rc1/chainlit/data/__init__.py +44 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/acl.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/base.py +1 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/dynamodb.py +5 -3
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/literalai.py +3 -5
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/sql_alchemy.py +6 -5
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/storage_clients/azure.py +1 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/storage_clients/s3.py +1 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/discord/app.py +2 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/element.py +6 -5
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/emitter.py +19 -10
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/DailyMotion-D1ipkdPJ.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/DailyMotion-C-_sjrtO.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Facebook-d4TLeTik.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Facebook-bB34P03l.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/FilePlayer-BcU7tttX.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/FilePlayer-BWgqGrXv.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Kaltura-DdaRjZrh.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Kaltura-OY4P9Ofd.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Mixcloud-BaJoMsaU.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Mixcloud-9CtT8w5Y.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Mux-DxPCM5d3.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Mux-BH9A0qEi.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Preview-tUK_Z9pZ.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Preview-Og00EJ05.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/SoundCloud-K8-lFZC6.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/SoundCloud-D7resGfn.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Streamable-hB-AQ54w.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Streamable-6f_6bYz1.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Twitch-pmuNY0J5.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Twitch-BZJl3peM.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Vidyard-BSUm6trV.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Vidyard-B7tv4b8_.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Vimeo-JIPn71zS.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Vimeo-F-eA4zQI.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/Wistia-D75KkqOG.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/Wistia-Dhxhn3IB.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/YouTube-CPlwqNm_.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/YouTube-aFdJGjI1.js +1 -1
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/index-CuSbXjG5.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/index-Ba33_hdJ.js +122 -122
- chainlit-2.0.dev2/chainlit/frontend/dist/assets/react-plotly-DALmanjC.js → chainlit-2.0rc1/chainlit/frontend/dist/assets/react-plotly-DoUJXMgz.js +1 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/frontend/dist/index.html +1 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/haystack/callbacks.py +5 -4
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/input_widget.py +6 -4
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/langchain/callbacks.py +56 -47
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/langflow/__init__.py +1 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/llama_index/callbacks.py +7 -7
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/message.py +6 -5
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/mistralai/__init__.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/oauth_providers.py +70 -3
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/openai/__init__.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/secret.py +1 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/server.py +232 -156
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/session.py +7 -5
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/slack/app.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/socket.py +88 -63
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/step.py +11 -10
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/sync.py +2 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/teams/app.py +1 -0
- chainlit-2.0rc1/chainlit/translations/nl-NL.json +229 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/types.py +3 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/user.py +2 -1
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/utils.py +3 -2
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/pyproject.toml +9 -9
- chainlit-2.0.dev2/chainlit/data/__init__.py +0 -25
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/README.md +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/__main__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/_utils.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/copilot/dist/assets/logo_dark-IkGJ_IwC.svg +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/copilot/dist/assets/logo_light-Bb_IPh6r.svg +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/storage_clients/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/storage_clients/base.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/data/utils.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/discord/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/frontend/dist/assets/index-CwmincdQ.css +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/frontend/dist/assets/logo_dark-IkGJ_IwC.svg +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/frontend/dist/assets/logo_light-Bb_IPh6r.svg +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/frontend/dist/favicon.svg +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/haystack/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/hello.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/langchain/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/llama_index/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/logger.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/markdown.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/py.typed +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/slack/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/teams/__init__.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/telemetry.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/bn.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/en-US.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/gu.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/he-IL.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/hi.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/kn.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/ml.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/mr.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/ta.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/te.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations/zh-CN.json +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/translations.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/user_session.py +0 -0
- {chainlit-2.0.dev2 → chainlit-2.0rc1}/chainlit/version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: chainlit
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.0rc1
|
|
4
4
|
Summary: Build Conversational AI.
|
|
5
5
|
Home-page: https://chainlit.io/
|
|
6
6
|
License: Apache-2.0
|
|
@@ -24,7 +24,7 @@ Requires-Dist: aiofiles (>=23.1.0,<24.0.0)
|
|
|
24
24
|
Requires-Dist: asyncer (>=0.0.7,<0.0.8)
|
|
25
25
|
Requires-Dist: click (>=8.1.3,<9.0.0)
|
|
26
26
|
Requires-Dist: dataclasses_json (>=0.6.7,<0.7.0)
|
|
27
|
-
Requires-Dist: fastapi (>=0.
|
|
27
|
+
Requires-Dist: fastapi (>=0.115.3,<0.116)
|
|
28
28
|
Requires-Dist: filetype (>=1.2.0,<2.0.0)
|
|
29
29
|
Requires-Dist: httpx (>=0.23.0)
|
|
30
30
|
Requires-Dist: lazify (>=0.4.0,<0.5.0)
|
|
@@ -37,7 +37,7 @@ Requires-Dist: pyjwt (>=2.8.0,<3.0.0)
|
|
|
37
37
|
Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
|
|
38
38
|
Requires-Dist: python-multipart (>=0.0.9,<0.0.10)
|
|
39
39
|
Requires-Dist: python-socketio (>=5.11.0,<6.0.0)
|
|
40
|
-
Requires-Dist: starlette (>=0.
|
|
40
|
+
Requires-Dist: starlette (>=0.41.2,<0.42.0)
|
|
41
41
|
Requires-Dist: syncer (>=2.0.3,<3.0.0)
|
|
42
42
|
Requires-Dist: tomli (>=2.0.1,<3.0.0)
|
|
43
43
|
Requires-Dist: uptrace (>=1.22.0,<2.0.0)
|
|
@@ -14,6 +14,9 @@ if env_found:
|
|
|
14
14
|
import asyncio
|
|
15
15
|
from typing import TYPE_CHECKING, Any, Dict
|
|
16
16
|
|
|
17
|
+
from literalai import ChatGeneration, CompletionGeneration, GenerationMessage
|
|
18
|
+
from pydantic.dataclasses import dataclass
|
|
19
|
+
|
|
17
20
|
import chainlit.input_widget as input_widget
|
|
18
21
|
from chainlit.action import Action
|
|
19
22
|
from chainlit.cache import cache
|
|
@@ -44,22 +47,21 @@ from chainlit.message import (
|
|
|
44
47
|
)
|
|
45
48
|
from chainlit.step import Step, step
|
|
46
49
|
from chainlit.sync import make_async, run_sync
|
|
47
|
-
from chainlit.types import InputAudioChunk, OutputAudioChunk,
|
|
50
|
+
from chainlit.types import ChatProfile, InputAudioChunk, OutputAudioChunk, Starter
|
|
48
51
|
from chainlit.user import PersistedUser, User
|
|
49
52
|
from chainlit.user_session import user_session
|
|
50
53
|
from chainlit.utils import make_module_getattr
|
|
51
54
|
from chainlit.version import __version__
|
|
52
|
-
from literalai import ChatGeneration, CompletionGeneration, GenerationMessage
|
|
53
|
-
from pydantic.dataclasses import dataclass
|
|
54
55
|
|
|
55
56
|
from .callbacks import (
|
|
56
57
|
action_callback,
|
|
57
58
|
author_rename,
|
|
59
|
+
data_layer,
|
|
58
60
|
header_auth_callback,
|
|
59
61
|
oauth_callback,
|
|
60
|
-
on_audio_start,
|
|
61
62
|
on_audio_chunk,
|
|
62
63
|
on_audio_end,
|
|
64
|
+
on_audio_start,
|
|
63
65
|
on_chat_end,
|
|
64
66
|
on_chat_resume,
|
|
65
67
|
on_chat_start,
|
|
@@ -67,7 +69,9 @@ from .callbacks import (
|
|
|
67
69
|
on_message,
|
|
68
70
|
on_settings_update,
|
|
69
71
|
on_stop,
|
|
72
|
+
on_window_message,
|
|
70
73
|
password_auth_callback,
|
|
74
|
+
send_window_message,
|
|
71
75
|
set_chat_profiles,
|
|
72
76
|
set_starters,
|
|
73
77
|
)
|
|
@@ -130,6 +134,7 @@ __all__ = [
|
|
|
130
134
|
"Image",
|
|
131
135
|
"Text",
|
|
132
136
|
"Component",
|
|
137
|
+
"Dataframe",
|
|
133
138
|
"Pyplot",
|
|
134
139
|
"File",
|
|
135
140
|
"Task",
|
|
@@ -149,6 +154,8 @@ __all__ = [
|
|
|
149
154
|
"CompletionGeneration",
|
|
150
155
|
"GenerationMessage",
|
|
151
156
|
"on_logout",
|
|
157
|
+
"on_window_message",
|
|
158
|
+
"send_window_message",
|
|
152
159
|
"on_chat_start",
|
|
153
160
|
"on_chat_end",
|
|
154
161
|
"on_chat_resume",
|
|
@@ -186,6 +193,7 @@ __all__ = [
|
|
|
186
193
|
"on_stop",
|
|
187
194
|
"action_callback",
|
|
188
195
|
"on_settings_update",
|
|
196
|
+
"data_layer",
|
|
189
197
|
]
|
|
190
198
|
|
|
191
199
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import uuid
|
|
2
2
|
from typing import Optional
|
|
3
3
|
|
|
4
|
+
from dataclasses_json import DataClassJsonMixin
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from pydantic.dataclasses import dataclass
|
|
7
|
+
|
|
4
8
|
from chainlit.context import context
|
|
5
9
|
from chainlit.telemetry import trace_event
|
|
6
|
-
from dataclasses_json import DataClassJsonMixin
|
|
7
|
-
from pydantic.dataclasses import Field, dataclass
|
|
8
10
|
|
|
9
11
|
|
|
10
12
|
@dataclass
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import os
|
|
2
|
-
from datetime import datetime, timedelta
|
|
3
|
-
from typing import Any, Dict
|
|
4
2
|
|
|
5
|
-
import
|
|
3
|
+
from fastapi import Depends, HTTPException
|
|
4
|
+
|
|
6
5
|
from chainlit.config import config
|
|
7
6
|
from chainlit.data import get_data_layer
|
|
7
|
+
from chainlit.logger import logger
|
|
8
8
|
from chainlit.oauth_providers import get_configured_oauth_providers
|
|
9
|
-
from chainlit.user import User
|
|
10
|
-
from fastapi import Depends, HTTPException
|
|
11
|
-
from fastapi.security import OAuth2PasswordBearer
|
|
12
|
-
|
|
13
|
-
reuseable_oauth = OAuth2PasswordBearer(tokenUrl="/login", auto_error=False)
|
|
14
9
|
|
|
10
|
+
from .cookie import OAuth2PasswordBearerWithCookie
|
|
11
|
+
from .jwt import create_jwt, decode_jwt, get_jwt_secret
|
|
15
12
|
|
|
16
|
-
|
|
17
|
-
return os.environ.get("CHAINLIT_AUTH_SECRET")
|
|
13
|
+
reuseable_oauth = OAuth2PasswordBearerWithCookie(tokenUrl="/login", auto_error=False)
|
|
18
14
|
|
|
19
15
|
|
|
20
16
|
def ensure_jwt_secret():
|
|
@@ -42,52 +38,39 @@ def get_configuration():
|
|
|
42
38
|
"requireLogin": require_login(),
|
|
43
39
|
"passwordAuth": config.code.password_auth_callback is not None,
|
|
44
40
|
"headerAuth": config.code.header_auth_callback is not None,
|
|
41
|
+
"cookieAuth": config.project.cookie_auth,
|
|
45
42
|
"oauthProviders": (
|
|
46
43
|
get_configured_oauth_providers() if is_oauth_enabled() else []
|
|
47
44
|
),
|
|
48
45
|
}
|
|
49
46
|
|
|
50
47
|
|
|
51
|
-
def create_jwt(data: User) -> str:
|
|
52
|
-
to_encode: Dict[str, Any] = data.to_dict()
|
|
53
|
-
to_encode.update(
|
|
54
|
-
{
|
|
55
|
-
"exp": datetime.utcnow() + timedelta(
|
|
56
|
-
seconds=config.project.user_session_timeout
|
|
57
|
-
),
|
|
58
|
-
}
|
|
59
|
-
)
|
|
60
|
-
encoded_jwt = jwt.encode(to_encode, get_jwt_secret(), algorithm="HS256")
|
|
61
|
-
return encoded_jwt
|
|
62
|
-
|
|
63
|
-
|
|
64
48
|
async def authenticate_user(token: str = Depends(reuseable_oauth)):
|
|
65
49
|
try:
|
|
66
|
-
|
|
67
|
-
token,
|
|
68
|
-
get_jwt_secret(),
|
|
69
|
-
algorithms=["HS256"],
|
|
70
|
-
options={"verify_signature": True},
|
|
71
|
-
)
|
|
72
|
-
del dict["exp"]
|
|
73
|
-
user = User(**dict)
|
|
50
|
+
user = decode_jwt(token)
|
|
74
51
|
except Exception as e:
|
|
75
52
|
raise HTTPException(
|
|
76
53
|
status_code=401, detail="Invalid authentication token"
|
|
77
54
|
) from e
|
|
55
|
+
|
|
78
56
|
if data_layer := get_data_layer():
|
|
57
|
+
# Get or create persistent user if we've a data layer available.
|
|
79
58
|
try:
|
|
80
59
|
persisted_user = await data_layer.get_user(user.identifier)
|
|
81
60
|
if persisted_user is None:
|
|
82
61
|
persisted_user = await data_layer.create_user(user)
|
|
83
|
-
|
|
62
|
+
assert persisted_user
|
|
63
|
+
except Exception as e:
|
|
64
|
+
logger.exception("Unable to get persisted_user from data layer: %s", e)
|
|
84
65
|
return user
|
|
85
66
|
|
|
86
67
|
if user and user.display_name:
|
|
68
|
+
# Copy ephemeral display_name from authenticated user to persistent user.
|
|
87
69
|
persisted_user.display_name = user.display_name
|
|
70
|
+
|
|
88
71
|
return persisted_user
|
|
89
|
-
|
|
90
|
-
|
|
72
|
+
|
|
73
|
+
return user
|
|
91
74
|
|
|
92
75
|
|
|
93
76
|
async def get_current_user(token: str = Depends(reuseable_oauth)):
|
|
@@ -95,3 +78,6 @@ async def get_current_user(token: str = Depends(reuseable_oauth)):
|
|
|
95
78
|
return None
|
|
96
79
|
|
|
97
80
|
return await authenticate_user(token)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
__all__ = ["create_jwt", "get_configuration", "get_current_user"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Literal, Optional, cast
|
|
3
|
+
|
|
4
|
+
from fastapi import Request, Response
|
|
5
|
+
from fastapi.exceptions import HTTPException
|
|
6
|
+
from fastapi.security.base import SecurityBase
|
|
7
|
+
from fastapi.security.utils import get_authorization_scheme_param
|
|
8
|
+
from starlette.status import HTTP_401_UNAUTHORIZED
|
|
9
|
+
|
|
10
|
+
""" Module level cookie settings. """
|
|
11
|
+
_cookie_samesite = cast(
|
|
12
|
+
Literal["lax", "strict", "none"],
|
|
13
|
+
os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax"),
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
assert (
|
|
17
|
+
_cookie_samesite
|
|
18
|
+
in [
|
|
19
|
+
"lax",
|
|
20
|
+
"strict",
|
|
21
|
+
"none",
|
|
22
|
+
]
|
|
23
|
+
), "Invalid value for CHAINLIT_COOKIE_SAMESITE. Must be one of 'lax', 'strict' or 'none'."
|
|
24
|
+
_cookie_secure = _cookie_samesite == "none"
|
|
25
|
+
|
|
26
|
+
_auth_cookie_lifetime = 60 * 60 # 1 hour
|
|
27
|
+
_state_cookie_lifetime = 3 * 60 # 3m
|
|
28
|
+
_auth_cookie_name = "access_token"
|
|
29
|
+
_state_cookie_name = "oauth_state"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class OAuth2PasswordBearerWithCookie(SecurityBase):
|
|
33
|
+
"""
|
|
34
|
+
OAuth2 password flow with cookie support with fallback to bearer token.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
tokenUrl: str,
|
|
40
|
+
scheme_name: Optional[str] = None,
|
|
41
|
+
auto_error: bool = True,
|
|
42
|
+
):
|
|
43
|
+
self.tokenUrl = tokenUrl
|
|
44
|
+
self.scheme_name = scheme_name or self.__class__.__name__
|
|
45
|
+
self.auto_error = auto_error
|
|
46
|
+
|
|
47
|
+
async def __call__(self, request: Request) -> Optional[str]:
|
|
48
|
+
# First try to get the token from the cookie
|
|
49
|
+
token = request.cookies.get(_auth_cookie_name)
|
|
50
|
+
|
|
51
|
+
# If no cookie, try the Authorization header as fallback
|
|
52
|
+
if not token:
|
|
53
|
+
# TODO: Only bother to check if cookie auth is explicitly disabled.
|
|
54
|
+
authorization = request.headers.get("Authorization")
|
|
55
|
+
if authorization:
|
|
56
|
+
scheme, token = get_authorization_scheme_param(authorization)
|
|
57
|
+
if scheme.lower() != "bearer":
|
|
58
|
+
if self.auto_error:
|
|
59
|
+
raise HTTPException(
|
|
60
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
61
|
+
detail="Invalid authentication credentials",
|
|
62
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
return None
|
|
66
|
+
else:
|
|
67
|
+
if self.auto_error:
|
|
68
|
+
raise HTTPException(
|
|
69
|
+
status_code=HTTP_401_UNAUTHORIZED,
|
|
70
|
+
detail="Not authenticated",
|
|
71
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
return token
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def set_auth_cookie(response: Response, token: str):
|
|
80
|
+
"""
|
|
81
|
+
Helper function to set the authentication cookie with secure parameters
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
response.set_cookie(
|
|
85
|
+
key=_auth_cookie_name,
|
|
86
|
+
value=token,
|
|
87
|
+
httponly=True,
|
|
88
|
+
secure=_cookie_secure,
|
|
89
|
+
samesite=_cookie_samesite,
|
|
90
|
+
max_age=_auth_cookie_lifetime,
|
|
91
|
+
path="/", # Why is path set here and not below?
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clear_auth_cookie(response: Response):
|
|
96
|
+
"""
|
|
97
|
+
Helper function to clear the authentication cookie
|
|
98
|
+
"""
|
|
99
|
+
response.delete_cookie(key=_auth_cookie_name, path="/")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def set_oauth_state_cookie(response: Response, token: str):
|
|
103
|
+
response.set_cookie(
|
|
104
|
+
_state_cookie_name,
|
|
105
|
+
token,
|
|
106
|
+
httponly=True,
|
|
107
|
+
samesite=_cookie_samesite,
|
|
108
|
+
secure=_cookie_secure,
|
|
109
|
+
max_age=_state_cookie_lifetime,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def validate_oauth_state_cookie(request: Request, state: str):
|
|
114
|
+
"""Check the state from the oauth provider against the browser cookie."""
|
|
115
|
+
|
|
116
|
+
oauth_state = request.cookies.get(_state_cookie_name)
|
|
117
|
+
|
|
118
|
+
if oauth_state != state:
|
|
119
|
+
raise Exception("oauth state does not correspond")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def clear_oauth_state_cookie(response: Response):
|
|
123
|
+
"""Oauth complete, delete state token."""
|
|
124
|
+
response.delete_cookie(_state_cookie_name) # Do we set path here?
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
import jwt as pyjwt
|
|
6
|
+
|
|
7
|
+
from chainlit.config import config
|
|
8
|
+
from chainlit.user import User
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_jwt_secret() -> Optional[str]:
|
|
12
|
+
return os.environ.get("CHAINLIT_AUTH_SECRET")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def create_jwt(data: User) -> str:
|
|
16
|
+
to_encode: Dict[str, Any] = data.to_dict()
|
|
17
|
+
to_encode.update(
|
|
18
|
+
{
|
|
19
|
+
"exp": datetime.datetime.utcnow()
|
|
20
|
+
+ datetime.timedelta(seconds=config.project.user_session_timeout),
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
secret = get_jwt_secret()
|
|
24
|
+
assert secret
|
|
25
|
+
encoded_jwt = pyjwt.encode(to_encode, secret, algorithm="HS256")
|
|
26
|
+
return encoded_jwt
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decode_jwt(token: str) -> User:
|
|
30
|
+
dict = pyjwt.decode(
|
|
31
|
+
token,
|
|
32
|
+
get_jwt_secret(),
|
|
33
|
+
algorithms=["HS256"],
|
|
34
|
+
options={"verify_signature": True},
|
|
35
|
+
)
|
|
36
|
+
del dict["exp"]
|
|
37
|
+
return User(**dict)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import importlib.util
|
|
2
2
|
import os
|
|
3
3
|
import threading
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
6
|
from chainlit.config import config
|
|
6
7
|
from chainlit.logger import logger
|
|
@@ -22,7 +23,7 @@ def init_lc_cache():
|
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
|
|
25
|
-
_cache = {}
|
|
26
|
+
_cache: dict[tuple, Any] = {}
|
|
26
27
|
_cache_lock = threading.Lock()
|
|
27
28
|
|
|
28
29
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
|
3
3
|
|
|
4
|
+
from fastapi import Request, Response
|
|
5
|
+
from starlette.datastructures import Headers
|
|
6
|
+
|
|
4
7
|
from chainlit.action import Action
|
|
5
8
|
from chainlit.config import config
|
|
9
|
+
from chainlit.context import context
|
|
10
|
+
from chainlit.data.base import BaseDataLayer
|
|
6
11
|
from chainlit.message import Message
|
|
7
12
|
from chainlit.oauth_providers import get_configured_oauth_providers
|
|
8
13
|
from chainlit.step import Step, step
|
|
@@ -10,13 +15,11 @@ from chainlit.telemetry import trace
|
|
|
10
15
|
from chainlit.types import ChatProfile, Starter, ThreadDict
|
|
11
16
|
from chainlit.user import User
|
|
12
17
|
from chainlit.utils import wrap_user_function
|
|
13
|
-
from fastapi import Request, Response
|
|
14
|
-
from starlette.datastructures import Headers
|
|
15
18
|
|
|
16
19
|
|
|
17
20
|
@trace
|
|
18
21
|
def password_auth_callback(
|
|
19
|
-
func: Callable[[str, str], Awaitable[Optional[User]]]
|
|
22
|
+
func: Callable[[str, str], Awaitable[Optional[User]]],
|
|
20
23
|
) -> Callable:
|
|
21
24
|
"""
|
|
22
25
|
Framework agnostic decorator to authenticate the user.
|
|
@@ -38,7 +41,7 @@ def password_auth_callback(
|
|
|
38
41
|
|
|
39
42
|
@trace
|
|
40
43
|
def header_auth_callback(
|
|
41
|
-
func: Callable[[Headers], Awaitable[Optional[User]]]
|
|
44
|
+
func: Callable[[Headers], Awaitable[Optional[User]]],
|
|
42
45
|
) -> Callable:
|
|
43
46
|
"""
|
|
44
47
|
Framework agnostic decorator to authenticate the user via a header
|
|
@@ -123,6 +126,33 @@ def on_message(func: Callable) -> Callable:
|
|
|
123
126
|
return func
|
|
124
127
|
|
|
125
128
|
|
|
129
|
+
@trace
|
|
130
|
+
async def send_window_message(data: Any):
|
|
131
|
+
"""
|
|
132
|
+
Send custom data to the host window via a window.postMessage event.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
data (Any): The data to send with the event.
|
|
136
|
+
"""
|
|
137
|
+
await context.emitter.send_window_message(data)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@trace
|
|
141
|
+
def on_window_message(func: Callable[[str], Any]) -> Callable:
|
|
142
|
+
"""
|
|
143
|
+
Hook to react to javascript postMessage events coming from the UI.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
func (Callable[[str], Any]): The function to be called when a window message is received.
|
|
147
|
+
Takes the message content as a string parameter.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Callable[[str], Any]: The decorated on_window_message function.
|
|
151
|
+
"""
|
|
152
|
+
config.code.on_window_message = wrap_user_function(func)
|
|
153
|
+
return func
|
|
154
|
+
|
|
155
|
+
|
|
126
156
|
@trace
|
|
127
157
|
def on_chat_start(func: Callable) -> Callable:
|
|
128
158
|
"""
|
|
@@ -177,7 +207,7 @@ def set_chat_profiles(
|
|
|
177
207
|
|
|
178
208
|
@trace
|
|
179
209
|
def set_starters(
|
|
180
|
-
func: Callable[[Optional["User"]], Awaitable[List["Starter"]]]
|
|
210
|
+
func: Callable[[Optional["User"]], Awaitable[List["Starter"]]],
|
|
181
211
|
) -> Callable:
|
|
182
212
|
"""
|
|
183
213
|
Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup).
|
|
@@ -221,6 +251,7 @@ def on_audio_start(func: Callable) -> Callable:
|
|
|
221
251
|
config.code.on_audio_start = wrap_user_function(func, with_task=False)
|
|
222
252
|
return func
|
|
223
253
|
|
|
254
|
+
|
|
224
255
|
@trace
|
|
225
256
|
def on_audio_chunk(func: Callable) -> Callable:
|
|
226
257
|
"""
|
|
@@ -254,7 +285,7 @@ def on_audio_end(func: Callable) -> Callable:
|
|
|
254
285
|
|
|
255
286
|
@trace
|
|
256
287
|
def author_rename(
|
|
257
|
-
func: Callable[[str], Awaitable[str]]
|
|
288
|
+
func: Callable[[str], Awaitable[str]],
|
|
258
289
|
) -> Callable[[str], Awaitable[str]]:
|
|
259
290
|
"""
|
|
260
291
|
Useful to rename the author of message to display more friendly author names in the UI.
|
|
@@ -315,3 +346,17 @@ def on_settings_update(
|
|
|
315
346
|
|
|
316
347
|
config.code.on_settings_update = wrap_user_function(func, with_task=True)
|
|
317
348
|
return func
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def data_layer(
|
|
352
|
+
func: Callable[[], BaseDataLayer],
|
|
353
|
+
) -> Callable[[], BaseDataLayer]:
|
|
354
|
+
"""
|
|
355
|
+
Hook to configure custom data layer.
|
|
356
|
+
"""
|
|
357
|
+
|
|
358
|
+
# We don't use wrap_user_function here because:
|
|
359
|
+
# 1. We don't need to support async here and;
|
|
360
|
+
# 2. We don't want to change the API for get_data_layer() to be async, everywhere (at this point).
|
|
361
|
+
config.code.data_layer = func
|
|
362
|
+
return func
|
|
@@ -25,10 +25,10 @@ class ChatContext:
|
|
|
25
25
|
|
|
26
26
|
if context.session.id not in chat_contexts:
|
|
27
27
|
chat_contexts[context.session.id] = []
|
|
28
|
-
|
|
28
|
+
|
|
29
29
|
if message not in chat_contexts[context.session.id]:
|
|
30
30
|
chat_contexts[context.session.id].append(message)
|
|
31
|
-
|
|
31
|
+
|
|
32
32
|
return message
|
|
33
33
|
|
|
34
34
|
def remove(self, message: "Message") -> bool:
|
|
@@ -9,6 +9,7 @@ import uvicorn
|
|
|
9
9
|
nest_asyncio.apply()
|
|
10
10
|
|
|
11
11
|
# ruff: noqa: E402
|
|
12
|
+
from chainlit.auth import ensure_jwt_secret
|
|
12
13
|
from chainlit.cache import init_lc_cache
|
|
13
14
|
from chainlit.config import (
|
|
14
15
|
BACKEND_ROOT,
|
|
@@ -24,7 +25,18 @@ from chainlit.logger import logger
|
|
|
24
25
|
from chainlit.markdown import init_markdown
|
|
25
26
|
from chainlit.secret import random_secret
|
|
26
27
|
from chainlit.telemetry import trace_event
|
|
27
|
-
from chainlit.utils import check_file
|
|
28
|
+
from chainlit.utils import check_file
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def assert_app():
|
|
32
|
+
if (
|
|
33
|
+
not config.code.on_chat_start
|
|
34
|
+
and not config.code.on_message
|
|
35
|
+
and not config.code.on_audio_chunk
|
|
36
|
+
):
|
|
37
|
+
raise Exception(
|
|
38
|
+
"You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback"
|
|
39
|
+
)
|
|
28
40
|
|
|
29
41
|
|
|
30
42
|
# Create the main command group for Chainlit CLI
|
|
@@ -66,6 +78,7 @@ def run_chainlit(target: str):
|
|
|
66
78
|
load_module(config.run.module_name)
|
|
67
79
|
|
|
68
80
|
ensure_jwt_secret()
|
|
81
|
+
assert_app()
|
|
69
82
|
|
|
70
83
|
# Create the chainlit.md file if it doesn't exist
|
|
71
84
|
init_markdown(config.root)
|
|
@@ -17,22 +17,30 @@ from typing import (
|
|
|
17
17
|
)
|
|
18
18
|
|
|
19
19
|
import tomli
|
|
20
|
+
from dataclasses_json import DataClassJsonMixin
|
|
21
|
+
from pydantic import Field
|
|
22
|
+
from pydantic.dataclasses import dataclass
|
|
23
|
+
from starlette.datastructures import Headers
|
|
24
|
+
|
|
25
|
+
from chainlit.data.base import BaseDataLayer
|
|
20
26
|
from chainlit.logger import logger
|
|
21
27
|
from chainlit.translations import lint_translation_json
|
|
22
28
|
from chainlit.version import __version__
|
|
23
|
-
from dataclasses_json import DataClassJsonMixin
|
|
24
|
-
from pydantic.dataclasses import Field, dataclass
|
|
25
|
-
from starlette.datastructures import Headers
|
|
26
29
|
|
|
27
30
|
from ._utils import is_path_inside
|
|
28
31
|
|
|
29
32
|
if TYPE_CHECKING:
|
|
33
|
+
from fastapi import Request, Response
|
|
34
|
+
|
|
30
35
|
from chainlit.action import Action
|
|
31
36
|
from chainlit.message import Message
|
|
32
|
-
from chainlit.types import
|
|
37
|
+
from chainlit.types import ChatProfile, InputAudioChunk, Starter, ThreadDict
|
|
33
38
|
from chainlit.user import User
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
else:
|
|
40
|
+
# Pydantic needs to resolve forward annotations. Because all of these are used
|
|
41
|
+
# within `typing.Callable`, alias to `Any` as Pydantic does not perform validation
|
|
42
|
+
# of callable argument/return types anyway.
|
|
43
|
+
Request = Response = Action = Message = ChatProfile = InputAudioChunk = Starter = ThreadDict = User = Any # fmt: off
|
|
36
44
|
|
|
37
45
|
BACKEND_ROOT = os.path.dirname(__file__)
|
|
38
46
|
PACKAGE_ROOT = os.path.dirname(os.path.dirname(BACKEND_ROOT))
|
|
@@ -87,6 +95,9 @@ auto_tag_thread = true
|
|
|
87
95
|
# Allow users to edit their own messages
|
|
88
96
|
edit_message = true
|
|
89
97
|
|
|
98
|
+
# Use httponly cookie for client->server authentication, required to be able to use file upload and elements.
|
|
99
|
+
cookie_auth = true
|
|
100
|
+
|
|
90
101
|
# Authorize users to spontaneously upload files with messages
|
|
91
102
|
[features.spontaneous_file_upload]
|
|
92
103
|
enabled = true
|
|
@@ -272,9 +283,9 @@ class CodeSettings:
|
|
|
272
283
|
password_auth_callback: Optional[
|
|
273
284
|
Callable[[str, str], Awaitable[Optional["User"]]]
|
|
274
285
|
] = None
|
|
275
|
-
header_auth_callback: Optional[
|
|
276
|
-
|
|
277
|
-
|
|
286
|
+
header_auth_callback: Optional[Callable[[Headers], Awaitable[Optional["User"]]]] = (
|
|
287
|
+
None
|
|
288
|
+
)
|
|
278
289
|
oauth_callback: Optional[
|
|
279
290
|
Callable[[str, str, Dict[str, str], "User"], Awaitable[Optional["User"]]]
|
|
280
291
|
] = None
|
|
@@ -284,6 +295,7 @@ class CodeSettings:
|
|
|
284
295
|
on_chat_end: Optional[Callable[[], Any]] = None
|
|
285
296
|
on_chat_resume: Optional[Callable[["ThreadDict"], Any]] = None
|
|
286
297
|
on_message: Optional[Callable[["Message"], Any]] = None
|
|
298
|
+
on_window_message: Optional[Callable[[str], Any]] = None
|
|
287
299
|
on_audio_start: Optional[Callable[[], Any]] = None
|
|
288
300
|
on_audio_chunk: Optional[Callable[["InputAudioChunk"], Any]] = None
|
|
289
301
|
on_audio_end: Optional[Callable[[], Any]] = None
|
|
@@ -293,14 +305,17 @@ class CodeSettings:
|
|
|
293
305
|
set_chat_profiles: Optional[
|
|
294
306
|
Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]
|
|
295
307
|
] = None
|
|
296
|
-
set_starters: Optional[
|
|
297
|
-
|
|
298
|
-
|
|
308
|
+
set_starters: Optional[Callable[[Optional["User"]], Awaitable[List["Starter"]]]] = (
|
|
309
|
+
None
|
|
310
|
+
)
|
|
311
|
+
data_layer: Optional[Callable[[], BaseDataLayer]] = None
|
|
299
312
|
|
|
300
313
|
|
|
301
314
|
@dataclass()
|
|
302
315
|
class ProjectSettings(DataClassJsonMixin):
|
|
303
316
|
allow_origins: List[str] = Field(default_factory=lambda: ["*"])
|
|
317
|
+
# Socket.io client transports option
|
|
318
|
+
transports: Optional[List[str]] = None
|
|
304
319
|
enable_telemetry: bool = True
|
|
305
320
|
# List of environment variables to be provided by each user to use the app. If empty, no environment variables will be asked to the user.
|
|
306
321
|
user_env: Optional[List[str]] = None
|
|
@@ -315,6 +330,8 @@ class ProjectSettings(DataClassJsonMixin):
|
|
|
315
330
|
cache: bool = False
|
|
316
331
|
# Follow symlink for asset mount (see https://github.com/Chainlit/chainlit/issues/317)
|
|
317
332
|
follow_symlink: bool = False
|
|
333
|
+
# Use httponly cookie for client->server authentication, required to be able to use file upload and elements.
|
|
334
|
+
cookie_auth: bool = True
|
|
318
335
|
|
|
319
336
|
|
|
320
337
|
@dataclass()
|
|
@@ -395,7 +412,7 @@ def init_config(log=False):
|
|
|
395
412
|
dst = os.path.join(config_translation_dir, file)
|
|
396
413
|
if not os.path.exists(dst):
|
|
397
414
|
src = os.path.join(TRANSLATIONS_DIR, file)
|
|
398
|
-
with open(src,
|
|
415
|
+
with open(src, encoding="utf-8") as f:
|
|
399
416
|
translation = json.load(f)
|
|
400
417
|
with open(dst, "w", encoding="utf-8") as f:
|
|
401
418
|
json.dump(translation, f, indent=4)
|
|
@@ -510,7 +527,7 @@ def load_config():
|
|
|
510
527
|
def lint_translations():
|
|
511
528
|
# Load the ground truth (en-US.json file from chainlit source code)
|
|
512
529
|
src = os.path.join(TRANSLATIONS_DIR, "en-US.json")
|
|
513
|
-
with open(src,
|
|
530
|
+
with open(src, encoding="utf-8") as f:
|
|
514
531
|
truth = json.load(f)
|
|
515
532
|
|
|
516
533
|
# Find the local app translations
|
|
@@ -518,7 +535,7 @@ def lint_translations():
|
|
|
518
535
|
if file.endswith(".json"):
|
|
519
536
|
# Load the translation file
|
|
520
537
|
to_lint = os.path.join(config_translation_dir, file)
|
|
521
|
-
with open(to_lint,
|
|
538
|
+
with open(to_lint, encoding="utf-8") as f:
|
|
522
539
|
translation = json.load(f)
|
|
523
540
|
|
|
524
541
|
# Lint the translation file
|