cycls 0.0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cycls-0.0.1.0/LICENSE +19 -0
- cycls-0.0.1.0/PKG-INFO +17 -0
- cycls-0.0.1.0/cycls/UI.py +26 -0
- cycls-0.0.1.0/cycls/__init__.py +3 -0
- cycls-0.0.1.0/cycls/client.py +160 -0
- cycls-0.0.1.0/cycls/configuration.py +27 -0
- cycls-0.0.1.0/cycls/static.py +3 -0
- cycls-0.0.1.0/cycls/typings.py +119 -0
- cycls-0.0.1.0/pyproject.toml +25 -0
cycls-0.0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (c) 2018 The Python Packaging Authority
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
SOFTWARE.
|
cycls-0.0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: cycls
|
|
3
|
+
Version: 0.0.1.0
|
|
4
|
+
Summary: Publish your chat app with cycls
|
|
5
|
+
Author: Khalid Alrasheed
|
|
6
|
+
Author-email: khalid@cycls.io
|
|
7
|
+
Requires-Python: >=3.8,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Requires-Dist: asyncio (>=3.4.3,<4.0.0)
|
|
14
|
+
Requires-Dist: certifi (>=2023.11.17,<2024.0.0)
|
|
15
|
+
Requires-Dist: httpx (>=0.25.2,<0.26.0)
|
|
16
|
+
Requires-Dist: pydantic (>=2.5.2,<3.0.0)
|
|
17
|
+
Requires-Dist: python-socketio[asyncio-client] (>=5.10.0,<6.0.0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
class UITypes(str, Enum):
|
|
8
|
+
Text = "text"
|
|
9
|
+
Image = "image"
|
|
10
|
+
|
|
11
|
+
class Text(BaseModel):
|
|
12
|
+
text: str
|
|
13
|
+
type:UITypes = UITypes.Text
|
|
14
|
+
meta:dict[str, Any] | None = None
|
|
15
|
+
|
|
16
|
+
def __init__(self, text:str, type:UITypes = UITypes.Text, meta=None):
|
|
17
|
+
super().__init__(text=text, meta=meta)
|
|
18
|
+
|
|
19
|
+
class Image(BaseModel):
|
|
20
|
+
image : str
|
|
21
|
+
type :UITypes = UITypes.Image
|
|
22
|
+
meta : dict[str, Any] | None = None
|
|
23
|
+
|
|
24
|
+
def __init__(self, image_url:str, type:UITypes = UITypes.Image, meta=None):
|
|
25
|
+
super().__init__(Image=image_url, meta=meta)
|
|
26
|
+
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from inspect import iscoroutinefunction, signature, Parameter, _empty
|
|
3
|
+
from typing import Callable, Any
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from socketio import AsyncClient
|
|
8
|
+
import asyncio
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
from .UI import Text, Image
|
|
12
|
+
from .configuration import AppConfiguration
|
|
13
|
+
from .typings import (
|
|
14
|
+
InputTypeHint,
|
|
15
|
+
UserMessage,
|
|
16
|
+
ConversationID,
|
|
17
|
+
ConversationSession,
|
|
18
|
+
Meta,
|
|
19
|
+
Response,
|
|
20
|
+
Message,
|
|
21
|
+
MessageContent
|
|
22
|
+
)
|
|
23
|
+
from .static import HANDLER_PATTERN, CYCLS_URL
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Cycls:
|
|
27
|
+
def __init__(self, key: str | None = None):
|
|
28
|
+
self.key = key
|
|
29
|
+
self.sio = AsyncClient(
|
|
30
|
+
reconnection_attempts=0,
|
|
31
|
+
reconnection_delay=1,
|
|
32
|
+
reconnection_delay_max=25,
|
|
33
|
+
logger=True,
|
|
34
|
+
)
|
|
35
|
+
self.apps_config: list[AppConfiguration] = []
|
|
36
|
+
self.sio.on("connect")(self.re_connect)
|
|
37
|
+
self.sio.on("connection_log")(self.connection_log)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def re_connect(self):
|
|
41
|
+
for app in self.apps_config:
|
|
42
|
+
await self.sio.emit("connect_app", data=app.model_dump(mode="json"))
|
|
43
|
+
print("CONNECTING")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def _run(self):
|
|
47
|
+
headers = {
|
|
48
|
+
"x-dev-secret": self.key,
|
|
49
|
+
}
|
|
50
|
+
await self.sio.connect(
|
|
51
|
+
CYCLS_URL,
|
|
52
|
+
headers=headers,
|
|
53
|
+
transports=["websocket"],
|
|
54
|
+
socketio_path="/app-socket/socket.io",
|
|
55
|
+
)
|
|
56
|
+
await self.sio.wait()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def publish(self):
|
|
60
|
+
asyncio.run(self._run())
|
|
61
|
+
|
|
62
|
+
def _process_response(self, response):
|
|
63
|
+
if isinstance(response, Response):
|
|
64
|
+
return response
|
|
65
|
+
elif isinstance(response, Text) or isinstance(response, Image):
|
|
66
|
+
return Response(messages=[response])
|
|
67
|
+
elif isinstance(response, list):
|
|
68
|
+
return Response(messages=response)
|
|
69
|
+
return Response(messages=[response])
|
|
70
|
+
|
|
71
|
+
def _parameter_type_hint(self, param: Parameter) -> InputTypeHint:
|
|
72
|
+
hint = param.annotation
|
|
73
|
+
mapping = {
|
|
74
|
+
_empty: InputTypeHint.EMPTY,
|
|
75
|
+
Message: InputTypeHint.MESSAGE,
|
|
76
|
+
ConversationSession: InputTypeHint.SESSION,
|
|
77
|
+
ConversationID: InputTypeHint.CONVERSATION_ID,
|
|
78
|
+
UserMessage: InputTypeHint.FULL,
|
|
79
|
+
MessageContent: InputTypeHint.MESSAGE_CONTENT,
|
|
80
|
+
}
|
|
81
|
+
if output := mapping.get(hint):
|
|
82
|
+
return output
|
|
83
|
+
else:
|
|
84
|
+
raise Exception("")
|
|
85
|
+
|
|
86
|
+
def _get_parameter_value(self, hint: InputTypeHint, obj: user_message) -> Any:
|
|
87
|
+
if hint == InputTypeHint.MESSAGE_CONTENT:
|
|
88
|
+
return obj.message.content
|
|
89
|
+
elif hint == InputTypeHint.SESSION:
|
|
90
|
+
return obj.session
|
|
91
|
+
elif hint == InputTypeHint.CONVERSATION_ID:
|
|
92
|
+
return obj.session.id
|
|
93
|
+
elif hint == InputTypeHint.MESSAGE:
|
|
94
|
+
return obj.message
|
|
95
|
+
elif hint == InputTypeHint.USER:
|
|
96
|
+
return None
|
|
97
|
+
elif hint == InputTypeHint.EMPTY or hint == InputTypeHint.FULL:
|
|
98
|
+
return obj
|
|
99
|
+
|
|
100
|
+
def process_handler_input(self, func: Callable, message: UserMessage):
|
|
101
|
+
kwargs = {}
|
|
102
|
+
for key, value in signature(func).parameters.items():
|
|
103
|
+
type_hint = self._parameter_type_hint(value)
|
|
104
|
+
kwargs[key] = self._get_parameter_value(hint=type_hint, obj=message)
|
|
105
|
+
return kwargs
|
|
106
|
+
|
|
107
|
+
def extract_handler_name(self, handler: str) -> str:
|
|
108
|
+
name = re.search(rf"^\@({HANDLER_PATTERN})$", handler.strip().lower())
|
|
109
|
+
if not name:
|
|
110
|
+
raise Exception(
|
|
111
|
+
"Your app handler has to start with @ and composed only of letters, numbers and '-'"
|
|
112
|
+
)
|
|
113
|
+
name = name.group(1)
|
|
114
|
+
return re.sub(r"_", "-", name)
|
|
115
|
+
|
|
116
|
+
def app(
|
|
117
|
+
self,
|
|
118
|
+
handler: str,
|
|
119
|
+
name: str | None = None,
|
|
120
|
+
image: str | None = None,
|
|
121
|
+
introduction: str| None = None,
|
|
122
|
+
suggestions: list[str] | None = None
|
|
123
|
+
):
|
|
124
|
+
""" """
|
|
125
|
+
app_handler = self.extract_handler_name(handler)
|
|
126
|
+
config = AppConfiguration(
|
|
127
|
+
handler=app_handler,
|
|
128
|
+
name=name,
|
|
129
|
+
image=image,
|
|
130
|
+
introduction=introduction,
|
|
131
|
+
suggestions=suggestions
|
|
132
|
+
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def decorator(func):
|
|
136
|
+
self.apps_config.append(config)
|
|
137
|
+
|
|
138
|
+
@self.sio.on(app_handler)
|
|
139
|
+
async def wrapper(data):
|
|
140
|
+
try:
|
|
141
|
+
message = UserMessage(sio=self.sio, **data)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
print(e)
|
|
144
|
+
print(f"we got error while trying to process {data}")
|
|
145
|
+
return Response(messages=[Text("something went wrong")]).model_dump(
|
|
146
|
+
mode="json"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
await func(**self.process_handler_input(func, message))
|
|
150
|
+
await message.send.send_message(None, message.send.user_message_id, True, "finish")
|
|
151
|
+
return 200
|
|
152
|
+
|
|
153
|
+
return wrapper
|
|
154
|
+
|
|
155
|
+
return decorator
|
|
156
|
+
|
|
157
|
+
async def connection_log(self, data):
|
|
158
|
+
print(
|
|
159
|
+
f"{datetime.now()}: HANDLER|{data.get('handler')} -> {data.get('message')}. STATUS: {data.get('status')}"
|
|
160
|
+
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pydantic import BaseModel, field_validator
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AppConfiguration(BaseModel):
|
|
6
|
+
handler:str
|
|
7
|
+
name: str | None = None
|
|
8
|
+
image: str | None = None
|
|
9
|
+
introduction: str| None = None
|
|
10
|
+
suggestions: list[str] | None = None
|
|
11
|
+
|
|
12
|
+
@field_validator("suggestions", mode="before")
|
|
13
|
+
@classmethod
|
|
14
|
+
def process_suggestions(cls, suggestions:list[str] | None) -> list[str] | None:
|
|
15
|
+
if not suggestions:
|
|
16
|
+
return None
|
|
17
|
+
elif isinstance(suggestions, list):
|
|
18
|
+
if len(suggestions) <= 4:
|
|
19
|
+
return suggestions
|
|
20
|
+
else:
|
|
21
|
+
raise Exception(f"suggestions can be at max 4")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# @field_validator("image", mode="before")
|
|
25
|
+
# @classmethod
|
|
26
|
+
# def process_image(cls, image:str | None) -> str:
|
|
27
|
+
# return image
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
from enum import Enum, auto
|
|
2
|
+
from pydantic import BaseModel, field_validator
|
|
3
|
+
from typing import Any, TypeVar
|
|
4
|
+
from cycls import UI
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from socketio import AsyncClient
|
|
7
|
+
import uuid
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
|
|
10
|
+
MessageContent = TypeVar('MessageContent',bound=UI.Text| UI.Image| None)
|
|
11
|
+
class InputTypeHint(Enum):
|
|
12
|
+
EMPTY = auto()
|
|
13
|
+
MESSAGE = auto()
|
|
14
|
+
CONVERSATION_ID = auto()
|
|
15
|
+
USER = auto()
|
|
16
|
+
SESSION = auto()
|
|
17
|
+
FULL = auto()
|
|
18
|
+
MESSAGE_CONTENT = auto()
|
|
19
|
+
|
|
20
|
+
class MessageRole(Enum):
|
|
21
|
+
ASSISTANT = "ASSISTANT"
|
|
22
|
+
USER = "user"
|
|
23
|
+
CYCLS = "cycls"
|
|
24
|
+
|
|
25
|
+
class Message(BaseModel):
|
|
26
|
+
id: str
|
|
27
|
+
created_at: datetime
|
|
28
|
+
content: MessageContent
|
|
29
|
+
role: MessageRole = MessageRole.ASSISTANT
|
|
30
|
+
meta: dict[str, Any] | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@field_validator("content", mode="before")
|
|
34
|
+
@classmethod
|
|
35
|
+
def create_content(cls, values: dict[str, Any]) :
|
|
36
|
+
content_type = values.get("type")
|
|
37
|
+
if content_type == "text":
|
|
38
|
+
return UI.Text(**values)
|
|
39
|
+
elif content_type == "image":
|
|
40
|
+
return UI.Image(**values)
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Unknown content type: {content_type}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
Meta = dict[str, Any]
|
|
46
|
+
ConversationID = str
|
|
47
|
+
|
|
48
|
+
class ConversationSession(BaseModel):
|
|
49
|
+
id : str
|
|
50
|
+
|
|
51
|
+
async def get_history(self):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Response(BaseModel):
|
|
59
|
+
messages: list[UI.Text| UI.Image]
|
|
60
|
+
meta: dict[str, Any] | None = None
|
|
61
|
+
|
|
62
|
+
class send_base:
|
|
63
|
+
def __init__(self, sio:AsyncClient, user_message_id) -> None:
|
|
64
|
+
self.sio = sio
|
|
65
|
+
self.user_message_id = user_message_id
|
|
66
|
+
async def send_message(self, content, id, stream:bool=False, finish_reason:str|None=None):
|
|
67
|
+
if isinstance(content, BaseModel):
|
|
68
|
+
content = content.model_dump(mode="json", exclude_none=True)
|
|
69
|
+
await self.sio.emit(
|
|
70
|
+
"response",
|
|
71
|
+
{
|
|
72
|
+
"content":content,
|
|
73
|
+
"id": id,
|
|
74
|
+
"user_message_id": self.user_message_id,
|
|
75
|
+
"finish_reason": finish_reason,
|
|
76
|
+
"stream":stream
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
class Send(send_base):
|
|
81
|
+
async def text(self, message):
|
|
82
|
+
id = str(uuid.uuid4())
|
|
83
|
+
content = UI.Text(text=message)
|
|
84
|
+
await self.send_message(content=content, id=id)
|
|
85
|
+
|
|
86
|
+
class SendStream(send_base):
|
|
87
|
+
@asynccontextmanager
|
|
88
|
+
async def text(self):
|
|
89
|
+
id = str(uuid.uuid4())
|
|
90
|
+
|
|
91
|
+
async def send(chunk):
|
|
92
|
+
await self.send_message(UI.Text(text=chunk), id, True)
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
yield send
|
|
96
|
+
except:
|
|
97
|
+
await self.send_message(None, id, True, "error")
|
|
98
|
+
finally:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
class userMessage(BaseModel):
|
|
102
|
+
message: Message
|
|
103
|
+
session : ConversationSession
|
|
104
|
+
meta: dict[str, Any] | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class UserMessage:
|
|
108
|
+
message: Message
|
|
109
|
+
session : ConversationSession
|
|
110
|
+
meta: dict[str, Any] | None = None
|
|
111
|
+
send:Send
|
|
112
|
+
stream:SendStream
|
|
113
|
+
def __init__(self, message, session, meta, sio):
|
|
114
|
+
m = userMessage(message=message, session=session, meta=meta)
|
|
115
|
+
self.message = m.message
|
|
116
|
+
self.session = m.session
|
|
117
|
+
self.meta = m.meta
|
|
118
|
+
self.send = Send(sio=sio, user_message_id=self.message.id)
|
|
119
|
+
self.stream = SendStream(sio=sio, user_message_id=self.message.id)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "cycls"
|
|
3
|
+
version = "0.0.1.0"
|
|
4
|
+
description = "Publish your chat app with cycls"
|
|
5
|
+
authors = ["Khalid Alrasheed <khalid@cycls.io>"]
|
|
6
|
+
packages = [{ include = "cycls" }]
|
|
7
|
+
|
|
8
|
+
[tool.poetry.dependencies]
|
|
9
|
+
python = "^3.8"
|
|
10
|
+
asyncio = "^3.4.3"
|
|
11
|
+
certifi = "^2023.11.17"
|
|
12
|
+
httpx = "^0.25.2"
|
|
13
|
+
pydantic = "^2.5.2"
|
|
14
|
+
python-socketio = { extras = ["asyncio-client"], version = "^5.10.0" }
|
|
15
|
+
|
|
16
|
+
[tool.poetry.group.dev.dependencies]
|
|
17
|
+
black = { version = "^23.11.0", allow-prereleases = true }
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["poetry-core"]
|
|
21
|
+
build-backend = "poetry.core.masonry.api"
|
|
22
|
+
|
|
23
|
+
[tool.pyright]
|
|
24
|
+
venvPath = "."
|
|
25
|
+
venv = ".venv"
|