Merge branch 'main' into multiuser

# Conflicts:
#	connpy/grpc_layer/server.py
This commit is contained in:
2026-05-28 10:47:21 -03:00
5 changed files with 71 additions and 19 deletions
+31 -5
View File
@@ -14,6 +14,23 @@ class NodeHandler:
self.app = app self.app = app
self.forms = Forms(app) self.forms = Forms(app)
def _filter_exact_match(self, matches, query):
if not query or len(matches) <= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args): def dispatch(self, args):
if not self.app.case and args.data != None: if not self.app.case and args.data != None:
args.data = args.data.lower() args.data = args.data.lower()
@@ -39,6 +56,7 @@ class NodeHandler:
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -73,6 +91,7 @@ class NodeHandler:
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -87,8 +106,9 @@ class NodeHandler:
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f"{matches[0]} deleted successfully") printer.success(f"{matches[0]} deleted successfully")
@@ -144,6 +164,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -171,6 +192,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -209,7 +231,7 @@ class NodeHandler:
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f"{args.data} edited successfully") printer.success(f"{args.data} edited successfully")
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item["type"] = "connection" updated_item["type"] = "connection"
@@ -222,8 +244,12 @@ class NodeHandler:
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info("Nothing to do here") printer.info("Nothing to do here")
+17 -2
View File
@@ -928,12 +928,17 @@ class StatusBridge:
return default return default
class AIServicer(connpy_pb2_grpc.AIServiceServicer): class AIServicer(connpy_pb2_grpc.AIServiceServicer):
def __init__(self, provider, registry=None): def __init__(self, provider, registry=None, debug=False):
if not hasattr(provider, "mode"): if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local") provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider self._fallback_provider = provider
self._registry = registry self._registry = registry
self.server_debug = debug
if debug:
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
def _get_provider(self): def _get_provider(self):
if self._registry: if self._registry:
@@ -988,6 +993,16 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
# Send final chunk marker # Send final chunk marker
chunk_queue.put(("final_mark", res)) chunk_queue.put(("final_mark", res))
except ValueError as e:
# Configuration or LLM provider connection errors are expected, only print in debug mode
if debug or getattr(self, "server_debug", False):
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
c = getattr(self, "server_console", None) or Console(theme=connpy_theme, file=get_original_stdout())
c.print(f"[debug][DEBUG][/debug] AI Task Error: {e}")
chunk_queue.put(("status", f"Error: {str(e)}"))
# Crucial: always send final_mark to avoid client deadlock
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "chat_history": history, "error": True}))
except Exception as e: except Exception as e:
import traceback import traceback
print(f"AI Task Error: {e}") print(f"AI Task Error: {e}")
@@ -1344,7 +1359,7 @@ def serve(config, port=8048, debug=False):
remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server) remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server)
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(fallback_provider, registry=registry), server) connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(fallback_provider, registry=registry), server) connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(fallback_provider, registry=registry), server) connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(fallback_provider, registry=registry, debug=debug), server)
connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(fallback_provider, registry=registry), server) connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(registry), server) connpy_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(registry), server)
+6 -7
View File
@@ -462,16 +462,18 @@ class NodeStub:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def move_node(self, src_id, dst_id, copy=False): def move_node(self, src_id, dst_id, copy=False):
@@ -895,9 +897,6 @@ class AIStub:
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get("response"):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
+6 -4
View File
@@ -148,7 +148,7 @@ class NodeService(BaseService):
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
"""Explicitly update an existing node.""" """Explicitly update an existing node."""
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -162,9 +162,10 @@ class NodeService(BaseService):
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
"""Logic for deleting a node or folder.""" """Logic for deleting a node or folder."""
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -177,7 +178,8 @@ class NodeService(BaseService):
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.") raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
"""Interact with a node directly.""" """Interact with a node directly."""
+11 -1
View File
@@ -40,7 +40,7 @@ def test_node_del(mock_prompt, mock_delete_node, mock_list_nodes, app):
mock_list_nodes.return_value = ["router1"] mock_list_nodes.return_value = ["router1"]
mock_prompt.return_value = {"delete": True} mock_prompt.return_value = {"delete": True}
app.start(["node", "-r", "router1"]) app.start(["node", "-r", "router1"])
mock_delete_node.assert_called_once_with("router1", is_folder=False) mock_delete_node.assert_called_once_with("router1", is_folder=False, save=True)
@patch("connpy.services.node_service.NodeService.list_nodes") @patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.get_node_details") @patch("connpy.services.node_service.NodeService.get_node_details")
@@ -314,3 +314,13 @@ def test_config_auth_file_path(mock_get_settings, mock_update_setting, mock_open
assert args[1]["engineer_auth"] == {"vertex_project": "file-project"} assert args[1]["engineer_auth"] == {"vertex_project": "file-project"}
@patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.connect_node")
def test_node_connect_exact_match_priority(mock_connect_node, mock_list_nodes, app):
"""Test that exact matches are prioritized over partial/regex matches when connecting."""
mock_list_nodes.return_value = ["pe1@ctx", "qro1pe1@ctx"]
app.start(["node", "pe1@ctx"])
mock_connect_node.assert_called_once_with("pe1@ctx", sftp=False, debug=False, logger=app._service_logger)