mmar-mcli 1.0.6__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 AIRI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: mmar-mcli
3
+ Version: 1.0.6
4
+ Summary: Client to Maestro
5
+ Keywords:
6
+ Author: tagin
7
+ Author-email: tagin <tagin@airi.net>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Documentation
19
+ Classifier: Topic :: Software Development
20
+ Classifier: Topic :: Utilities
21
+ Classifier: Typing :: Typed
22
+ Requires-Dist: aiohttp==3.12.15
23
+ Requires-Dist: loguru==0.7.3
24
+ Requires-Dist: mmar-mapi~=1.2.5
25
+ Requires-Dist: mmar-utils~=1.1.3
26
+ Requires-Python: >=3.12
27
+ Description-Content-Type: text/markdown
28
+
29
+ # MMAR Maestro Client
@@ -0,0 +1 @@
1
+ # MMAR Maestro Client
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "mmar-mcli"
3
+ # dynamic version is not supported yet on uv_build
4
+ version = "1.0.6"
5
+ description = "Client to Maestro"
6
+ authors = [{name = "tagin", email = "tagin@airi.net"}]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ keywords = []
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "Programming Language :: Python",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Programming Language :: Python :: 3.14",
21
+ "Topic :: Documentation",
22
+ "Topic :: Software Development",
23
+ "Topic :: Utilities",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = [
27
+ "aiohttp==3.12.15",
28
+ "loguru==0.7.3",
29
+
30
+ "mmar_mapi~=1.2.5",
31
+ "mmar_utils~=1.1.3",
32
+ ]
33
+
34
+ [build-system]
35
+ requires = ["uv_build>=0.8.14,<0.9.0"]
36
+ build-backend = "uv_build"
37
+
38
+ [tool.uv.build-backend]
39
+ module-name = "mmar_mcli"
40
+ source-exclude = [".ruff_cache"]
41
+
42
+ [dependency-groups]
43
+ ci = [
44
+ "ruff>=0.4",
45
+ "pytest>=8.2",
46
+ "pytest-asyncio>=1.0.0",
47
+ ]
48
+
49
+ [tool.uv]
50
+ default-groups = ["ci"]
51
+
52
+ [tool.ruff]
53
+ line-length = 120
54
+
55
+ [tool.pytest.ini_options]
56
+ asyncio_mode = "auto"
57
+
58
+ [pytest]
59
+ python_files = [ "test_*.py" ]
60
+ testpaths = [ "tests" ]
61
+
62
+ # action:message_regex:warning_class:module_regex:line
63
+ filterwarnings = [ "error" ]
64
+
65
+ [mypy]
66
+ ignore_missing_imports = true
67
+ exclude = "tests/fixtures/"
68
+ warn_unused_ignores = true
69
+ show_error_codes = true
@@ -0,0 +1,5 @@
1
+ from mmar_mcli.maestro_client import MaestroClient, MESSAGE_START
2
+ from mmar_mcli.maestro_client_dummy import MaestroClientDummy
3
+ from mmar_mcli.models import FileData, FileName, MessageData
4
+
5
+ __all__ = [MaestroClient, MaestroClientDummy, FileName, FileData, MessageData, MESSAGE_START]
@@ -0,0 +1,55 @@
1
+ import asyncio
2
+ from mimetypes import guess_type
3
+
4
+ from aiohttp import ClientConnectionError, ClientError, ClientResponseError, ClientSession, ClientTimeout, FormData
5
+ from loguru import logger
6
+ from mmar_utils import on_error_log_and_none, retry_on_ex
7
+
8
+ from mmar_mcli.models import FileData
9
+
10
+ POST_ERRORS = (asyncio.TimeoutError, ClientConnectionError, ClientResponseError)
11
+
12
+
13
+ def make_file_form_data(file_data: FileData) -> FormData:
14
+ fd = FormData()
15
+ fname, fbytes = file_data
16
+ content_type = guess_type(fname)[0] or "application/octet-stream"
17
+ fd.add_field(name="file", filename=fname, content_type=content_type, value=fbytes)
18
+ return fd
19
+
20
+
21
+ @on_error_log_and_none(logger.exception)
22
+ @retry_on_ex(attempts=3, wait_seconds=1, catch=POST_ERRORS, logger=logger.warning)
23
+ async def request_with_session(
24
+ *,
25
+ method: str,
26
+ url: str,
27
+ json: dict | None = None,
28
+ headers: dict[str, str] | None = None,
29
+ params: dict[str, str] | None = None,
30
+ timeout: int | None = None,
31
+ data: FormData | None = None,
32
+ headers_extra: dict[str, str] | None = None,
33
+ ) -> bytes | dict:
34
+ headers_all = headers | (headers_extra or {})
35
+ async with ClientSession(headers={}) as session:
36
+ timeout_ = ClientTimeout(timeout) if isinstance(timeout, int) else None
37
+ async with session.request(
38
+ method=method,
39
+ url=url,
40
+ json=json,
41
+ headers=headers_all,
42
+ params=params,
43
+ timeout=timeout_,
44
+ data=data,
45
+ ) as resp:
46
+ content_type = resp.headers.get("Content-Type", "").lower()
47
+ if "application/json" in content_type:
48
+ body: dict = await resp.json()
49
+ try:
50
+ resp.raise_for_status()
51
+ except Exception as ex:
52
+ raise ClientError(f"{ex}\nResponse body: {body}") from ex
53
+ return body
54
+ else:
55
+ return await resp.read()
@@ -0,0 +1,150 @@
1
+ import time
2
+ from functools import cache, partial
3
+ from types import SimpleNamespace
4
+
5
+ from aiohttp import FormData
6
+ from loguru import logger
7
+ from mmar_mapi import AIMessage, Context, FileStorage, HumanMessage, ResourceId, make_content
8
+ from mmar_utils import remove_prefix_if_present
9
+
10
+ from mmar_mcli.io_aiohttp import make_file_form_data, request_with_session
11
+ from mmar_mcli.models import FileData, MaestroConfig, MessageData, RequestCall
12
+
13
+ ROUTES = SimpleNamespace(
14
+ send="api/v0/send",
15
+ download="api/v2/files/download_bytes",
16
+ upload="api/v2/files/upload",
17
+ )
18
+
19
+
20
+ MESSAGE_START: MessageData = make_content(text="/start"), None
21
+
22
+
23
+ def fix_maestro_address(maestro_address: str) -> str:
24
+ if not maestro_address.startswith("https:"):
25
+ if maestro_address.startswith(":"):
26
+ maestro_address = f"localhost{maestro_address}"
27
+ maestro_address = "http://" + remove_prefix_if_present(maestro_address, "http://")
28
+ return maestro_address
29
+
30
+
31
+ class RemoteStorager:
32
+ def __init__(self, request: RequestCall, url_base: str, client_id: str):
33
+ self.request = request
34
+ self.url_download = url_base.replace(ROUTES.send, ROUTES.download)
35
+ self.url_upload = url_base.replace(ROUTES.send, ROUTES.upload)
36
+ self.client_id = client_id
37
+ self.headers = {"client-id": client_id}
38
+
39
+ async def upload_async(self, content: bytes | str, fname: str) -> ResourceId:
40
+ content = content if isinstance(content, bytes) else content.encode()
41
+ data: FormData = make_file_form_data((fname, content))
42
+ response_data = await self.request(method="post", url=self.url_upload, headers=self.headers, data=data)
43
+ if not isinstance(response_data, dict):
44
+ raise ValueError(f"POST {self.url_upload}: expected json, found {type(response_data)}")
45
+ resource_id: str = response_data["ResourceId"]
46
+ return resource_id
47
+
48
+ async def download_async(self, resource_id: str) -> bytes:
49
+ params = dict(resource_id=resource_id)
50
+ resource_bytes = await self.request(method="get", url=self.url_download, params=params, headers=self.headers)
51
+ if not isinstance(resource_bytes, bytes):
52
+ raise ValueError(f"GET {self.url_download}: expected bytes, found {type(resource_bytes)}")
53
+ return resource_bytes
54
+
55
+
56
+ class MaestroClientI:
57
+ async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
58
+ pass
59
+
60
+ async def upload_resource(self, file_data: FileData, client_id: str) -> str | None:
61
+ pass
62
+
63
+ async def download_resource(self, resource_id: str, client_id: str) -> bytes:
64
+ pass
65
+
66
+
67
+ class MaestroClient(MaestroClientI):
68
+ def __init__(self, config: MaestroConfig):
69
+ addresses__maestro: str = getattr(config, "addresses__maestro", "https://maestro.airi.net")
70
+ error: str = getattr(getattr(config, "res", SimpleNamespace()), "error", "Server is not available")
71
+ headers_extra: dict[str, str] | None = getattr(config, "headers_extra", "")
72
+ files_dir: str | None = getattr(config, "files_dir", None)
73
+ timeout: int = getattr(config, "timeout", 120)
74
+
75
+ self.url = fix_maestro_address(addresses__maestro) + "/" + ROUTES.send
76
+ logger.info(f"Creating client, maestro url: {self.url}")
77
+ self.request = partial(request_with_session, timeout=timeout, headers_extra=headers_extra)
78
+ self.msg_data_response_error: MessageData = make_content(text=error), None
79
+
80
+ self.file_storage: FileStorage | None = files_dir and FileStorage(files_dir)
81
+
82
+ @cache
83
+ def get_file_storage(self, client_id: str) -> FileStorage:
84
+ return self.file_storage or RemoteStorager(self.request, self.url, client_id)
85
+
86
+ async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
87
+ start = time.time()
88
+ msg_datas_response = await self._send(context, msg_data)
89
+ elapsed = time.time() - start
90
+
91
+ entrypoint_key = (context.extra or {}).get("entrypoint_key", "")
92
+ cd = f"{context.track_id}.{context.session_id}.{entrypoint_key}"
93
+ log_suffix = " (NULL!)" if not msg_datas_response else ""
94
+ logger.info(f"BotResponse processing time for {cd}: {elapsed:.2f} s{log_suffix}")
95
+
96
+ msg_datas_response = msg_datas_response or [self.msg_data_response_error]
97
+ return msg_datas_response
98
+
99
+ async def upload_resource(self, file_data: FileData, client_id: str) -> str | None:
100
+ file_name, file_bytes = file_data
101
+ resourse_id: str = await self.get_file_storage(client_id).upload_async(file_bytes, file_name)
102
+ return resourse_id
103
+
104
+ async def download_resource(self, resource_id: str, client_id: str) -> bytes:
105
+ res: bytes = await self.get_file_storage(client_id).download_async(resource_id)
106
+ return res
107
+
108
+ async def _download_file_data_maybe(self, msg: HumanMessage, client_id: str) -> FileData | None:
109
+ resource_id = msg.resource_id
110
+ if not resource_id:
111
+ return None
112
+ logger.info(f"Downloading resource: {resource_id}")
113
+ resource_name = msg.resource_name
114
+ if not resource_name:
115
+ resource_ext = resource_id.split(".")[-1]
116
+ resource_name = f"result.{resource_ext}"
117
+ resource_bytes = await self.download_resource(resource_id, client_id)
118
+ return resource_name, resource_bytes
119
+
120
+ async def _send(self, context: Context, msg_data: MessageData | str) -> list[MessageData] | None:
121
+ if isinstance(msg_data, str):
122
+ msg_data = msg_data, None
123
+ content, file_data = msg_data
124
+
125
+ resource_id = file_data and await self.upload_resource(file_data, context.client_id)
126
+ content = make_content(content=content, resource_id=resource_id)
127
+ msg = HumanMessage(content=content)
128
+ ai_messages = await self._send_raw(context, msg)
129
+ if not ai_messages:
130
+ return None
131
+ download = partial(self._download_file_data_maybe, client_id=context.client_id)
132
+ res = [(ai_msg.content, await download(ai_msg)) for ai_msg in ai_messages]
133
+ return res
134
+
135
+ async def _send_raw(self, context: Context, msg: HumanMessage) -> list[AIMessage] | None:
136
+ dict_user_message = msg.model_dump()
137
+ dict_ctx = context.model_dump()
138
+ data_json = {"context": dict_ctx, "messages": [dict_user_message]}
139
+ headers = {"client-id": context.client_id}
140
+ try:
141
+ response_data = await self.request(method="post", url=self.url, json=data_json, headers=headers)
142
+ except Exception:
143
+ logger.exception(f"Failed to send request {msg}")
144
+ return None
145
+ if response_data is None:
146
+ return None
147
+ logger.trace(f"Response data: {response_data}")
148
+ response_messages_raw = response_data["response_messages"]
149
+ ai_messages = list(map(AIMessage.model_validate, response_messages_raw))
150
+ return ai_messages
@@ -0,0 +1,38 @@
1
+ import asyncio
2
+
3
+ from loguru import logger
4
+ from mmar_mapi import Context
5
+ from mmar_mapi.models.chat import _get_command, _get_text
6
+ from mmar_utils import try_parse_int
7
+
8
+ from mmar_mcli.maestro_client import MaestroClientI
9
+ from mmar_mcli.models import MessageData
10
+
11
+
12
+ class MaestroClientDummy(MaestroClientI):
13
+ def __init__(self, config):
14
+ pass
15
+
16
+ async def send(self, context: Context, msg_data: MessageData | str) -> list[MessageData]:
17
+ if isinstance(msg_data, str):
18
+ msg_data = msg_data, None
19
+ content, file_data = msg_data
20
+
21
+ text = _get_text(content)
22
+ command = _get_command(content)
23
+
24
+ if text.lower().startswith("wait"):
25
+ seconds = try_parse_int(text[len("wait") :].strip())
26
+ logger.info(f"Going to wait {seconds} seconds")
27
+ if seconds:
28
+ await asyncio.sleep(seconds)
29
+ return [(f"After waiting {seconds} seconds", None)]
30
+
31
+ text_response_lines = [
32
+ f"Your context: {context}",
33
+ f"Your text: {text}",
34
+ f"Your command: {command}",
35
+ f"Your file_data: {file_data and (file_data[0], len(file_data[1]))}",
36
+ ]
37
+ text_response = "\n".join(text_response_lines)
38
+ return [(text_response, None)]
@@ -0,0 +1,20 @@
1
+ from collections.abc import Callable
2
+ from typing import Awaitable, NamedTuple, Protocol
3
+
4
+ from mmar_mapi import Content
5
+
6
+ FileName = str
7
+ FileData = tuple[FileName, bytes]
8
+ MessageData = tuple[Content | None, FileData | None]
9
+
10
+ RequestCall = Callable[..., Awaitable[bytes | dict]]
11
+ BotConfig = NamedTuple("BotConfig", [("timeout", int)])
12
+ ResourcesConfig = NamedTuple("ResourcesConfig", [("error", str)])
13
+
14
+
15
+ class MaestroConfig(Protocol):
16
+ addresses__maestro: str
17
+ res: ResourcesConfig
18
+ headers_extra: dict[str, str] | None
19
+ files_dir: str | None
20
+ timeout: int
File without changes