lmnr 0.2.5__py3-none-any.whl → 0.2.7__py3-none-any.whl

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,27 @@
1
+ from dataclasses import dataclass
2
+ import uuid
3
+
4
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
5
+ from lmnr.cli.parser.utils import map_handles
6
+
7
+
8
+ @dataclass
9
+ class CodeNode(NodeFunctions):
10
+ id: uuid.UUID
11
+ name: str
12
+ inputs: list[Handle]
13
+ outputs: list[Handle]
14
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
15
+
16
+ def handles_mapping(
17
+ self, output_handle_id_to_node_name: dict[str, str]
18
+ ) -> list[tuple[str, str]]:
19
+ return map_handles(
20
+ self.inputs, self.inputs_mappings, output_handle_id_to_node_name
21
+ )
22
+
23
+ def node_type(self) -> str:
24
+ return "Code"
25
+
26
+ def config(self) -> dict:
27
+ return {}
@@ -0,0 +1,30 @@
1
+ from dataclasses import dataclass
2
+ import uuid
3
+
4
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
5
+ from lmnr.cli.parser.utils import map_handles
6
+
7
+
8
+ @dataclass
9
+ class ConditionNode(NodeFunctions):
10
+ id: uuid.UUID
11
+ name: str
12
+ inputs: list[Handle]
13
+ outputs: list[Handle]
14
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
15
+ condition: str
16
+
17
+ def handles_mapping(
18
+ self, output_handle_id_to_node_name: dict[str, str]
19
+ ) -> list[tuple[str, str]]:
20
+ return map_handles(
21
+ self.inputs, self.inputs_mappings, output_handle_id_to_node_name
22
+ )
23
+
24
+ def node_type(self) -> str:
25
+ return "Condition"
26
+
27
+ def config(self) -> dict:
28
+ return {
29
+ "condition": self.condition,
30
+ }
@@ -0,0 +1,26 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ import uuid
4
+
5
+ from lmnr.cli.parser.nodes import Handle, HandleType, NodeFunctions
6
+ from lmnr.types import NodeInput
7
+
8
+
9
+ @dataclass
10
+ class InputNode(NodeFunctions):
11
+ id: uuid.UUID
12
+ name: str
13
+ outputs: list[Handle]
14
+ input: Optional[NodeInput]
15
+ input_type: HandleType
16
+
17
+ def handles_mapping(
18
+ self, output_handle_id_to_node_name: dict[str, str]
19
+ ) -> list[tuple[str, str]]:
20
+ return []
21
+
22
+ def node_type(self) -> str:
23
+ return "Input"
24
+
25
+ def config(self) -> dict:
26
+ return {}
@@ -0,0 +1,51 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+ import uuid
4
+
5
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
6
+ from lmnr.cli.parser.utils import map_handles
7
+
8
+
9
+ @dataclass
10
+ class LLMNode(NodeFunctions):
11
+ id: uuid.UUID
12
+ name: str
13
+ inputs: list[Handle]
14
+ dynamic_inputs: list[Handle]
15
+ outputs: list[Handle]
16
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
17
+ prompt: str
18
+ model: str
19
+ model_params: Optional[str]
20
+ stream: bool
21
+ structured_output_enabled: bool
22
+ structured_output_max_retries: int
23
+ structured_output_schema: Optional[str]
24
+ structured_output_schema_target: Optional[str]
25
+
26
+ def handles_mapping(
27
+ self, output_handle_id_to_node_name: dict[str, str]
28
+ ) -> list[tuple[str, str]]:
29
+ combined_inputs = self.inputs + self.dynamic_inputs
30
+ return map_handles(
31
+ combined_inputs, self.inputs_mappings, output_handle_id_to_node_name
32
+ )
33
+
34
+ def node_type(self) -> str:
35
+ return "LLM"
36
+
37
+ def config(self) -> dict:
38
+ # For easier access in the template separate the provider and model here
39
+ provider, model = self.model.split(":", maxsplit=1)
40
+
41
+ return {
42
+ "prompt": self.prompt,
43
+ "provider": provider,
44
+ "model": model,
45
+ "model_params": self.model_params,
46
+ "stream": self.stream,
47
+ "structured_output_enabled": self.structured_output_enabled,
48
+ "structured_output_max_retries": self.structured_output_max_retries,
49
+ "structured_output_schema": self.structured_output_schema,
50
+ "structured_output_schema_target": self.structured_output_schema_target,
51
+ }
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+ import uuid
3
+
4
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
5
+ from lmnr.cli.parser.utils import map_handles
6
+
7
+
8
+ @dataclass
9
+ class OutputNode(NodeFunctions):
10
+ id: uuid.UUID
11
+ name: str
12
+ inputs: list[Handle]
13
+ outputs: list[Handle]
14
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
15
+
16
+ def handles_mapping(
17
+ self, output_handle_id_to_node_name: dict[str, str]
18
+ ) -> list[tuple[str, str]]:
19
+ return map_handles(
20
+ self.inputs, self.inputs_mappings, output_handle_id_to_node_name
21
+ )
22
+
23
+ def node_type(self) -> str:
24
+ return "Output"
25
+
26
+ def config(self) -> dict:
27
+ return {}
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+ import uuid
3
+
4
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
5
+ from lmnr.cli.parser.utils import map_handles
6
+
7
+
8
+ @dataclass
9
+ class Route:
10
+ name: str
11
+
12
+
13
+ @dataclass
14
+ class RouterNode(NodeFunctions):
15
+ id: uuid.UUID
16
+ name: str
17
+ inputs: list[Handle]
18
+ outputs: list[Handle]
19
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
20
+ routes: list[Route]
21
+ has_default_route: bool
22
+
23
+ def handles_mapping(
24
+ self, output_handle_id_to_node_name: dict[str, str]
25
+ ) -> list[tuple[str, str]]:
26
+ return map_handles(
27
+ self.inputs, self.inputs_mappings, output_handle_id_to_node_name
28
+ )
29
+
30
+ def node_type(self) -> str:
31
+ return "Router"
32
+
33
+ def config(self) -> dict:
34
+ return {
35
+ "routes": str([route.name for route in self.routes]),
36
+ "has_default_route": str(self.has_default_route),
37
+ }
@@ -0,0 +1,81 @@
1
+ from dataclasses import dataclass
2
+ from datetime import datetime
3
+
4
+ import uuid
5
+
6
+ from lmnr.cli.parser.nodes import Handle, NodeFunctions
7
+ from lmnr.cli.parser.utils import map_handles
8
+
9
+
10
+ @dataclass
11
+ class FileMetadata:
12
+ id: uuid.UUID
13
+ created_at: datetime
14
+ project_id: uuid.UUID
15
+ filename: str
16
+
17
+
18
+ @dataclass
19
+ class Dataset:
20
+ id: uuid.UUID
21
+ created_at: datetime
22
+ project_id: uuid.UUID
23
+ name: str
24
+
25
+
26
+ @dataclass
27
+ class SemanticSearchDatasource:
28
+ type: str
29
+ id: uuid.UUID
30
+ # TODO: Paste other fields here, use Union[FileMetadata, Dataset]
31
+
32
+ @classmethod
33
+ def from_dict(cls, datasource_dict: dict) -> "SemanticSearchDatasource":
34
+ if datasource_dict["type"] == "File":
35
+ return cls(
36
+ type="File",
37
+ id=uuid.UUID(datasource_dict["id"]),
38
+ )
39
+ elif datasource_dict["type"] == "Dataset":
40
+ return cls(
41
+ type="Dataset",
42
+ id=uuid.UUID(datasource_dict["id"]),
43
+ )
44
+ else:
45
+ raise ValueError(
46
+ f"Invalid SemanticSearchDatasource type: {datasource_dict['type']}"
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class SemanticSearchNode(NodeFunctions):
52
+ id: uuid.UUID
53
+ name: str
54
+ inputs: list[Handle]
55
+ outputs: list[Handle]
56
+ inputs_mappings: dict[uuid.UUID, uuid.UUID]
57
+ limit: int
58
+ threshold: float
59
+ template: str
60
+ datasources: list[SemanticSearchDatasource]
61
+
62
+ def handles_mapping(
63
+ self, output_handle_id_to_node_name: dict[str, str]
64
+ ) -> list[tuple[str, str]]:
65
+ return map_handles(
66
+ self.inputs, self.inputs_mappings, output_handle_id_to_node_name
67
+ )
68
+
69
+ def node_type(self) -> str:
70
+ return "SemanticSearch"
71
+
72
+ def config(self) -> dict:
73
+ return {
74
+ "limit": self.limit,
75
+ "threshold": self.threshold,
76
+ "template": self.template,
77
+ "datasource_ids": [str(datasource.id) for datasource in self.datasources],
78
+ "datasource_ids_list": str(
79
+ [str(datasource.id) for datasource in self.datasources]
80
+ ),
81
+ }
@@ -1,8 +1,17 @@
1
- from dataclasses import dataclass
2
- from typing import Any, Optional, Union
1
+ from typing import Any, Union
3
2
  import uuid
4
- from lmnr.cli.parser.nodes import Handle, HandleType, NodeFunctions
5
- from lmnr.cli.parser.utils import map_handles
3
+
4
+ from lmnr.cli.parser.nodes import Handle
5
+ from lmnr.cli.parser.nodes.code import CodeNode
6
+ from lmnr.cli.parser.nodes.condition import ConditionNode
7
+ from lmnr.cli.parser.nodes.input import InputNode
8
+ from lmnr.cli.parser.nodes.llm import LLMNode
9
+ from lmnr.cli.parser.nodes.output import OutputNode
10
+ from lmnr.cli.parser.nodes.router import Route, RouterNode
11
+ from lmnr.cli.parser.nodes.semantic_search import (
12
+ SemanticSearchDatasource,
13
+ SemanticSearchNode,
14
+ )
6
15
  from lmnr.types import NodeInput, ChatMessage
7
16
 
8
17
 
@@ -15,97 +24,15 @@ def node_input_from_json(json_val: Any) -> NodeInput:
15
24
  raise ValueError(f"Invalid NodeInput value: {json_val}")
16
25
 
17
26
 
18
- # TODO: Convert to Pydantic
19
- @dataclass
20
- class InputNode(NodeFunctions):
21
- id: uuid.UUID
22
- name: str
23
- outputs: list[Handle]
24
- input: Optional[NodeInput]
25
- input_type: HandleType
26
-
27
- def handles_mapping(
28
- self, output_handle_id_to_node_name: dict[str, str]
29
- ) -> list[tuple[str, str]]:
30
- return []
31
-
32
- def node_type(self) -> str:
33
- return "Input"
34
-
35
- def config(self) -> dict:
36
- return {}
37
-
38
-
39
- # TODO: Convert to Pydantic
40
- @dataclass
41
- class LLMNode(NodeFunctions):
42
- id: uuid.UUID
43
- name: str
44
- inputs: list[Handle]
45
- dynamic_inputs: list[Handle]
46
- outputs: list[Handle]
47
- inputs_mappings: dict[uuid.UUID, uuid.UUID]
48
- prompt: str
49
- model: str
50
- model_params: Optional[str]
51
- stream: bool
52
- structured_output_enabled: bool
53
- structured_output_max_retries: int
54
- structured_output_schema: Optional[str]
55
- structured_output_schema_target: Optional[str]
56
-
57
- def handles_mapping(
58
- self, output_handle_id_to_node_name: dict[str, str]
59
- ) -> list[tuple[str, str]]:
60
- combined_inputs = self.inputs + self.dynamic_inputs
61
- return map_handles(
62
- combined_inputs, self.inputs_mappings, output_handle_id_to_node_name
63
- )
64
-
65
- def node_type(self) -> str:
66
- return "LLM"
67
-
68
- def config(self) -> dict:
69
- # For easier access in the template separate the provider and model here
70
- provider, model = self.model.split(":", maxsplit=1)
71
-
72
- return {
73
- "prompt": self.prompt,
74
- "provider": provider,
75
- "model": model,
76
- "model_params": self.model_params,
77
- "stream": self.stream,
78
- "structured_output_enabled": self.structured_output_enabled,
79
- "structured_output_max_retries": self.structured_output_max_retries,
80
- "structured_output_schema": self.structured_output_schema,
81
- "structured_output_schema_target": self.structured_output_schema_target,
82
- }
83
-
84
-
85
- # TODO: Convert to Pydantic
86
- @dataclass
87
- class OutputNode(NodeFunctions):
88
- id: uuid.UUID
89
- name: str
90
- inputs: list[Handle]
91
- outputs: list[Handle]
92
- inputs_mappings: dict[uuid.UUID, uuid.UUID]
93
-
94
- def handles_mapping(
95
- self, output_handle_id_to_node_name: dict[str, str]
96
- ) -> list[tuple[str, str]]:
97
- return map_handles(
98
- self.inputs, self.inputs_mappings, output_handle_id_to_node_name
99
- )
100
-
101
- def node_type(self) -> str:
102
- return "Output"
103
-
104
- def config(self) -> dict:
105
- return {}
106
-
107
-
108
- Node = Union[InputNode, OutputNode, LLMNode]
27
+ Node = Union[
28
+ InputNode,
29
+ OutputNode,
30
+ ConditionNode,
31
+ LLMNode,
32
+ RouterNode,
33
+ SemanticSearchNode,
34
+ CodeNode,
35
+ ]
109
36
 
110
37
 
111
38
  def node_from_dict(node_dict: dict) -> Node:
@@ -128,6 +55,18 @@ def node_from_dict(node_dict: dict) -> Node:
128
55
  for k, v in node_dict["inputsMappings"].items()
129
56
  },
130
57
  )
58
+ elif node_dict["type"] == "Condition":
59
+ return ConditionNode(
60
+ id=uuid.UUID(node_dict["id"]),
61
+ name=node_dict["name"],
62
+ inputs=[Handle.from_dict(handle) for handle in node_dict["inputs"]],
63
+ outputs=[Handle.from_dict(handle) for handle in node_dict["outputs"]],
64
+ inputs_mappings={
65
+ uuid.UUID(k): uuid.UUID(v)
66
+ for k, v in node_dict["inputsMappings"].items()
67
+ },
68
+ condition=node_dict["condition"],
69
+ )
131
70
  elif node_dict["type"] == "LLM":
132
71
  return LLMNode(
133
72
  id=uuid.UUID(node_dict["id"]),
@@ -153,5 +92,47 @@ def node_from_dict(node_dict: dict) -> Node:
153
92
  structured_output_schema=None,
154
93
  structured_output_schema_target=None,
155
94
  )
95
+ elif node_dict["type"] == "Router":
96
+ return RouterNode(
97
+ id=uuid.UUID(node_dict["id"]),
98
+ name=node_dict["name"],
99
+ inputs=[Handle.from_dict(handle) for handle in node_dict["inputs"]],
100
+ outputs=[Handle.from_dict(handle) for handle in node_dict["outputs"]],
101
+ inputs_mappings={
102
+ uuid.UUID(k): uuid.UUID(v)
103
+ for k, v in node_dict["inputsMappings"].items()
104
+ },
105
+ routes=[Route(name=route["name"]) for route in node_dict["routes"]],
106
+ has_default_route=node_dict["hasDefaultRoute"],
107
+ )
108
+ elif node_dict["type"] == "SemanticSearch":
109
+ return SemanticSearchNode(
110
+ id=uuid.UUID(node_dict["id"]),
111
+ name=node_dict["name"],
112
+ inputs=[Handle.from_dict(handle) for handle in node_dict["inputs"]],
113
+ outputs=[Handle.from_dict(handle) for handle in node_dict["outputs"]],
114
+ inputs_mappings={
115
+ uuid.UUID(k): uuid.UUID(v)
116
+ for k, v in node_dict["inputsMappings"].items()
117
+ },
118
+ limit=node_dict["limit"],
119
+ threshold=node_dict["threshold"],
120
+ template=node_dict["template"],
121
+ datasources=[
122
+ SemanticSearchDatasource.from_dict(ds)
123
+ for ds in node_dict["datasources"]
124
+ ],
125
+ )
126
+ elif node_dict["type"] == "Code":
127
+ return CodeNode(
128
+ id=uuid.UUID(node_dict["id"]),
129
+ name=node_dict["name"],
130
+ inputs=[Handle.from_dict(handle) for handle in node_dict["inputs"]],
131
+ outputs=[Handle.from_dict(handle) for handle in node_dict["outputs"]],
132
+ inputs_mappings={
133
+ uuid.UUID(k): uuid.UUID(v)
134
+ for k, v in node_dict["inputsMappings"].items()
135
+ },
136
+ )
156
137
  else:
157
138
  raise ValueError(f"Node type {node_dict['type']} not supported")
lmnr/cli/parser/parser.py CHANGED
@@ -17,6 +17,9 @@ def runnable_graph_to_template_vars(graph: dict) -> dict:
17
17
  node = node_from_dict(node_obj)
18
18
  handles_mapping = node.handles_mapping(output_handle_id_to_node_name)
19
19
  node_type = node.node_type()
20
+
21
+ unique_handles = set([handle_name for (handle_name, _) in handles_mapping])
22
+
20
23
  tasks.append(
21
24
  {
22
25
  "name": node.name,
@@ -28,10 +31,7 @@ def runnable_graph_to_template_vars(graph: dict) -> dict:
28
31
  handle_name for (handle_name, _) in handles_mapping
29
32
  ],
30
33
  "handle_args": ", ".join(
31
- [
32
- f"{handle_name}: NodeInput"
33
- for (handle_name, _) in handles_mapping
34
- ]
34
+ [f"{handle_name}: NodeInput" for handle_name in unique_handles]
35
35
  ),
36
36
  "prev": [],
37
37
  "next": [],
@@ -9,7 +9,8 @@ import queue
9
9
  from .task import Task
10
10
  from .action import NodeRunError, RunOutput
11
11
  from .state import State
12
- from lmnr_engine.types import Message, NodeInput
12
+ from lmnr.types import NodeInput
13
+ from lmnr_engine.types import Message
13
14
 
14
15
 
15
16
  logger = logging.getLogger(__name__)
@@ -169,13 +170,17 @@ class Engine:
169
170
  active_tasks.remove(task_id)
170
171
 
171
172
  if depth > 0:
172
- # propagate reset once we enter the loop
173
- # TODO: Implement this for cycles
174
- raise NotImplementedError()
173
+ self.propagate_reset(task_id, task_id, tasks)
175
174
 
175
+ # terminate graph on recursion depth exceeding 10
176
176
  if depth == 10:
177
- # TODO: Implement this for cycles
178
- raise NotImplementedError()
177
+ logging.error("Max recursion depth exceeded, terminating graph")
178
+ error = Message(
179
+ id=uuid.uuid4(),
180
+ value="Max recursion depth exceeded",
181
+ start_time=start_time,
182
+ end_time=datetime.datetime.now(),
183
+ )
179
184
 
180
185
  if not next:
181
186
  # if there are no next tasks, we can terminate the graph
@@ -259,3 +264,30 @@ class Engine:
259
264
  task,
260
265
  queue,
261
266
  )
267
+
268
+ def propagate_reset(
269
+ self, current_task_name: str, start_task_name: str, tasks: dict[str, Task]
270
+ ) -> None:
271
+ task = tasks[current_task_name]
272
+
273
+ for next_task_name in task.next:
274
+ if next_task_name == start_task_name:
275
+ return
276
+
277
+ next_task = tasks[next_task_name]
278
+
279
+ # in majority of cases there will be only one handle name
280
+ # however we need to handle the case when single output is mapped
281
+ # to multiple inputs on the next node
282
+ handle_names = []
283
+ for k, v in next_task.handles_mapping:
284
+ if v == task.name:
285
+ handle_names.append(k)
286
+
287
+ for handle_name in handle_names:
288
+ next_state = next_task.input_states[handle_name]
289
+
290
+ if next_state.get_state().is_success():
291
+ next_state.set_state(State.empty())
292
+ next_state.semaphore.release()
293
+ self.propagate_reset(next_task_name, start_task_name, tasks)
@@ -1,6 +1,7 @@
1
1
  import requests
2
2
  import json
3
3
 
4
+ from lmnr.types import ConditionedValue
4
5
  from lmnr_engine.engine.action import NodeRunError, RunOutput
5
6
  from lmnr_engine.types import ChatMessage, NodeInput
6
7
 
@@ -24,8 +25,8 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
24
25
  rendered_prompt = """{{task.config.prompt}}"""
25
26
  {% set prompt_variables = task.input_handle_names|reject("equalto", "chat_messages") %}
26
27
  {% for prompt_variable in prompt_variables %}
27
- # TODO: Fix this. Using double curly braces in quotes because normal double curly braces
28
- # get replaced during rendering by Cookiecutter. This is a hacky solution.
28
+ {# TODO: Fix this. Using double curly braces in quotes because normal double curly braces
29
+ # get replaced during rendering by Cookiecutter. This is a hacky solution.#}
29
30
  rendered_prompt = rendered_prompt.replace("{{'{{'}}{{prompt_variable}}{{'}}'}}", {{prompt_variable}}) # type: ignore
30
31
  {% endfor %}
31
32
 
@@ -68,7 +69,8 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
68
69
  completion_message = chat_completion["choices"][0]["message"]["content"]
69
70
 
70
71
  meta_log = {}
71
- meta_log["node_chunk_id"] = None # TODO: Add node chunk id
72
+ {# TODO: Add node chunk id #}
73
+ meta_log["node_chunk_id"] = None
72
74
  meta_log["model"] = "{{task.config.model}}"
73
75
  meta_log["prompt"] = rendered_prompt
74
76
  meta_log["input_message_count"] = len(messages)
@@ -77,7 +79,8 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
77
79
  meta_log["total_token_count"] = (
78
80
  chat_completion["usage"]["prompt_tokens"] + chat_completion["usage"]["completion_tokens"]
79
81
  )
80
- meta_log["approximate_cost"] = None # TODO: Add approximate cost
82
+ {# TODO: Add approximate cost #}
83
+ meta_log["approximate_cost"] = None
81
84
  {% elif task.config.provider == "anthropic" %}
82
85
  data = {
83
86
  "model": "{{task.config.model}}",
@@ -85,7 +88,7 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
85
88
  }
86
89
  data.update(params)
87
90
 
88
- # TODO: Generate appropriate code based on this if-else block
91
+ {# TODO: Generate appropriate code based on this if-else block #}
89
92
  if len(messages) == 1 and messages[0].role == "system":
90
93
  messages[0].role = "user"
91
94
  message_jsons = [
@@ -116,7 +119,8 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
116
119
  completion_message = chat_completion["content"][0]["text"]
117
120
 
118
121
  meta_log = {}
119
- meta_log["node_chunk_id"] = None # TODO: Add node chunk id
122
+ {# TODO: Add node chunk id#}
123
+ meta_log["node_chunk_id"] = None
120
124
  meta_log["model"] = "{{task.config.model}}"
121
125
  meta_log["prompt"] = rendered_prompt
122
126
  meta_log["input_message_count"] = len(messages)
@@ -125,13 +129,81 @@ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunO
125
129
  meta_log["total_token_count"] = (
126
130
  chat_completion["usage"]["input_tokens"] + chat_completion["usage"]["output_tokens"]
127
131
  )
128
- meta_log["approximate_cost"] = None # TODO: Add approximate cost
132
+ {# TODO: Add approximate cost#}
133
+ meta_log["approximate_cost"] = None
129
134
  {% else %}
130
135
  {% endif %}
131
136
 
132
137
  return RunOutput(status="Success", output=completion_message)
133
138
 
134
139
 
140
+ {% elif task.node_type == "SemanticSearch" %}
141
+ def {{task.function_name}}(query: NodeInput, _env: dict[str, str]) -> RunOutput:
142
+ {% set datasources_length=task.config.datasource_ids|length %}
143
+ {% if datasources_length == 0 %}
144
+ raise NodeRunError("No datasources provided")
145
+ {% endif %}
146
+
147
+ headers = {
148
+ "Authorization": f"Bearer {_env['LMNR_PROJECT_API_KEY']}",
149
+ }
150
+ data = {
151
+ "query": query,
152
+ "limit": {{ task.config.limit }},
153
+ "threshold": {{ task.config.threshold }},
154
+ "datasourceIds": {{ task.config.datasource_ids_list }},
155
+ }
156
+ query_res = requests.post("https://api.lmnr.ai/v2/semantic-search", headers=headers, json=data)
157
+ if query_res.status_code != 200:
158
+ raise NodeRunError(f"Vector search request failed:{query_res.status_code}\n{query_res.text}")
159
+
160
+ results = query_res.json()
161
+
162
+ def render_query_res_point(template: str, point: dict, relevance_index: int) -> str:
163
+ data = point["data"]
164
+ data["relevance_index"] = relevance_index
165
+ res = template
166
+ for key, value in data.items():
167
+ res = res.replace("{{'{{'}}" + key + "{{'}}'}}", str(value))
168
+ return res
169
+
170
+ rendered_res_points = [render_query_res_point("""{{task.config.template}}""", res_point, index + 1) for (index, res_point) in enumerate(results)]
171
+ output = "\n".join(rendered_res_points)
172
+
173
+ return RunOutput(status="Success", output=output)
174
+
175
+
176
+ {% elif task.node_type == "Router" %}
177
+ def {{task.function_name}}(condition: NodeInput, input: NodeInput, _env: dict[str, str]) -> RunOutput:
178
+ routes = {{ task.config.routes }}
179
+ has_default_route = {{ task.config.has_default_route }}
180
+
181
+ for route in routes:
182
+ if route == condition:
183
+ return RunOutput(status="Success", output=ConditionedValue(condition=route, value=input))
184
+
185
+ if has_default_route:
186
+ return RunOutput(status="Success", output=ConditionedValue(condition=routes[-1], value=input))
187
+
188
+ raise NodeRunError(f"No route found for condition {condition}")
189
+
190
+
191
+ {% elif task.node_type == "Condition" %}
192
+ def {{task.function_name}}(input: NodeInput, _env: dict[str, str]) -> RunOutput:
193
+ condition = "{{task.config.condition}}"
194
+
195
+ if input.condition == condition:
196
+ return RunOutput(status="Success", output=input.value)
197
+ else:
198
+ return RunOutput(status="Termination", output=None)
199
+
200
+
201
+ {% elif task.node_type == "Code" %}
202
+ def {{task.function_name}}({{ task.handle_args }}, _env: dict[str, str]) -> RunOutput:
203
+ # Implement any functionality you want here
204
+ raise NodeRunError("Implement your code here")
205
+
206
+
135
207
  {% elif task.node_type == "Output" %}
136
208
  def {{task.function_name}}(output: NodeInput, _env: dict[str, str]) -> RunOutput:
137
209
  return RunOutput(status="Success", output=output)
lmnr/sdk/endpoint.py CHANGED
@@ -4,7 +4,7 @@ import pydantic
4
4
  import requests
5
5
  from lmnr.types import (
6
6
  EndpointRunError, EndpointRunResponse, NodeInput, EndpointRunRequest,
7
- ToolCall, SDKError
7
+ ToolCallError, ToolCallRequest, ToolCallResponse, SDKError
8
8
  )
9
9
  from typing import Callable, Optional
10
10
  from websockets.sync.client import connect
@@ -126,31 +126,52 @@ class Laminar:
126
126
  }
127
127
  ) as websocket:
128
128
  websocket.send(request.model_dump_json())
129
+ req_id = None
129
130
 
130
131
  while True:
131
132
  message = websocket.recv()
132
133
  try:
133
- tool_call = ToolCall.model_validate_json(message)
134
+ tool_call = ToolCallRequest.model_validate_json(message)
135
+ req_id = tool_call.req_id
134
136
  matching_tools = [
135
137
  tool for tool in tools
136
- if tool.__name__ == tool_call.function.name
138
+ if tool.__name__ == tool_call.toolCall.function.name
137
139
  ]
138
140
  if not matching_tools:
139
141
  raise SDKError(
140
- f'Tool {tool_call.function.name} not found.'
142
+ f'Tool {tool_call.toolCall.function.name} not found.'
141
143
  ' Registered tools: '
142
144
  f'{", ".join([tool.__name__ for tool in tools])}'
143
145
  )
144
146
  tool = matching_tools[0]
145
- if tool.__name__ == tool_call.function.name:
146
147
  # default the arguments to an empty dictionary
148
+ if tool.__name__ == tool_call.toolCall.function.name:
147
149
  arguments = {}
148
150
  try:
149
- arguments = json.loads(tool_call.function.arguments)
151
+ arguments = json.loads(tool_call.toolCall.function.arguments)
150
152
  except:
151
153
  pass
152
- response = tool(**arguments)
153
- websocket.send(json.dumps(response))
154
+ try:
155
+ response = tool(**arguments)
156
+ except Exception as e:
157
+ error_message = 'Error occurred while running tool' +\
158
+ f'{tool.__name__}: {e}'
159
+ e = ToolCallError(error=error_message, reqId=req_id)
160
+ websocket.send(e.model_dump_json())
161
+ formatted_response = None
162
+ try:
163
+ formatted_response = ToolCallResponse(
164
+ reqId=tool_call.reqId,
165
+ response=response
166
+ )
167
+ except pydantic.ValidationError as e:
168
+ formatted_response = ToolCallResponse(
169
+ reqId=tool_call.reqId,
170
+ response=str(response)
171
+ )
172
+ websocket.send(
173
+ formatted_response.model_dump_json()
174
+ )
154
175
  except pydantic.ValidationError as e:
155
176
  message_json = json.loads(message)
156
177
  keys = list(message_json.keys())
@@ -161,6 +182,5 @@ class Laminar:
161
182
  result = EndpointRunResponse.model_validate(message_json)
162
183
  websocket.close()
163
184
  return result
164
- except Exception:
165
- websocket.close()
166
- raise SDKError('Error communicating to backend through websocket')
185
+ except Exception as e:
186
+ raise SDKError(f'Error communicating to backend through websocket {e}')
@@ -1,13 +1,21 @@
1
1
  from typing import Callable, Optional
2
2
  from websockets.sync.client import connect
3
+ import pydantic
3
4
  import websockets
4
- from lmnr.types import DeregisterDebuggerRequest, NodeInput, RegisterDebuggerRequest, SDKError, ToolCall, ToolCallError
5
+ from lmnr.types import (
6
+ DeregisterDebuggerRequest, NodeInput, RegisterDebuggerRequest,
7
+ SDKError, ToolCallError, ToolCallRequest, ToolCallResponse
8
+ )
5
9
  import uuid
6
10
  import json
7
11
  from threading import Thread
8
12
 
9
13
  class RemoteDebugger:
10
- def __init__(self, project_api_key: str, tools: list[Callable[..., NodeInput]] = []):
14
+ def __init__(
15
+ self,
16
+ project_api_key: str,
17
+ tools: list[Callable[..., NodeInput]] = []
18
+ ):
11
19
  self.project_api_key = project_api_key
12
20
  self.url = 'wss://api.lmnr.ai/v2/endpoint/ws'
13
21
  self.tools = tools
@@ -25,7 +33,8 @@ class RemoteDebugger:
25
33
  self.stop_flag = True
26
34
  self.thread.join()
27
35
  self.session = None
28
- # python allows running threads only once, so we need to create a new thread
36
+ # python allows running threads only once, so we need to create
37
+ # a new thread
29
38
  # in case the user wants to start the debugger again
30
39
  self.thread = Thread(target=self._run)
31
40
 
@@ -41,10 +50,13 @@ class RemoteDebugger:
41
50
  }
42
51
  ) as websocket:
43
52
  websocket.send(request.model_dump_json())
44
- print(self._format_session_id())
53
+ print(self._format_session_id_and_registerd_functions())
54
+ req_id = None
55
+
45
56
  while not self.stop_flag:
46
57
  try:
47
- # blocks the thread until a message is received or a timeout (3 seconds) occurs
58
+ # blocks the thread until a message
59
+ # is received or a timeout (3 seconds) occurs
48
60
  message = websocket.recv(3)
49
61
  except TimeoutError:
50
62
  continue
@@ -52,45 +64,76 @@ class RemoteDebugger:
52
64
  print("Connection closed. Please restart the debugger.")
53
65
  return
54
66
  try:
55
- tool_call = ToolCall.model_validate_json(message)
67
+ tool_call = ToolCallRequest.model_validate_json(message)
68
+ req_id = tool_call.reqId
56
69
  except:
57
70
  raise SDKError(f'Invalid message received:\n{message}')
58
71
  matching_tools = [
59
72
  tool for tool in self.tools
60
- if tool.__name__ == tool_call.function.name
73
+ if tool.__name__ == tool_call.toolCall.function.name
61
74
  ]
62
75
  if not matching_tools:
63
- error_message = f'Tool {tool_call.function.name} not found.' +\
64
- f' Registered tools: {", ".join([tool.__name__ for tool in self.tools])}'
65
- e = ToolCallError(error=error_message)
76
+ error_message = \
77
+ f'Tool {tool_call.toolCall.function.name} not found' +\
78
+ '. Registered tools: ' +\
79
+ {", ".join([tool.__name__ for tool in self.tools])}
80
+ e = ToolCallError(error=error_message, reqId=req_id)
66
81
  websocket.send(e.model_dump_json())
67
82
  continue
68
83
  tool = matching_tools[0]
69
- if tool.__name__ == tool_call.function.name:
84
+ if tool.__name__ == tool_call.toolCall.function.name:
70
85
  # default the arguments to an empty dictionary
71
86
  arguments = {}
72
87
  try:
73
- arguments = json.loads(tool_call.function.arguments)
88
+ arguments = json.loads(
89
+ tool_call.toolCall.function.arguments)
74
90
  except:
75
91
  pass
76
92
  try:
77
- response = tool(**arguments) # of type NodeInput
78
- websocket.send(json.dumps(response))
93
+ response = tool(**arguments)
79
94
  except Exception as e:
80
- error_message = f'Error occurred while running tool {tool.__name__}: {e}'
81
- e = ToolCallError(error=error_message)
95
+ error_message = 'Error occurred while running tool' +\
96
+ f'{tool.__name__}: {e}'
97
+ e = ToolCallError(error=error_message, reqId=req_id)
82
98
  websocket.send(e.model_dump_json())
83
- websocket.send(DeregisterDebuggerRequest(debuggerSessionId=self.session, deregister=True).model_dump_json())
99
+ formatted_response = None
100
+ try:
101
+ formatted_response = ToolCallResponse(
102
+ reqId=tool_call.reqId,
103
+ response=response
104
+ )
105
+ except pydantic.ValidationError as e:
106
+ formatted_response = ToolCallResponse(
107
+ reqId=tool_call.reqId,
108
+ response=str(response)
109
+ )
110
+ websocket.send(
111
+ formatted_response.model_dump_json()
112
+ )
113
+ websocket.send(
114
+ DeregisterDebuggerRequest(
115
+ debuggerSessionId=self.session,
116
+ deregister=True
117
+ ).model_dump_json()
118
+ )
84
119
 
85
120
  def _generate_session_id(self) -> str:
86
121
  return uuid.uuid4().urn[9:]
87
122
 
88
- def _format_session_id(self) -> str:
123
+ def _format_session_id_and_registerd_functions(self) -> str:
124
+ registered_functions = \
125
+ ',\n'.join(['- ' + tool.__name__ for tool in self.tools])
89
126
  return \
90
127
  f"""
91
128
  ========================================
92
129
  Debugger Session ID:
93
130
  {self.session}
131
+ ========================================
132
+
133
+ Registered functions:
134
+ {registered_functions}
135
+
94
136
  ========================================
95
137
  """
138
+
96
139
 
lmnr/types.py CHANGED
@@ -1,13 +1,21 @@
1
-
2
1
  import requests
3
2
  import pydantic
3
+ import uuid
4
4
  from typing import Union, Optional
5
5
 
6
+
6
7
  class ChatMessage(pydantic.BaseModel):
7
8
  role: str
8
9
  content: str
9
10
 
10
- NodeInput = Union[str, list[ChatMessage]] # TypeAlias
11
+
12
+ class ConditionedValue(pydantic.BaseModel):
13
+ condition: str
14
+ value: "NodeInput"
15
+
16
+
17
+ NodeInput = Union[str, list[ChatMessage], ConditionedValue] # TypeAlias
18
+
11
19
 
12
20
  class EndpointRunRequest(pydantic.BaseModel):
13
21
  inputs: dict[str, NodeInput]
@@ -15,10 +23,12 @@ class EndpointRunRequest(pydantic.BaseModel):
15
23
  env: dict[str, str] = pydantic.Field(default_factory=dict)
16
24
  metadata: dict[str, str] = pydantic.Field(default_factory=dict)
17
25
 
26
+
18
27
  class EndpointRunResponse(pydantic.BaseModel):
19
28
  outputs: dict[str, dict[str, NodeInput]]
20
29
  run_id: str
21
30
 
31
+
22
32
  class EndpointRunError(Exception):
23
33
  error_code: str
24
34
  error_message: str
@@ -26,39 +36,57 @@ class EndpointRunError(Exception):
26
36
  def __init__(self, response: requests.Response):
27
37
  try:
28
38
  resp_json = response.json()
29
- self.error_code = resp_json['error_code']
30
- self.error_message = resp_json['error_message']
39
+ self.error_code = resp_json["error_code"]
40
+ self.error_message = resp_json["error_message"]
31
41
  super().__init__(self.error_message)
32
- except:
42
+ except Exception:
33
43
  super().__init__(response.text)
34
-
44
+
35
45
  def __str__(self) -> str:
36
46
  try:
37
- return str({'error_code': self.error_code, 'error_message': self.error_message})
38
- except:
47
+ return str(
48
+ {"error_code": self.error_code, "error_message": self.error_message}
49
+ )
50
+ except Exception:
39
51
  return super().__str__()
40
-
52
+
53
+
41
54
  class SDKError(Exception):
42
55
  def __init__(self, error_message: str):
43
56
  super().__init__(error_message)
44
57
 
45
- class ToolCallRequest(pydantic.BaseModel):
58
+
59
+ class ToolCallFunction(pydantic.BaseModel):
46
60
  name: str
47
61
  arguments: str
48
62
 
63
+
49
64
  class ToolCall(pydantic.BaseModel):
50
65
  id: Optional[str]
51
66
  type: Optional[str]
52
- function: ToolCallRequest
67
+ function: ToolCallFunction
68
+
69
+
70
+ # TODO: allow snake_case and manually convert to camelCase
71
+ class ToolCallRequest(pydantic.BaseModel):
72
+ reqId: uuid.UUID
73
+ toolCall: ToolCall
74
+
75
+
76
+ class ToolCallResponse(pydantic.BaseModel):
77
+ reqId: uuid.UUID
78
+ response: NodeInput
79
+
53
80
 
54
- ToolCallResponse = NodeInput
55
81
  class ToolCallError(pydantic.BaseModel):
82
+ reqId: uuid.UUID
56
83
  error: str
57
84
 
58
- # TODO: allow snake_case and manually convert to camelCase
85
+
59
86
  class RegisterDebuggerRequest(pydantic.BaseModel):
60
87
  debuggerSessionId: str
61
88
 
89
+
62
90
  class DeregisterDebuggerRequest(pydantic.BaseModel):
63
91
  debuggerSessionId: str
64
92
  deregister: bool
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lmnr
3
- Version: 0.2.5
3
+ Version: 0.2.7
4
4
  Summary: Python SDK for Laminar AI
5
5
  License: Apache-2.0
6
6
  Author: lmnr.ai
@@ -17,11 +17,12 @@ Requires-Dist: cookiecutter (>=2.6.0,<3.0.0)
17
17
  Requires-Dist: pydantic (>=2.7.4,<3.0.0)
18
18
  Requires-Dist: python-dotenv (>=1.0.1,<2.0.0)
19
19
  Requires-Dist: requests (>=2.32.3,<3.0.0)
20
- Requires-Dist: urllib3 (==1.26.6)
21
20
  Requires-Dist: websockets (>=12.0,<13.0)
22
21
  Description-Content-Type: text/markdown
23
22
 
24
- # Python SDK for Laminar AI
23
+ # Laminar AI
24
+
25
+ This repo provides core for code generation, Laminar CLI, and Laminar SDK.
25
26
 
26
27
  ## Quickstart
27
28
  ```sh
@@ -139,14 +140,6 @@ Set up `DEBUGGER_SESSION_ID` environment variable in your pipeline.
139
140
 
140
141
  You can run as many sessions as you need, experimenting with your flows.
141
142
 
142
- #### 5. Stop the debugger
143
-
144
- In order to stop the session, do
145
-
146
- ```python
147
- debugger.stop()
148
- ```
149
-
150
143
  ## CLI for code generation
151
144
 
152
145
  ### Basic usage
@@ -5,24 +5,31 @@ lmnr/cli/cli.py,sha256=pzr5LUILi7TcaJIkC-CzmT7RG7-HWApQmUpgK9bc7mI,2847
5
5
  lmnr/cli/cookiecutter.json,sha256=PeiMMzCPzDhsapqYoAceYGPI5lOUNimvFzh5KeQv5QE,359
6
6
  lmnr/cli/parser/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  lmnr/cli/parser/nodes/__init__.py,sha256=BNbbfn0WwbFDA6TNhLOaT_Ji69rCL5voUibqMD7Knng,1163
8
- lmnr/cli/parser/nodes/types.py,sha256=fqw_0-szpcZ8G3bADjgG97gCng2DRIkL72nLV11gTP4,5081
9
- lmnr/cli/parser/parser.py,sha256=UtuupKj2Dh-47HVcYU4PI2s4Erh1gTjKiuGCcc-nkbM,2163
8
+ lmnr/cli/parser/nodes/code.py,sha256=GXqOxN6tdiStZGWLbN3WZCmDfzwYIgSRmZ5t72AOIXc,661
9
+ lmnr/cli/parser/nodes/condition.py,sha256=AJny0ILXbSy1hTwsRvZvDUqts9INNx63yQSkD7Dp7KU,740
10
+ lmnr/cli/parser/nodes/input.py,sha256=Xwktcih7Mezqv4cEejfPkpG8uJxDsbqRytBvKmlJDYE,578
11
+ lmnr/cli/parser/nodes/llm.py,sha256=iQWYFnQi5PcQD9WJpTSHbSzClM6s0wBOoEqyN5c4yQo,1674
12
+ lmnr/cli/parser/nodes/output.py,sha256=1XBppSscxM01kfZhE9oOh2GgdCVzyPVe2RAxLI5HmUc,665
13
+ lmnr/cli/parser/nodes/router.py,sha256=dmCx4ho8_GdFJXQa8UevMf_uEP7AKBv_MJ2zpLC6Vck,894
14
+ lmnr/cli/parser/nodes/semantic_search.py,sha256=o_XCR7BShAq8VGeKjPTwL6MxLdB07XHSd5CE71sFFiY,2105
15
+ lmnr/cli/parser/nodes/types.py,sha256=NRhlgI3WGd86AToo-tU974DEZzbLaH4iDdP-hEEQiIo,5343
16
+ lmnr/cli/parser/parser.py,sha256=kAZEeg358lyj_Q1IIhQB_bA7LW3Aw6RduShIfUSmLqQ,2173
10
17
  lmnr/cli/parser/utils.py,sha256=wVaqHVOR9VXl8Og9nkVyCVgHIcgbtYGkDOEGPtmjZ8g,715
11
18
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
19
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/__init__.py,sha256=pLVZqvDnNf9foGR-HXnX2F7WC2TWmyCTNcUctG8SXAI,27
13
20
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/action.py,sha256=mZMQwwPV5LtSfwdwQ7HefI3ttvwuokp4mhVI_Xn1Zck,274
14
- lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/engine.py,sha256=8DPh3zLhs4oDepuT5g6Z8mxuy2561v8P7Dhd4hUOn6g,8375
21
+ lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/engine.py,sha256=kCY6J7oQpm3f9YCYY2ZBzM_9bUv_XYTCRD_uFa6PLWQ,9640
15
22
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/state.py,sha256=wTx7jAv7b63-8k34cYfQp_DJxhCCOYT_qRHkmnZfnc0,1686
16
23
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/engine/task.py,sha256=ware5VIrZvluHH3mpH6h7G0YDk5L0buSQ7s09za4Fro,1200
17
24
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/pipelines/{{cookiecutter.pipeline_dir_name}}/__init__.py,sha256=bsfbNUBYtKv37qzc_GLhSAzBam2lnowP_dlr8pubhcg,80
18
- lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/pipelines/{{cookiecutter.pipeline_dir_name}}/nodes/functions.py,sha256=2fLTN6PbhlSPEcrHBSSJESmg__UNMk4Ivs5mz0LqnG8,5169
25
+ lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/pipelines/{{cookiecutter.pipeline_dir_name}}/nodes/functions.py,sha256=Bwu8p7m16NAyt9wC0DTQL0MrHbM44WylLs5wTLwSxBM,7845
19
26
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/pipelines/{{cookiecutter.pipeline_dir_name}}/{{cookiecutter.pipeline_dir_name}}.py,sha256=WG-ZMofPpGXCx5jdWVry3_XBzcKjqn8ZycFSiWEOBPg,2858
20
27
  lmnr/cli/{{cookiecutter.lmnr_pipelines_dir_name}}/types.py,sha256=iWuflMV7TiaBPs6-B-BlrovvWpZgHGGHK0v8rSqER7A,997
21
- lmnr/sdk/endpoint.py,sha256=tT6-w-mwbh4BAwnj5G0pCVE_Sz8EUzZmpBtacm_T2pE,6359
22
- lmnr/sdk/remote_debugger.py,sha256=pNSmmk-KAAOUygKQAeTB1DbB3vvlpHL8JLwPBpBhBwA,3892
23
- lmnr/types.py,sha256=Y41GGiCVemBUCTKedCwDhxjdsoO8uENr3LRwfdp9MI0,1771
24
- lmnr-0.2.5.dist-info/LICENSE,sha256=67b_wJHVV1CBaWkrKFWU1wyqTPSdzH77Ls-59631COg,10411
25
- lmnr-0.2.5.dist-info/METADATA,sha256=BgYsaYsxprvRZ5-RxZk8DQdSTKmF51hOvU27hg_0wn4,5492
26
- lmnr-0.2.5.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
27
- lmnr-0.2.5.dist-info/entry_points.txt,sha256=Qg7ZRax4k-rcQsZ26XRYQ8YFSBiyY2PNxYfq4a6PYXI,41
28
- lmnr-0.2.5.dist-info/RECORD,,
28
+ lmnr/sdk/endpoint.py,sha256=0HjcxMUcJz-klFZO2f5xtTaoLjcaEb8vrJ_YldTWUc8,7467
29
+ lmnr/sdk/remote_debugger.py,sha256=vCpMz7y3uboOi81qEwr8z3fhQ2H1y2YtJAxXVtb6uCA,5141
30
+ lmnr/types.py,sha256=OR9xRAQ5uTTwpJTDL_e3jZqxYJWvyX96CCoxr3oo94g,2112
31
+ lmnr-0.2.7.dist-info/LICENSE,sha256=67b_wJHVV1CBaWkrKFWU1wyqTPSdzH77Ls-59631COg,10411
32
+ lmnr-0.2.7.dist-info/METADATA,sha256=Ya1KVPAiyGxAZybuXSum8wmy4l-SnyYvaTTrvQ7uZRU,5427
33
+ lmnr-0.2.7.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
34
+ lmnr-0.2.7.dist-info/entry_points.txt,sha256=Qg7ZRax4k-rcQsZ26XRYQ8YFSBiyY2PNxYfq4a6PYXI,41
35
+ lmnr-0.2.7.dist-info/RECORD,,
File without changes
File without changes