From e52d300cf1481aab30a9f505d5af530749d9f63d Mon Sep 17 00:00:00 2001 From: Fede Luzzi Date: Thu, 28 May 2026 18:22:00 -0300 Subject: [PATCH] new version and docs --- connpy/_version.py | 2 +- docs/connpy/cli/ai_handler.html | 4 +- docs/connpy/cli/api_handler.html | 4 +- docs/connpy/cli/config_handler.html | 4 +- docs/connpy/cli/context_handler.html | 4 +- docs/connpy/cli/forms.html | 4 +- docs/connpy/cli/help_text.html | 4 +- docs/connpy/cli/helpers.html | 4 +- docs/connpy/cli/import_export_handler.html | 4 +- docs/connpy/cli/index.html | 14 +- docs/connpy/cli/login_handler.html | 408 ++++++++++ docs/connpy/cli/node_handler.html | 59 +- docs/connpy/cli/plugin_handler.html | 4 +- docs/connpy/cli/profile_handler.html | 4 +- docs/connpy/cli/run_handler.html | 78 +- docs/connpy/cli/sync_handler.html | 4 +- docs/connpy/cli/terminal_ui.html | 4 +- docs/connpy/cli/user_handler.html | 522 +++++++++++++ docs/connpy/cli/validators.html | 4 +- docs/connpy/grpc_layer/connpy_pb2.html | 4 +- docs/connpy/grpc_layer/connpy_pb2_grpc.html | 295 +++++++- docs/connpy/grpc_layer/index.html | 9 +- docs/connpy/grpc_layer/remote_plugin_pb2.html | 12 +- .../grpc_layer/remote_plugin_pb2_grpc.html | 4 +- docs/connpy/grpc_layer/server.html | 696 ++++++++++++++++-- docs/connpy/grpc_layer/stubs.html | 347 ++++++++- docs/connpy/grpc_layer/user_registry.html | 295 ++++++++ docs/connpy/grpc_layer/utils.html | 4 +- docs/connpy/index.html | 130 +++- docs/connpy/mcp_client.html | 14 +- docs/connpy/proto/index.html | 4 +- docs/connpy/services/ai_service.html | 14 +- docs/connpy/services/base.html | 4 +- docs/connpy/services/config_service.html | 4 +- docs/connpy/services/context_service.html | 4 +- docs/connpy/services/exceptions.html | 4 +- docs/connpy/services/execution_service.html | 4 +- .../services/import_export_service.html | 4 +- docs/connpy/services/index.html | 336 ++++++--- docs/connpy/services/node_service.html | 28 +- docs/connpy/services/plugin_service.html | 297 +++++--- docs/connpy/services/profile_service.html | 4 +- docs/connpy/services/provider.html | 34 +- docs/connpy/services/sync_service.html | 4 +- docs/connpy/services/system_service.html | 4 +- docs/connpy/services/user_service.html | 595 +++++++++++++++ docs/connpy/tunnels.html | 4 +- docs/connpy/utils.html | 4 +- 48 files changed, 3910 insertions(+), 387 deletions(-) create mode 100644 docs/connpy/cli/login_handler.html create mode 100644 docs/connpy/cli/user_handler.html create mode 100644 docs/connpy/grpc_layer/user_registry.html create mode 100644 docs/connpy/services/user_service.html diff --git a/connpy/_version.py b/connpy/_version.py index ba9efd8..6aadc96 100644 --- a/connpy/_version.py +++ b/connpy/_version.py @@ -1 +1 @@ -__version__ = "6.0.0b12" +__version__ = "6.0.0b13" diff --git a/docs/connpy/cli/ai_handler.html b/docs/connpy/cli/ai_handler.html index a60ecf6..b50170c 100644 --- a/docs/connpy/cli/ai_handler.html +++ b/docs/connpy/cli/ai_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.ai_handler API documentation @@ -666,7 +666,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/api_handler.html b/docs/connpy/cli/api_handler.html index 29eb836..1263f6e 100644 --- a/docs/connpy/cli/api_handler.html +++ b/docs/connpy/cli/api_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.api_handler API documentation @@ -193,7 +193,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/config_handler.html b/docs/connpy/cli/config_handler.html index 85a922f..ec1277b 100644 --- a/docs/connpy/cli/config_handler.html +++ b/docs/connpy/cli/config_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.config_handler API documentation @@ -551,7 +551,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/context_handler.html b/docs/connpy/cli/context_handler.html index 11e2a86..a6b3dfb 100644 --- a/docs/connpy/cli/context_handler.html +++ b/docs/connpy/cli/context_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.context_handler API documentation @@ -249,7 +249,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/forms.html b/docs/connpy/cli/forms.html index 4a2c1bb..da27067 100644 --- a/docs/connpy/cli/forms.html +++ b/docs/connpy/cli/forms.html @@ -3,7 +3,7 @@ - + connpy.cli.forms API documentation @@ -690,7 +690,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/help_text.html b/docs/connpy/cli/help_text.html index 1440196..1fe8afd 100644 --- a/docs/connpy/cli/help_text.html +++ b/docs/connpy/cli/help_text.html @@ -3,7 +3,7 @@ - + connpy.cli.help_text API documentation @@ -303,7 +303,7 @@ tasks: diff --git a/docs/connpy/cli/helpers.html b/docs/connpy/cli/helpers.html index 0116865..4b523ad 100644 --- a/docs/connpy/cli/helpers.html +++ b/docs/connpy/cli/helpers.html @@ -3,7 +3,7 @@ - + connpy.cli.helpers API documentation @@ -333,7 +333,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/import_export_handler.html b/docs/connpy/cli/import_export_handler.html index cbb12ec..6f6aa1b 100644 --- a/docs/connpy/cli/import_export_handler.html +++ b/docs/connpy/cli/import_export_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.import_export_handler API documentation @@ -272,7 +272,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/index.html b/docs/connpy/cli/index.html index 6980227..7efb21a 100644 --- a/docs/connpy/cli/index.html +++ b/docs/connpy/cli/index.html @@ -3,7 +3,7 @@ - + connpy.cli API documentation @@ -72,6 +72,10 @@ el.replaceWith(d);
+
connpy.cli.login_handler
+
+
+
connpy.cli.node_handler
@@ -96,6 +100,10 @@ el.replaceWith(d);
+
connpy.cli.user_handler
+
+
+
connpy.cli.validators
@@ -129,12 +137,14 @@ el.replaceWith(d);
  • connpy.cli.help_text
  • connpy.cli.helpers
  • connpy.cli.import_export_handler
  • +
  • connpy.cli.login_handler
  • connpy.cli.node_handler
  • connpy.cli.plugin_handler
  • connpy.cli.profile_handler
  • connpy.cli.run_handler
  • connpy.cli.sync_handler
  • connpy.cli.terminal_ui
  • +
  • connpy.cli.user_handler
  • connpy.cli.validators
  • @@ -142,7 +152,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/login_handler.html b/docs/connpy/cli/login_handler.html new file mode 100644 index 0000000..8b0d6f3 --- /dev/null +++ b/docs/connpy/cli/login_handler.html @@ -0,0 +1,408 @@ + + + + + + +connpy.cli.login_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module connpy.cli.login_handler

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class LoginHandler +(app) +
    +
    +
    + +Expand source code + +
    class LoginHandler:
    +    def __init__(self, app):
    +        self.app = app
    +
    +    def dispatch(self, args):
    +        action = getattr(args, "action", None)
    +        if action == "login":
    +            return self.login(args)
    +        elif action == "logout":
    +            return self.logout(args)
    +        else:
    +            printer.error(f"Unknown action: {action}")
    +            sys.exit(1)
    +
    +    def login(self, args):
    +        if getattr(args, "status", False):
    +            return self.show_status()
    +
    +        if self.app.services.mode != "remote":
    +            printer.warning("Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to 'remote'.")
    +
    +        username = getattr(args, "username", None)
    +        if not username:
    +            try:
    +                username = input("Username: ").strip()
    +                if not username:
    +                    printer.error("Username cannot be empty.")
    +                    sys.exit(1)
    +            except (KeyboardInterrupt, EOFError):
    +                printer.warning("\nOperation cancelled.")
    +                sys.exit(130)
    +
    +        try:
    +            password = getpass.getpass("Password: ")
    +            if not password:
    +                printer.error("Password cannot be empty.")
    +                sys.exit(1)
    +        except (KeyboardInterrupt, EOFError):
    +            printer.warning("\nOperation cancelled.")
    +            sys.exit(130)
    +
    +        # Make the gRPC login call via self.app.services.auth stub
    +        # We need to make sure auth is initialized in remote mode.
    +        # If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
    +        # Let's instantiate it dynamically if it's not present.
    +        auth_service = getattr(self.app.services, "auth", None)
    +        if not auth_service:
    +            import grpc
    +            from ..grpc_layer.stubs import AuthStub
    +            remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
    +            if not remote_host:
    +                printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
    +                sys.exit(1)
    +            try:
    +                channel = grpc.insecure_channel(remote_host)
    +                auth_service = AuthStub(channel, remote_host=remote_host)
    +            except Exception as e:
    +                printer.error(f"Failed to connect to remote server for login: {e}")
    +                sys.exit(1)
    +
    +        try:
    +            res = auth_service.login(username, password)
    +            token = res["token"]
    +            
    +            # Save token to ~/.config/conn/.token
    +            token_path = os.path.join(self.app.config.defaultdir, ".token")
    +            with open(token_path, "w") as f:
    +                f.write(token)
    +            os.chmod(token_path, 0o600)
    +            
    +            printer.success(f"Logged in successfully as '{username}'. Session expires in 8 hours.")
    +        except ConnpyError as e:
    +            printer.error(f"Login failed: {e}")
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Login failed with unexpected error: {e}")
    +            sys.exit(1)
    +
    +    def logout(self, args):
    +        token_path = os.path.join(self.app.config.defaultdir, ".token")
    +        if os.path.exists(token_path):
    +            try:
    +                os.remove(token_path)
    +                printer.success("Logged out successfully. Local session cleared.")
    +            except Exception as e:
    +                printer.error(f"Failed to clear session: {e}")
    +                sys.exit(1)
    +        else:
    +            printer.info("No active session found (already logged out).")
    +
    +    def show_status(self):
    +        import base64
    +        import json
    +        import datetime
    +        
    +        token_path = os.path.join(self.app.config.defaultdir, ".token")
    +        if not os.path.exists(token_path):
    +            printer.warning("No active session found. You can log in using 'connpy login'.")
    +            return
    +            
    +        try:
    +            with open(token_path, "r") as f:
    +                token = f.read().strip()
    +                
    +            parts = token.split(".")
    +            if len(parts) != 3:
    +                printer.error("Invalid local session token format.")
    +                return
    +                
    +            payload_b64 = parts[1]
    +            payload_b64 += "=" * ((4 - len(payload_b64) % 4) % 4)
    +            payload_bytes = base64.urlsafe_b64decode(payload_b64)
    +            payload = json.loads(payload_bytes.decode("utf-8"))
    +            
    +            username = payload.get("sub")
    +            exp = payload.get("exp")
    +            
    +            if not exp:
    +                printer.success(f"Active session as '{username}' (Indefinite expiration).")
    +                return
    +                
    +            now = datetime.datetime.now(datetime.timezone.utc).timestamp()
    +            if now > exp:
    +                printer.error("Session has expired. Please log in again using 'connpy login'.")
    +                return
    +                
    +            remaining = exp - now
    +            hours = int(remaining // 3600)
    +            minutes = int((remaining % 3600) // 60)
    +            
    +            printer.success(f"Logged in as '{username}'")
    +            printer.info(f"Time remaining: {hours}h {minutes}m")
    +            
    +            exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
    +            printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
    +        except Exception as e:
    +            printer.error(f"Failed to check local session status: {e}")
    +
    +
    +

    Methods

    +
    +
    +def dispatch(self, args) +
    +
    +
    + +Expand source code + +
    def dispatch(self, args):
    +    action = getattr(args, "action", None)
    +    if action == "login":
    +        return self.login(args)
    +    elif action == "logout":
    +        return self.logout(args)
    +    else:
    +        printer.error(f"Unknown action: {action}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def login(self, args) +
    +
    +
    + +Expand source code + +
    def login(self, args):
    +    if getattr(args, "status", False):
    +        return self.show_status()
    +
    +    if self.app.services.mode != "remote":
    +        printer.warning("Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to 'remote'.")
    +
    +    username = getattr(args, "username", None)
    +    if not username:
    +        try:
    +            username = input("Username: ").strip()
    +            if not username:
    +                printer.error("Username cannot be empty.")
    +                sys.exit(1)
    +        except (KeyboardInterrupt, EOFError):
    +            printer.warning("\nOperation cancelled.")
    +            sys.exit(130)
    +
    +    try:
    +        password = getpass.getpass("Password: ")
    +        if not password:
    +            printer.error("Password cannot be empty.")
    +            sys.exit(1)
    +    except (KeyboardInterrupt, EOFError):
    +        printer.warning("\nOperation cancelled.")
    +        sys.exit(130)
    +
    +    # Make the gRPC login call via self.app.services.auth stub
    +    # We need to make sure auth is initialized in remote mode.
    +    # If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
    +    # Let's instantiate it dynamically if it's not present.
    +    auth_service = getattr(self.app.services, "auth", None)
    +    if not auth_service:
    +        import grpc
    +        from ..grpc_layer.stubs import AuthStub
    +        remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
    +        if not remote_host:
    +            printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
    +            sys.exit(1)
    +        try:
    +            channel = grpc.insecure_channel(remote_host)
    +            auth_service = AuthStub(channel, remote_host=remote_host)
    +        except Exception as e:
    +            printer.error(f"Failed to connect to remote server for login: {e}")
    +            sys.exit(1)
    +
    +    try:
    +        res = auth_service.login(username, password)
    +        token = res["token"]
    +        
    +        # Save token to ~/.config/conn/.token
    +        token_path = os.path.join(self.app.config.defaultdir, ".token")
    +        with open(token_path, "w") as f:
    +            f.write(token)
    +        os.chmod(token_path, 0o600)
    +        
    +        printer.success(f"Logged in successfully as '{username}'. Session expires in 8 hours.")
    +    except ConnpyError as e:
    +        printer.error(f"Login failed: {e}")
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Login failed with unexpected error: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def logout(self, args) +
    +
    +
    + +Expand source code + +
    def logout(self, args):
    +    token_path = os.path.join(self.app.config.defaultdir, ".token")
    +    if os.path.exists(token_path):
    +        try:
    +            os.remove(token_path)
    +            printer.success("Logged out successfully. Local session cleared.")
    +        except Exception as e:
    +            printer.error(f"Failed to clear session: {e}")
    +            sys.exit(1)
    +    else:
    +        printer.info("No active session found (already logged out).")
    +
    +
    +
    +
    +def show_status(self) +
    +
    +
    + +Expand source code + +
    def show_status(self):
    +    import base64
    +    import json
    +    import datetime
    +    
    +    token_path = os.path.join(self.app.config.defaultdir, ".token")
    +    if not os.path.exists(token_path):
    +        printer.warning("No active session found. You can log in using 'connpy login'.")
    +        return
    +        
    +    try:
    +        with open(token_path, "r") as f:
    +            token = f.read().strip()
    +            
    +        parts = token.split(".")
    +        if len(parts) != 3:
    +            printer.error("Invalid local session token format.")
    +            return
    +            
    +        payload_b64 = parts[1]
    +        payload_b64 += "=" * ((4 - len(payload_b64) % 4) % 4)
    +        payload_bytes = base64.urlsafe_b64decode(payload_b64)
    +        payload = json.loads(payload_bytes.decode("utf-8"))
    +        
    +        username = payload.get("sub")
    +        exp = payload.get("exp")
    +        
    +        if not exp:
    +            printer.success(f"Active session as '{username}' (Indefinite expiration).")
    +            return
    +            
    +        now = datetime.datetime.now(datetime.timezone.utc).timestamp()
    +        if now > exp:
    +            printer.error("Session has expired. Please log in again using 'connpy login'.")
    +            return
    +            
    +        remaining = exp - now
    +        hours = int(remaining // 3600)
    +        minutes = int((remaining % 3600) // 60)
    +        
    +        printer.success(f"Logged in as '{username}'")
    +        printer.info(f"Time remaining: {hours}h {minutes}m")
    +        
    +        exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
    +        printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
    +    except Exception as e:
    +        printer.error(f"Failed to check local session status: {e}")
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/connpy/cli/node_handler.html b/docs/connpy/cli/node_handler.html index 9123836..419dab5 100644 --- a/docs/connpy/cli/node_handler.html +++ b/docs/connpy/cli/node_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.node_handler API documentation @@ -60,6 +60,23 @@ el.replaceWith(d); self.app = 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): if not self.app.case and args.data != None: args.data = args.data.lower() @@ -85,6 +102,7 @@ el.replaceWith(d); else: try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -119,6 +137,7 @@ el.replaceWith(d); matches = self.app.services.nodes.list_folders(args.data) else: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -133,8 +152,9 @@ el.replaceWith(d); sys.exit(7) try: - for item in matches: - self.app.services.nodes.delete_node(item, is_folder=is_folder) + for i, item in enumerate(matches): + 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: printer.success(f"{matches[0]} deleted successfully") @@ -190,6 +210,7 @@ el.replaceWith(d); try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -217,6 +238,7 @@ el.replaceWith(d); try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -255,7 +277,7 @@ el.replaceWith(d); self.app.services.nodes.update_node(matches[0], updatenode) printer.success(f"{args.data} edited successfully") else: - editcount = 0 + changed_items = [] for k in matches: updated_item = self.app.services.nodes.explode_unique(k) updated_item["type"] = "connection" @@ -268,8 +290,12 @@ el.replaceWith(d); updated_item[key] = updatenode[key] if this_item_changed: - editcount += 1 - self.app.services.nodes.update_node(k, updated_item) + changed_items.append((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: printer.info("Nothing to do here") @@ -354,6 +380,7 @@ el.replaceWith(d); else: try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -398,6 +425,7 @@ el.replaceWith(d); matches = self.app.services.nodes.list_folders(args.data) else: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -412,8 +440,9 @@ el.replaceWith(d); sys.exit(7) try: - for item in matches: - self.app.services.nodes.delete_node(item, is_folder=is_folder) + for i, item in enumerate(matches): + 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: printer.success(f"{matches[0]} deleted successfully") @@ -456,6 +485,7 @@ el.replaceWith(d); try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -494,7 +524,7 @@ el.replaceWith(d); self.app.services.nodes.update_node(matches[0], updatenode) printer.success(f"{args.data} edited successfully") else: - editcount = 0 + changed_items = [] for k in matches: updated_item = self.app.services.nodes.explode_unique(k) updated_item["type"] = "connection" @@ -507,8 +537,12 @@ el.replaceWith(d); updated_item[key] = updatenode[key] if this_item_changed: - editcount += 1 - self.app.services.nodes.update_node(k, updated_item) + changed_items.append((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: printer.info("Nothing to do here") @@ -535,6 +569,7 @@ el.replaceWith(d); try: matches = self.app.services.nodes.list_nodes(args.data) + matches = self._filter_exact_match(matches, args.data) except Exception: matches = [] @@ -606,7 +641,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/plugin_handler.html b/docs/connpy/cli/plugin_handler.html index 318bde6..17142e7 100644 --- a/docs/connpy/cli/plugin_handler.html +++ b/docs/connpy/cli/plugin_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.plugin_handler API documentation @@ -385,7 +385,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/profile_handler.html b/docs/connpy/cli/profile_handler.html index 67daeaf..0d6680f 100644 --- a/docs/connpy/cli/profile_handler.html +++ b/docs/connpy/cli/profile_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.profile_handler API documentation @@ -314,7 +314,7 @@ el.replaceWith(d); diff --git a/docs/connpy/cli/run_handler.html b/docs/connpy/cli/run_handler.html index ba80e89..11759c3 100644 --- a/docs/connpy/cli/run_handler.html +++ b/docs/connpy/cli/run_handler.html @@ -3,7 +3,7 @@ - + connpy.cli.run_handler API documentation @@ -68,6 +68,17 @@ el.replaceWith(d); def node_run(self, args): nodes_filter = args.data[0] + + # Resolve and filter nodes through context-aware list_nodes + try: + matched_nodes = self.app.services.nodes.list_nodes(nodes_filter) + except Exception: + matched_nodes = [] + + if not matched_nodes: + printer.error(f"No nodes found matching filter: {nodes_filter}") + sys.exit(2) + commands = [" ".join(args.data[1:])] try: @@ -84,7 +95,7 @@ el.replaceWith(d); printer.test_panel(unique, node_output, node_status, node_result) results = self.app.services.execution.test_commands( - nodes_filter=nodes_filter, + nodes_filter=matched_nodes, commands=commands, expected=args.test_expected, on_node_complete=_on_node_complete @@ -101,7 +112,7 @@ el.replaceWith(d); printer.node_panel(unique, node_output, node_status) results = self.app.services.execution.run_commands( - nodes_filter=nodes_filter, + nodes_filter=matched_nodes, commands=commands, on_node_complete=_on_node_complete ) @@ -151,6 +162,28 @@ el.replaceWith(d); folder = output_cfg if output_cfg not in [None, "stdout"] else None prompt = options.get("prompt") + # Resolve and filter nodes through context-aware list_nodes + try: + if isinstance(nodelist, str): + resolved_nodes = self.app.services.nodes.list_nodes(nodelist) + elif isinstance(nodelist, list): + resolved_nodes = [] + for item in nodelist: + matches = self.app.services.nodes.list_nodes(item) + for m in matches: + if m not in resolved_nodes: + resolved_nodes.append(m) + else: + resolved_nodes = [] + except Exception: + resolved_nodes = [] + + if not resolved_nodes: + printer.error(f"[{name}] No nodes found matching filter: {nodelist}") + sys.exit(11) + + nodelist = resolved_nodes + try: header_printed = False if action == "run": @@ -242,6 +275,28 @@ el.replaceWith(d); folder = output_cfg if output_cfg not in [None, "stdout"] else None prompt = options.get("prompt") + # Resolve and filter nodes through context-aware list_nodes + try: + if isinstance(nodelist, str): + resolved_nodes = self.app.services.nodes.list_nodes(nodelist) + elif isinstance(nodelist, list): + resolved_nodes = [] + for item in nodelist: + matches = self.app.services.nodes.list_nodes(item) + for m in matches: + if m not in resolved_nodes: + resolved_nodes.append(m) + else: + resolved_nodes = [] + except Exception: + resolved_nodes = [] + + if not resolved_nodes: + printer.error(f"[{name}] No nodes found matching filter: {nodelist}") + sys.exit(11) + + nodelist = resolved_nodes + try: header_printed = False if action == "run": @@ -333,6 +388,17 @@ el.replaceWith(d);
    def node_run(self, args):
         nodes_filter = args.data[0]
    +    
    +    # Resolve and filter nodes through context-aware list_nodes
    +    try:
    +        matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
    +    except Exception:
    +        matched_nodes = []
    +        
    +    if not matched_nodes:
    +        printer.error(f"No nodes found matching filter: {nodes_filter}")
    +        sys.exit(2)
    +        
         commands = [" ".join(args.data[1:])]
     
         try:
    @@ -349,7 +415,7 @@ el.replaceWith(d);
                     printer.test_panel(unique, node_output, node_status, node_result)
     
                 results = self.app.services.execution.test_commands(
    -                nodes_filter=nodes_filter,
    +                nodes_filter=matched_nodes,
                     commands=commands,
                     expected=args.test_expected,
                     on_node_complete=_on_node_complete
    @@ -366,7 +432,7 @@ el.replaceWith(d);
                     printer.node_panel(unique, node_output, node_status)
     
                 results = self.app.services.execution.run_commands(
    -                nodes_filter=nodes_filter,
    +                nodes_filter=matched_nodes,
                     commands=commands,
                     on_node_complete=_on_node_complete
                 )
    @@ -454,7 +520,7 @@ el.replaceWith(d);
     
     
     
     
     
    diff --git a/docs/connpy/cli/sync_handler.html b/docs/connpy/cli/sync_handler.html
    index 4c751be..4ddd115 100644
    --- a/docs/connpy/cli/sync_handler.html
    +++ b/docs/connpy/cli/sync_handler.html
    @@ -3,7 +3,7 @@
     
     
     
    -
    +
     connpy.cli.sync_handler API documentation
     
     
    @@ -427,7 +427,7 @@ el.replaceWith(d);
     
     
     
     
     
    diff --git a/docs/connpy/cli/terminal_ui.html b/docs/connpy/cli/terminal_ui.html
    index 0d2e2c5..e39f037 100644
    --- a/docs/connpy/cli/terminal_ui.html
    +++ b/docs/connpy/cli/terminal_ui.html
    @@ -3,7 +3,7 @@
     
     
     
    -
    +
     connpy.cli.terminal_ui API documentation
     
     
    @@ -1017,7 +1017,7 @@ on_ai_call: async function(active_buffer, question) -> result_dict

    diff --git a/docs/connpy/cli/user_handler.html b/docs/connpy/cli/user_handler.html new file mode 100644 index 0000000..2bef829 --- /dev/null +++ b/docs/connpy/cli/user_handler.html @@ -0,0 +1,522 @@ + + + + + + +connpy.cli.user_handler API documentation + + + + + + + + + + + +
    +
    +
    +

    Module connpy.cli.user_handler

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    Classes

    +
    +
    +class UserHandler +(app) +
    +
    +
    + +Expand source code + +
    class UserHandler:
    +    def __init__(self, app):
    +        self.app = app
    +
    +    def dispatch(self, args):
    +        if self.app.services.mode == "remote":
    +            printer.error("User management commands are only available in local/server-side mode.")
    +            sys.exit(1)
    +
    +        # Parse actions from argparse mutually exclusive options
    +        if getattr(args, "add", None):
    +            args.action = "add"
    +            args.username = args.add[0]
    +        elif getattr(args, "delete", None):
    +            args.action = "del"
    +            args.username = args.delete[0]
    +        elif getattr(args, "list", False):
    +            args.action = "list"
    +        elif getattr(args, "show", None):
    +            args.action = "show"
    +            args.username = args.show[0]
    +        elif getattr(args, "regen_password", None):
    +            args.action = "regen_password"
    +            args.username = args.regen_password[0]
    +
    +        action = getattr(args, "action", None)
    +        
    +        if action == "add":
    +            return self.add_user(args)
    +        elif action == "del":
    +            return self.delete_user(args)
    +        elif action == "list":
    +            return self.list_users(args)
    +        elif action == "show":
    +            return self.show_user(args)
    +        elif action == "regen_password":
    +            return self.regen_password(args)
    +        else:
    +            printer.error(f"Unknown action: {action}")
    +            sys.exit(1)
    +
    +    def add_user(self, args):
    +        username = getattr(args, "username", None)
    +        if not username:
    +            printer.error("Username is required. Usage: connpy user --add <username>")
    +            sys.exit(1)
    +            
    +        custom_path = getattr(args, "path", None)
    +        if custom_path:
    +            custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
    +
    +        try:
    +            password = getpass.getpass("Enter password for new user: ")
    +            if not password:
    +                printer.error("Password cannot be empty.")
    +                sys.exit(1)
    +            confirm = getpass.getpass("Confirm password: ")
    +            if password != confirm:
    +                printer.error("Passwords do not match.")
    +                sys.exit(1)
    +        except (KeyboardInterrupt, EOFError):
    +            printer.warning("\nOperation cancelled.")
    +            sys.exit(130)
    +
    +        try:
    +            self.app.services.users.create_user(username, password, config_path=custom_path)
    +            printer.success(f"User '{username}' created successfully.")
    +        except ConnpyError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except ValueError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Failed to create user: {e}")
    +            sys.exit(1)
    +
    +    def delete_user(self, args):
    +        username = getattr(args, "username", None)
    +        if not username:
    +            printer.error("Username is required. Usage: connpy user --del <username>")
    +            sys.exit(1)
    +
    +        try:
    +            self.app.services.users.delete_user(username)
    +            printer.success(f"User '{username}' deleted successfully.")
    +        except ConnpyError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except ValueError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Failed to delete user: {e}")
    +            sys.exit(1)
    +
    +    def list_users(self, args):
    +        try:
    +            users = self.app.services.users.list_users()
    +            if not users:
    +                printer.warning("No users registered.")
    +                return
    +            
    +            # Format custom config path, falling back to computed default path instead of null/None
    +            formatted_users = []
    +            for u in users:
    +                formatted_u = u.copy()
    +                if not formatted_u.get("config_path"):
    +                    formatted_u["config_path"] = os.path.join(self.app.services.users.users_dir, formatted_u["username"])
    +                formatted_users.append(formatted_u)
    +            
    +            yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
    +            printer.data("Registered Users", yaml_str)
    +        except Exception as e:
    +            printer.error(f"Failed to list users: {e}")
    +            sys.exit(1)
    +
    +    def show_user(self, args):
    +        username = getattr(args, "username", None)
    +        if not username:
    +            printer.error("Username is required. Usage: connpy user --show <username>")
    +            sys.exit(1)
    +
    +        try:
    +            user = self.app.services.users.get_user(username)
    +            if not user:
    +                printer.error(f"User '{username}' not found.")
    +                sys.exit(1)
    +            
    +            # Hide the password hash from the CLI output for safety
    +            safe_user = {k: v for k, v in user.items() if k != "password_hash"}
    +            if not safe_user.get("config_path"):
    +                safe_user["config_path"] = os.path.join(self.app.services.users.users_dir, username)
    +            
    +            yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
    +            printer.data(f"User: {username}", yaml_str)
    +        except ValueError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Failed to retrieve user details: {e}")
    +            sys.exit(1)
    +
    +    def regen_password(self, args):
    +        username = getattr(args, "username", None)
    +        if not username:
    +            printer.error("Username is required. Usage: connpy user --regen-password <username>")
    +            sys.exit(1)
    +
    +        try:
    +            user = self.app.services.users.get_user(username)
    +            if not user:
    +                printer.error(f"User '{username}' not found.")
    +                sys.exit(1)
    +        except ValueError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Failed to retrieve user details: {e}")
    +            sys.exit(1)
    +
    +        try:
    +            new_password = getpass.getpass("Enter new password: ")
    +            if not new_password:
    +                printer.error("Password cannot be empty.")
    +                sys.exit(1)
    +            confirm = getpass.getpass("Confirm new password: ")
    +            if new_password != confirm:
    +                printer.error("Passwords do not match.")
    +                sys.exit(1)
    +        except (KeyboardInterrupt, EOFError):
    +            printer.warning("\nOperation cancelled.")
    +            sys.exit(130)
    +
    +        try:
    +            self.app.services.users.admin_change_password(username, new_password)
    +            printer.success(f"Password for user '{username}' regenerated successfully.")
    +        except ValueError as e:
    +            printer.error(str(e))
    +            sys.exit(1)
    +        except Exception as e:
    +            printer.error(f"Failed to regenerate password: {e}")
    +            sys.exit(1)
    +
    +
    +

    Methods

    +
    +
    +def add_user(self, args) +
    +
    +
    + +Expand source code + +
    def add_user(self, args):
    +    username = getattr(args, "username", None)
    +    if not username:
    +        printer.error("Username is required. Usage: connpy user --add <username>")
    +        sys.exit(1)
    +        
    +    custom_path = getattr(args, "path", None)
    +    if custom_path:
    +        custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
    +
    +    try:
    +        password = getpass.getpass("Enter password for new user: ")
    +        if not password:
    +            printer.error("Password cannot be empty.")
    +            sys.exit(1)
    +        confirm = getpass.getpass("Confirm password: ")
    +        if password != confirm:
    +            printer.error("Passwords do not match.")
    +            sys.exit(1)
    +    except (KeyboardInterrupt, EOFError):
    +        printer.warning("\nOperation cancelled.")
    +        sys.exit(130)
    +
    +    try:
    +        self.app.services.users.create_user(username, password, config_path=custom_path)
    +        printer.success(f"User '{username}' created successfully.")
    +    except ConnpyError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except ValueError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Failed to create user: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def delete_user(self, args) +
    +
    +
    + +Expand source code + +
    def delete_user(self, args):
    +    username = getattr(args, "username", None)
    +    if not username:
    +        printer.error("Username is required. Usage: connpy user --del <username>")
    +        sys.exit(1)
    +
    +    try:
    +        self.app.services.users.delete_user(username)
    +        printer.success(f"User '{username}' deleted successfully.")
    +    except ConnpyError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except ValueError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Failed to delete user: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def dispatch(self, args) +
    +
    +
    + +Expand source code + +
    def dispatch(self, args):
    +    if self.app.services.mode == "remote":
    +        printer.error("User management commands are only available in local/server-side mode.")
    +        sys.exit(1)
    +
    +    # Parse actions from argparse mutually exclusive options
    +    if getattr(args, "add", None):
    +        args.action = "add"
    +        args.username = args.add[0]
    +    elif getattr(args, "delete", None):
    +        args.action = "del"
    +        args.username = args.delete[0]
    +    elif getattr(args, "list", False):
    +        args.action = "list"
    +    elif getattr(args, "show", None):
    +        args.action = "show"
    +        args.username = args.show[0]
    +    elif getattr(args, "regen_password", None):
    +        args.action = "regen_password"
    +        args.username = args.regen_password[0]
    +
    +    action = getattr(args, "action", None)
    +    
    +    if action == "add":
    +        return self.add_user(args)
    +    elif action == "del":
    +        return self.delete_user(args)
    +    elif action == "list":
    +        return self.list_users(args)
    +    elif action == "show":
    +        return self.show_user(args)
    +    elif action == "regen_password":
    +        return self.regen_password(args)
    +    else:
    +        printer.error(f"Unknown action: {action}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def list_users(self, args) +
    +
    +
    + +Expand source code + +
    def list_users(self, args):
    +    try:
    +        users = self.app.services.users.list_users()
    +        if not users:
    +            printer.warning("No users registered.")
    +            return
    +        
    +        # Format custom config path, falling back to computed default path instead of null/None
    +        formatted_users = []
    +        for u in users:
    +            formatted_u = u.copy()
    +            if not formatted_u.get("config_path"):
    +                formatted_u["config_path"] = os.path.join(self.app.services.users.users_dir, formatted_u["username"])
    +            formatted_users.append(formatted_u)
    +        
    +        yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
    +        printer.data("Registered Users", yaml_str)
    +    except Exception as e:
    +        printer.error(f"Failed to list users: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def regen_password(self, args) +
    +
    +
    + +Expand source code + +
    def regen_password(self, args):
    +    username = getattr(args, "username", None)
    +    if not username:
    +        printer.error("Username is required. Usage: connpy user --regen-password <username>")
    +        sys.exit(1)
    +
    +    try:
    +        user = self.app.services.users.get_user(username)
    +        if not user:
    +            printer.error(f"User '{username}' not found.")
    +            sys.exit(1)
    +    except ValueError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Failed to retrieve user details: {e}")
    +        sys.exit(1)
    +
    +    try:
    +        new_password = getpass.getpass("Enter new password: ")
    +        if not new_password:
    +            printer.error("Password cannot be empty.")
    +            sys.exit(1)
    +        confirm = getpass.getpass("Confirm new password: ")
    +        if new_password != confirm:
    +            printer.error("Passwords do not match.")
    +            sys.exit(1)
    +    except (KeyboardInterrupt, EOFError):
    +        printer.warning("\nOperation cancelled.")
    +        sys.exit(130)
    +
    +    try:
    +        self.app.services.users.admin_change_password(username, new_password)
    +        printer.success(f"Password for user '{username}' regenerated successfully.")
    +    except ValueError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Failed to regenerate password: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +def show_user(self, args) +
    +
    +
    + +Expand source code + +
    def show_user(self, args):
    +    username = getattr(args, "username", None)
    +    if not username:
    +        printer.error("Username is required. Usage: connpy user --show <username>")
    +        sys.exit(1)
    +
    +    try:
    +        user = self.app.services.users.get_user(username)
    +        if not user:
    +            printer.error(f"User '{username}' not found.")
    +            sys.exit(1)
    +        
    +        # Hide the password hash from the CLI output for safety
    +        safe_user = {k: v for k, v in user.items() if k != "password_hash"}
    +        if not safe_user.get("config_path"):
    +            safe_user["config_path"] = os.path.join(self.app.services.users.users_dir, username)
    +        
    +        yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
    +        printer.data(f"User: {username}", yaml_str)
    +    except ValueError as e:
    +        printer.error(str(e))
    +        sys.exit(1)
    +    except Exception as e:
    +        printer.error(f"Failed to retrieve user details: {e}")
    +        sys.exit(1)
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + + diff --git a/docs/connpy/cli/validators.html b/docs/connpy/cli/validators.html index ee2f6b6..3bbf7cd 100644 --- a/docs/connpy/cli/validators.html +++ b/docs/connpy/cli/validators.html @@ -3,7 +3,7 @@ - + connpy.cli.validators API documentation @@ -508,7 +508,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/connpy_pb2.html b/docs/connpy/grpc_layer/connpy_pb2.html index 25db16e..0511c34 100644 --- a/docs/connpy/grpc_layer/connpy_pb2.html +++ b/docs/connpy/grpc_layer/connpy_pb2.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.connpy_pb2 API documentation @@ -61,7 +61,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/connpy_pb2_grpc.html b/docs/connpy/grpc_layer/connpy_pb2_grpc.html index cb46699..67d36e2 100644 --- a/docs/connpy/grpc_layer/connpy_pb2_grpc.html +++ b/docs/connpy/grpc_layer/connpy_pb2_grpc.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.connpy_pb2_grpc API documentation @@ -108,6 +108,34 @@ el.replaceWith(d);
    +
    +def add_AuthServiceServicer_to_server(servicer, server) +
    +
    +
    + +Expand source code + +
    def add_AuthServiceServicer_to_server(servicer, server):
    +    rpc_method_handlers = {
    +            'login': grpc.unary_unary_rpc_method_handler(
    +                    servicer.login,
    +                    request_deserializer=connpy__pb2.LoginRequest.FromString,
    +                    response_serializer=connpy__pb2.LoginResponse.SerializeToString,
    +            ),
    +            'change_password': grpc.unary_unary_rpc_method_handler(
    +                    servicer.change_password,
    +                    request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
    +                    response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
    +            ),
    +    }
    +    generic_handler = grpc.method_handlers_generic_handler(
    +            'connpy.AuthService', rpc_method_handlers)
    +    server.add_generic_rpc_handlers((generic_handler,))
    +    server.add_registered_method_handlers('connpy.AuthService', rpc_method_handlers)
    +
    +
    +
    def add_ConfigServiceServicer_to_server(servicer, server)
    @@ -1341,6 +1369,251 @@ def load_session_data(request,
    A grpc.Channel.
    +
    +class AuthService +
    +
    +
    + +Expand source code + +
    class AuthService(object):
    +    """Missing associated documentation comment in .proto file."""
    +
    +    @staticmethod
    +    def login(request,
    +            target,
    +            options=(),
    +            channel_credentials=None,
    +            call_credentials=None,
    +            insecure=False,
    +            compression=None,
    +            wait_for_ready=None,
    +            timeout=None,
    +            metadata=None):
    +        return grpc.experimental.unary_unary(
    +            request,
    +            target,
    +            '/connpy.AuthService/login',
    +            connpy__pb2.LoginRequest.SerializeToString,
    +            connpy__pb2.LoginResponse.FromString,
    +            options,
    +            channel_credentials,
    +            insecure,
    +            call_credentials,
    +            compression,
    +            wait_for_ready,
    +            timeout,
    +            metadata,
    +            _registered_method=True)
    +
    +    @staticmethod
    +    def change_password(request,
    +            target,
    +            options=(),
    +            channel_credentials=None,
    +            call_credentials=None,
    +            insecure=False,
    +            compression=None,
    +            wait_for_ready=None,
    +            timeout=None,
    +            metadata=None):
    +        return grpc.experimental.unary_unary(
    +            request,
    +            target,
    +            '/connpy.AuthService/change_password',
    +            connpy__pb2.ChangePasswordRequest.SerializeToString,
    +            google_dot_protobuf_dot_empty__pb2.Empty.FromString,
    +            options,
    +            channel_credentials,
    +            insecure,
    +            call_credentials,
    +            compression,
    +            wait_for_ready,
    +            timeout,
    +            metadata,
    +            _registered_method=True)
    +
    +

    Missing associated documentation comment in .proto file.

    +

    Static methods

    +
    +
    +def change_password(request,
    target,
    options=(),
    channel_credentials=None,
    call_credentials=None,
    insecure=False,
    compression=None,
    wait_for_ready=None,
    timeout=None,
    metadata=None)
    +
    +
    +
    + +Expand source code + +
    @staticmethod
    +def change_password(request,
    +        target,
    +        options=(),
    +        channel_credentials=None,
    +        call_credentials=None,
    +        insecure=False,
    +        compression=None,
    +        wait_for_ready=None,
    +        timeout=None,
    +        metadata=None):
    +    return grpc.experimental.unary_unary(
    +        request,
    +        target,
    +        '/connpy.AuthService/change_password',
    +        connpy__pb2.ChangePasswordRequest.SerializeToString,
    +        google_dot_protobuf_dot_empty__pb2.Empty.FromString,
    +        options,
    +        channel_credentials,
    +        insecure,
    +        call_credentials,
    +        compression,
    +        wait_for_ready,
    +        timeout,
    +        metadata,
    +        _registered_method=True)
    +
    +
    +
    +
    +def login(request,
    target,
    options=(),
    channel_credentials=None,
    call_credentials=None,
    insecure=False,
    compression=None,
    wait_for_ready=None,
    timeout=None,
    metadata=None)
    +
    +
    +
    + +Expand source code + +
    @staticmethod
    +def login(request,
    +        target,
    +        options=(),
    +        channel_credentials=None,
    +        call_credentials=None,
    +        insecure=False,
    +        compression=None,
    +        wait_for_ready=None,
    +        timeout=None,
    +        metadata=None):
    +    return grpc.experimental.unary_unary(
    +        request,
    +        target,
    +        '/connpy.AuthService/login',
    +        connpy__pb2.LoginRequest.SerializeToString,
    +        connpy__pb2.LoginResponse.FromString,
    +        options,
    +        channel_credentials,
    +        insecure,
    +        call_credentials,
    +        compression,
    +        wait_for_ready,
    +        timeout,
    +        metadata,
    +        _registered_method=True)
    +
    +
    +
    +
    +
    +
    +class AuthServiceServicer +
    +
    +
    + +Expand source code + +
    class AuthServiceServicer(object):
    +    """Missing associated documentation comment in .proto file."""
    +
    +    def login(self, request, context):
    +        """Missing associated documentation comment in .proto file."""
    +        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +        context.set_details('Method not implemented!')
    +        raise NotImplementedError('Method not implemented!')
    +
    +    def change_password(self, request, context):
    +        """Missing associated documentation comment in .proto file."""
    +        context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +        context.set_details('Method not implemented!')
    +        raise NotImplementedError('Method not implemented!')
    +
    +

    Missing associated documentation comment in .proto file.

    +

    Subclasses

    + +

    Methods

    +
    +
    +def change_password(self, request, context) +
    +
    +
    + +Expand source code + +
    def change_password(self, request, context):
    +    """Missing associated documentation comment in .proto file."""
    +    context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +    context.set_details('Method not implemented!')
    +    raise NotImplementedError('Method not implemented!')
    +
    +

    Missing associated documentation comment in .proto file.

    +
    +
    +def login(self, request, context) +
    +
    +
    + +Expand source code + +
    def login(self, request, context):
    +    """Missing associated documentation comment in .proto file."""
    +    context.set_code(grpc.StatusCode.UNIMPLEMENTED)
    +    context.set_details('Method not implemented!')
    +    raise NotImplementedError('Method not implemented!')
    +
    +

    Missing associated documentation comment in .proto file.

    +
    +
    +
    +
    +class AuthServiceStub +(channel) +
    +
    +
    + +Expand source code + +
    class AuthServiceStub(object):
    +    """Missing associated documentation comment in .proto file."""
    +
    +    def __init__(self, channel):
    +        """Constructor.
    +
    +        Args:
    +            channel: A grpc.Channel.
    +        """
    +        self.login = channel.unary_unary(
    +                '/connpy.AuthService/login',
    +                request_serializer=connpy__pb2.LoginRequest.SerializeToString,
    +                response_deserializer=connpy__pb2.LoginResponse.FromString,
    +                _registered_method=True)
    +        self.change_password = channel.unary_unary(
    +                '/connpy.AuthService/change_password',
    +                request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
    +                response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
    +                _registered_method=True)
    +
    +

    Missing associated documentation comment in .proto file.

    +

    Constructor.

    +

    Args

    +
    +
    channel
    +
    A grpc.Channel.
    +
    +
    class ConfigService
    @@ -5802,6 +6075,7 @@ def stop_api(request,
  • Functions

    • add_AIServiceServicer_to_server
    • +
    • add_AuthServiceServicer_to_server
    • add_ConfigServiceServicer_to_server
    • add_ExecutionServiceServicer_to_server
    • add_ImportExportServiceServicer_to_server
    • @@ -5845,6 +6119,23 @@ def stop_api(request,

      AIServiceStub

    • +

      AuthService

      + +
    • +
    • +

      AuthServiceServicer

      + +
    • +
    • +

      AuthServiceStub

      +
    • +
    • ConfigService

    • @@ -102,7 +107,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/remote_plugin_pb2.html b/docs/connpy/grpc_layer/remote_plugin_pb2.html index c841aa0..6e7bb97 100644 --- a/docs/connpy/grpc_layer/remote_plugin_pb2.html +++ b/docs/connpy/grpc_layer/remote_plugin_pb2.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.remote_plugin_pb2 API documentation @@ -62,7 +62,7 @@ el.replaceWith(d);
      var DESCRIPTOR
      -

      The type of the None singleton.

      +
      @@ -81,7 +81,7 @@ el.replaceWith(d);
      var DESCRIPTOR
      -

      The type of the None singleton.

      +
      @@ -100,7 +100,7 @@ el.replaceWith(d);
      var DESCRIPTOR
      -

      The type of the None singleton.

      +
      @@ -119,7 +119,7 @@ el.replaceWith(d);
      var DESCRIPTOR
      -

      The type of the None singleton.

      +
      @@ -168,7 +168,7 @@ el.replaceWith(d); diff --git a/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html b/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html index 61ed251..6372fcd 100644 --- a/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html +++ b/docs/connpy/grpc_layer/remote_plugin_pb2_grpc.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.remote_plugin_pb2_grpc API documentation @@ -366,7 +366,7 @@ def invoke_plugin(request, diff --git a/docs/connpy/grpc_layer/server.html b/docs/connpy/grpc_layer/server.html index f22e46a..beddcf5 100644 --- a/docs/connpy/grpc_layer/server.html +++ b/docs/connpy/grpc_layer/server.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.server API documentation @@ -59,10 +59,16 @@ el.replaceWith(d); try: for item in func(*args, **kwargs): yield item + except grpc.RpcError: + raise except ConnpyError as e: context = kwargs.get("context") or args[-1] context.abort(grpc.StatusCode.INTERNAL, str(e)) except Exception as e: + if type(e) is Exception and not e.args: + raise e + if e.__class__.__name__ in ("_AbortError", "RpcError"): + raise e context = kwargs.get("context") or args[-1] context.abort(grpc.StatusCode.UNKNOWN, str(e)) finally: @@ -72,10 +78,16 @@ el.replaceWith(d); def wrapper(*args, **kwargs): try: return func(*args, **kwargs) + except grpc.RpcError: + raise except ConnpyError as e: context = kwargs.get("context") or args[-1] context.abort(grpc.StatusCode.INTERNAL, str(e)) except Exception as e: + if type(e) is Exception and not e.args: + raise e + if e.__class__.__name__ in ("_AbortError", "RpcError"): + raise e context = kwargs.get("context") or args[-1] context.abort(grpc.StatusCode.UNKNOWN, str(e)) finally: @@ -93,19 +105,30 @@ el.replaceWith(d); Expand source code
      def serve(config, port=8048, debug=False):
      -    interceptors = [LoggingInterceptor()] if debug else []
      +    from connpy.grpc_layer.user_registry import UserRegistry
      +    from connpy.services.provider import ServiceProvider
      +
      +    fallback_provider = ServiceProvider(config, mode="local")
      +    registry = UserRegistry(config.defaultdir)
      +
      +    interceptors = []
      +    if debug:
      +        interceptors.append(LoggingInterceptor())
      +    interceptors.append(AuthInterceptor(registry))
      +    
           server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
           
      -    connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config, debug=debug), server)
      -    connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(config), server)
      -    connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server)
      -    plugin_servicer = PluginServicer(config)
      +    connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(fallback_provider, registry=registry, debug=debug), server)
      +    connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(fallback_provider, registry=registry), server)
      +    connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(fallback_provider, registry=registry), server)
      +    plugin_servicer = PluginServicer(fallback_provider, registry=registry)
           connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server)
           remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server)
      -    connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(config), server)
      -    connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(config), server)
      -    connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(config), server)
      -    connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(config), 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_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_AuthServiceServicer_to_server(AuthServicer(registry), server)
           
           server.add_insecure_port(f'[::]:{port}')
           server.start()
      @@ -120,7 +143,7 @@ el.replaceWith(d);
       
      class AIServicer -(config) +(provider, registry=None, debug=False)
      @@ -128,14 +151,35 @@ el.replaceWith(d); Expand source code
      class AIServicer(connpy_pb2_grpc.AIServiceServicer):
      -    def __init__(self, config):
      -        self.service = AIService(config)
      +    def __init__(self, provider, registry=None, debug=False):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        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):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().ai
       
           @handle_errors
           def ask(self, request_iterator, context):
               import queue
               import threading
       
      +        ai_service = self.service
               chunk_queue = queue.Queue()
               request_queue = queue.Queue()
               bridge = None
      @@ -153,7 +197,7 @@ el.replaceWith(d);
                   nonlocal history, bridge, agent_instance
                   try:
                       # Run the AI interaction (this blocks this specific thread)
      -                res = self.service.ask(
      +                res = ai_service.ask(
                           input_text,
                           chat_history=history if history else None,
                           session_id=session_id,
      @@ -172,6 +216,16 @@ el.replaceWith(d);
       
                       # Send final chunk marker
                       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:
                       import traceback
                       print(f"AI Task Error: {e}")
      @@ -313,6 +367,21 @@ el.replaceWith(d);
       
      +

      Instance variables

      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().ai
      +
      +
      +
      +

      Inherited members

      +
      +class AuthInterceptor +(registry) +
      +
      +
      + +Expand source code + +
      class AuthInterceptor(grpc.ServerInterceptor):
      +    OPEN_METHODS = ["/connpy.AuthService/login"]
      +
      +    def __init__(self, registry):
      +        self.registry = registry
      +
      +    def intercept_service(self, continuation, handler_call_details):
      +        method = handler_call_details.method
      +        if method in self.OPEN_METHODS:
      +            return continuation(handler_call_details)
      +
      +        if not self.registry.has_users():
      +            return continuation(handler_call_details)
      +
      +        token = self._extract_token(handler_call_details.invocation_metadata)
      +        if not token:
      +            return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
      +
      +        username = self.registry.user_service.verify_jwt(token)
      +        if not username:
      +            return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
      +
      +        handler = continuation(handler_call_details)
      +        if handler is None:
      +            return None
      +
      +        return self._wrap_handler(handler, username)
      +
      +    def _wrap_handler(self, handler, username):
      +        if handler.unary_unary:
      +            original_behavior = handler.unary_unary
      +            def wrapper(request, context):
      +                token = _current_user.set(username)
      +                try:
      +                    return original_behavior(request, context)
      +                finally:
      +                    _current_user.reset(token)
      +            return grpc.unary_unary_rpc_method_handler(
      +                wrapper,
      +                request_deserializer=handler.request_deserializer,
      +                response_serializer=handler.response_serializer,
      +            )
      +        elif handler.unary_stream:
      +            original_behavior = handler.unary_stream
      +            def wrapper(request, context):
      +                token = _current_user.set(username)
      +                try:
      +                    for response in original_behavior(request, context):
      +                        yield response
      +                finally:
      +                    _current_user.reset(token)
      +            return grpc.unary_stream_rpc_method_handler(
      +                wrapper,
      +                request_deserializer=handler.request_deserializer,
      +                response_serializer=handler.response_serializer,
      +            )
      +        elif handler.stream_unary:
      +            original_behavior = handler.stream_unary
      +            def wrapper(request_iterator, context):
      +                token = _current_user.set(username)
      +                try:
      +                    return original_behavior(request_iterator, context)
      +                finally:
      +                    _current_user.reset(token)
      +            return grpc.stream_unary_rpc_method_handler(
      +                wrapper,
      +                request_deserializer=handler.request_deserializer,
      +                response_serializer=handler.response_serializer,
      +            )
      +        elif handler.stream_stream:
      +            original_behavior = handler.stream_stream
      +            def wrapper(request_iterator, context):
      +                token = _current_user.set(username)
      +                try:
      +                    for response in original_behavior(request_iterator, context):
      +                        yield response
      +                finally:
      +                    _current_user.reset(token)
      +            return grpc.stream_stream_rpc_method_handler(
      +                wrapper,
      +                request_deserializer=handler.request_deserializer,
      +                response_serializer=handler.response_serializer,
      +            )
      +        return handler
      +
      +    def _extract_token(self, metadata):
      +        for key, value in metadata:
      +            if key.lower() == "authorization":
      +                if value.startswith("Bearer "):
      +                    return value[7:]
      +        return None
      +
      +    def _unauthenticated_handler(self, handler_call_details, message):
      +        def abort_call(request_or_iterator, context):
      +            context.abort(grpc.StatusCode.UNAUTHENTICATED, message)
      +        return grpc.unary_unary_rpc_method_handler(abort_call)
      +
      +

      Affords intercepting incoming RPCs on the service-side.

      +

      Ancestors

      +
        +
      • grpc.ServerInterceptor
      • +
      • abc.ABC
      • +
      +

      Class variables

      +
      +
      var OPEN_METHODS
      +
      +
      +
      +
      +

      Methods

      +
      +
      +def intercept_service(self, continuation, handler_call_details) +
      +
      +
      + +Expand source code + +
      def intercept_service(self, continuation, handler_call_details):
      +    method = handler_call_details.method
      +    if method in self.OPEN_METHODS:
      +        return continuation(handler_call_details)
      +
      +    if not self.registry.has_users():
      +        return continuation(handler_call_details)
      +
      +    token = self._extract_token(handler_call_details.invocation_metadata)
      +    if not token:
      +        return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
      +
      +    username = self.registry.user_service.verify_jwt(token)
      +    if not username:
      +        return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
      +
      +    handler = continuation(handler_call_details)
      +    if handler is None:
      +        return None
      +
      +    return self._wrap_handler(handler, username)
      +
      +

      Intercepts incoming RPCs before handing them over to a handler.

      +

      State can be passed from an interceptor to downstream interceptors +via contextvars. The first interceptor is called from an empty +contextvars.Context, and the same Context is used for downstream +interceptors and for the final handler call. Note that there are no +guarantees that interceptors and handlers will be called from the +same thread.

      +

      Args

      +
      +
      continuation
      +
      A function that takes a HandlerCallDetails and +proceeds to invoke the next interceptor in the chain, if any, +or the RPC handler lookup logic, with the call details passed +as an argument, and returns an RpcMethodHandler instance if +the RPC is considered serviced, or None otherwise.
      +
      handler_call_details
      +
      A HandlerCallDetails describing the RPC.
      +
      +

      Returns

      +

      An RpcMethodHandler with which the RPC may be serviced if the +interceptor chooses to service this RPC, or None otherwise.

      +
      +
      +
      +
      +class AuthServicer +(registry) +
      +
      +
      + +Expand source code + +
      class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
      +    def __init__(self, registry):
      +        self.registry = registry
      +
      +    @handle_errors
      +    def login(self, request, context):
      +        username = request.username
      +        password = request.password
      +        
      +        if not self.registry.user_service.authenticate(username, password):
      +            context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
      +            
      +        token = self.registry.user_service.generate_jwt(username)
      +        expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
      +        
      +        return connpy_pb2.LoginResponse(
      +            token=token,
      +            username=username,
      +            expires_at=expires_at
      +        )
      +
      +    @handle_errors
      +    def change_password(self, request, context):
      +        username = _current_user.get()
      +        if not username:
      +            context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
      +            
      +        try:
      +            self.registry.user_service.change_password(username, request.old_password, request.new_password)
      +            self.registry.evict(username)
      +        except ValueError as e:
      +            context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
      +            
      +        return Empty()
      +
      +

      Missing associated documentation comment in .proto file.

      +

      Ancestors

      + +

      Inherited members

      + +
      class ConfigServicer -(config) +(provider, registry=None)
      @@ -340,8 +643,23 @@ el.replaceWith(d); Expand source code
      class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
      -    def __init__(self, config):
      -        self.service = ConfigService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
      +
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().config_svc
       
           @handle_errors
           def get_settings(self, request, context):
      @@ -374,6 +692,21 @@ el.replaceWith(d);
       
      +

      Instance variables

      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().config_svc
      +
      +
      +
      +

      Inherited members

      class ExecutionServicer -(config) +(provider, registry=None)
      @@ -398,8 +731,23 @@ el.replaceWith(d); Expand source code
      class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
      -    def __init__(self, config):
      -        self.service = ExecutionService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
      +
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().execution
       
           @handle_errors
           def run_commands(self, request, context):
      @@ -408,6 +756,11 @@ el.replaceWith(d);
               
               nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
               
      +        # Resolve provider in the main gRPC thread where _current_user ContextVar is set.
      +        # threading.Thread does NOT inherit ContextVars, so self.service inside
      +        # _worker() would fall back to the admin provider.
      +        execution_service = self.service
      +        
               q = queue.Queue()
               
               def _on_complete(unique, output, status):
      @@ -415,7 +768,7 @@ el.replaceWith(d);
                   
               def _worker():
                   try:
      -                self.service.run_commands(                    nodes_filter=nodes_filter,
      +                execution_service.run_commands(                    nodes_filter=nodes_filter,
                           commands=list(request.commands),
                           folder=request.folder if request.folder else None,
                           prompt=request.prompt if request.prompt else None,
      @@ -454,6 +807,9 @@ el.replaceWith(d);
               
               nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
       
      +        # Resolve provider in the main gRPC thread where _current_user ContextVar is set.
      +        execution_service = self.service
      +        
               q = queue.Queue()
               
               def _on_complete(unique, node_output, node_status, node_result):
      @@ -461,7 +817,7 @@ el.replaceWith(d);
                   
               def _worker():
                   try:
      -                self.service.test_commands(
      +                execution_service.test_commands(
                           nodes_filter=nodes_filter,
                           commands=list(request.commands),
                           expected=list(request.expected),
      @@ -511,6 +867,21 @@ el.replaceWith(d);
       
      +

      Instance variables

      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().execution
      +
      +
      +
      +

      Inherited members

      class ImportExportServicer -(config) +(provider, registry=None)
      @@ -533,9 +904,27 @@ el.replaceWith(d); Expand source code
      class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
      -    def __init__(self, config):
      -        self.service = ImportExportService(config)
      -        self.node_service = NodeService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
      +
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().import_export
      +
      +    @property
      +    def node_service(self):
      +        return self._get_provider().nodes
       
           @handle_errors
           def export_to_file(self, request, context):
      @@ -565,6 +954,33 @@ el.replaceWith(d);
       
      +

      Instance variables

      +
      +
      prop node_service
      +
      +
      + +Expand source code + +
      @property
      +def node_service(self):
      +    return self._get_provider().nodes
      +
      +
      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().import_export
      +
      +
      +
      +

      Inherited members

      class NodeServicer -(config, debug=False) +(provider, registry=None, debug=False)
      @@ -674,25 +1090,46 @@ interceptor chooses to service this RPC, or None otherwise.

      Expand source code
      class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
      -    def __init__(self, config, debug=False):
      -        self.service = NodeService(config)
      +    def __init__(self, provider, registry=None, debug=False):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        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):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().nodes
      +
           @handle_errors
           def interact_node(self, request_iterator, context):
               import sys
               import os
               import asyncio
               from connpy.core import node
      -        from ..services.profile_service import ProfileService
               from connpy.tunnels import RemoteStream
               import queue
               import threading
       
      +        # Resolve provider once at the start of the RPC stream
      +        provider = self._get_provider()
      +        nodes_service = provider.nodes
      +        profile_service = provider.profiles
      +        ai_service = provider.ai
      +        user_config = provider.config
      +
               # Fetch first setup packet
               try:
                   first_req = next(request_iterator)
      @@ -719,9 +1156,9 @@ interceptor chooses to service this RPC, or None otherwise.

      if base_node_id: # Look up the base node in config and use its full data - nodes = self.service.config._getallnodes(base_node_id) + nodes = user_config._getallnodes(base_node_id) if nodes: - device = self.service.config.getitem(nodes[0]) + device = user_config.getitem(nodes[0]) # Override device properties with any passed in params for attr in valid_attrs: if attr in params: @@ -735,11 +1172,11 @@ interceptor chooses to service this RPC, or None otherwise.

      device["tags"] = device_tags node_name = params.get("name", base_node_id) - n = node(node_name, **device, config=self.service.config) + n = node(node_name, **device, config=user_config) else: # base_node not found, fall back to dynamic node_name = params.get("name", fallback_id) - n = node(node_name, host=params.get("host", ""), config=self.service.config) + n = node(node_name, host=params.get("host", ""), config=user_config) for attr in valid_attrs: if attr in params: setattr(n, attr, params[attr]) @@ -747,19 +1184,22 @@ interceptor chooses to service this RPC, or None otherwise.

      n.tags = params["tags"] else: node_name = params.get("name", fallback_id) - n = node(node_name, host=params.get("host", ""), config=self.service.config) + n = node(node_name, host=params.get("host", ""), config=user_config) for attr in valid_attrs: if attr in params: setattr(n, attr, params[attr]) if "tags" in params: n.tags = params["tags"] else: - node_data = self.service.config.getitem(unique_id, extract=False) + try: + node_data = user_config.getitem(unique_id, extract=False) + except (KeyError, TypeError): + node_data = None + if not node_data: context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found") - profile_service = ProfileService(self.service.config) resolved_data = profile_service.resolve_node_data(node_data) - n = node(unique_id, **resolved_data, config=self.service.config) + n = node(unique_id, **resolved_data, config=user_config) if sftp: n.protocol = "sftp" @@ -826,9 +1266,8 @@ interceptor chooses to service this RPC, or None otherwise.

      import json import asyncio import os - from ..services.ai_service import AIService - service = AIService(self.service.config) + service = ai_service if node_info is None: node_info = {} @@ -1102,6 +1541,21 @@ interceptor chooses to service this RPC, or None otherwise.

      +

      Instance variables

      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().nodes
      +
      +
      +
      +

      Inherited members

      • NodeServiceServicer: @@ -1127,7 +1581,7 @@ interceptor chooses to service this RPC, or None otherwise.

      class PluginServicer -(config) +(provider, registry=None)
      @@ -1135,8 +1589,23 @@ interceptor chooses to service this RPC, or None otherwise.

      Expand source code
      class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer):
      -    def __init__(self, config):
      -        self.service = PluginService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
      +
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().plugins
       
           @handle_errors
           def list_plugins(self, request, context):
      @@ -1183,6 +1652,21 @@ interceptor chooses to service this RPC, or None otherwise.

    • PluginServiceServicer
    • RemotePluginServiceServicer
    +

    Instance variables

    +
    +
    prop service
    +
    +
    + +Expand source code + +
    @property
    +def service(self):
    +    return self._get_provider().plugins
    +
    +
    +
    +

    Inherited members

    • PluginServiceServicer: @@ -1204,7 +1688,7 @@ interceptor chooses to service this RPC, or None otherwise.

      class ProfileServicer -(config) +(provider, registry=None)
      @@ -1212,10 +1696,27 @@ interceptor chooses to service this RPC, or None otherwise.

      Expand source code
      class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
      -    def __init__(self, config):
      -        self.service = ProfileService(config)
      -        self.node_service = NodeService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
       
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().profiles
      +
      +    @property
      +    def node_service(self):
      +        return self._get_provider().nodes
       
           @handle_errors
           def list_profiles(self, request, context):
      @@ -1253,6 +1754,33 @@ interceptor chooses to service this RPC, or None otherwise.

      +

      Instance variables

      +
      +
      prop node_service
      +
      +
      + +Expand source code + +
      @property
      +def node_service(self):
      +    return self._get_provider().nodes
      +
      +
      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().profiles
      +
      +
      +
      +

      Inherited members

      class SystemServicer -(config) +(provider, registry=None)
      @@ -1471,8 +1999,23 @@ interceptor chooses to service this RPC, or None otherwise.

      Expand source code
      class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
      -    def __init__(self, config):
      -        self.service = SystemService(config)
      +    def __init__(self, provider, registry=None):
      +        if not hasattr(provider, "mode"):
      +            from connpy.services.provider import ServiceProvider
      +            provider = ServiceProvider(provider, mode="local")
      +        self._fallback_provider = provider
      +        self._registry = registry
      +
      +    def _get_provider(self):
      +        if self._registry:
      +            username = _current_user.get()
      +            if username:
      +                return self._registry.get_provider(username)
      +        return self._fallback_provider
      +
      +    @property
      +    def service(self):
      +        return self._get_provider().system
       
           @handle_errors
           def start_api(self, request, context):
      @@ -1503,6 +2046,21 @@ interceptor chooses to service this RPC, or None otherwise.

      +

      Instance variables

      +
      +
      prop service
      +
      +
      + +Expand source code + +
      @property
      +def service(self):
      +    return self._get_provider().system
      +
      +
      +
      +

      Inherited members

      • SystemServiceServicer: @@ -1539,15 +2097,38 @@ interceptor chooses to service this RPC, or None otherwise.

      • @@ -1583,7 +2177,7 @@ interceptor chooses to service this RPC, or None otherwise.

        diff --git a/docs/connpy/grpc_layer/stubs.html b/docs/connpy/grpc_layer/stubs.html index 7454a4d..efd0d8f 100644 --- a/docs/connpy/grpc_layer/stubs.html +++ b/docs/connpy/grpc_layer/stubs.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.stubs API documentation @@ -272,9 +272,6 @@ el.replaceWith(d); from ..printer import connpy_theme, get_original_stdout stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) 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 except Exception as e: # Check if it was a gRPC error that we should let handle_errors catch @@ -517,9 +514,6 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu from ..printer import connpy_theme, get_original_stdout stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) 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 except Exception as e: # Check if it was a gRPC error that we should let handle_errors catch @@ -652,6 +646,303 @@ def load_session_data(self, session_id):
      +
      +class AuthClientInterceptor +(token_provider) +
      +
      +
      + +Expand source code + +
      class AuthClientInterceptor(grpc.UnaryUnaryClientInterceptor,
      +                            grpc.UnaryStreamClientInterceptor,
      +                            grpc.StreamUnaryClientInterceptor,
      +                            grpc.StreamStreamClientInterceptor):
      +    def __init__(self, token_provider):
      +        self.token_provider = token_provider
      +
      +    def _add_metadata(self, client_call_details):
      +        token = self.token_provider()
      +        if not token:
      +            return client_call_details
      +        
      +        metadata = []
      +        if client_call_details.metadata:
      +            metadata = list(client_call_details.metadata)
      +            
      +        # Check if already present to avoid duplicates
      +        if not any(k.lower() == "authorization" for k, v in metadata):
      +            metadata.append(("authorization", f"Bearer {token}"))
      +            
      +        return _ClientCallDetails(
      +            method=client_call_details.method,
      +            timeout=client_call_details.timeout,
      +            metadata=metadata,
      +            credentials=client_call_details.credentials,
      +            wait_for_ready=client_call_details.wait_for_ready,
      +            compression=client_call_details.compression,
      +        )
      +
      +    def intercept_unary_unary(self, continuation, client_call_details, request):
      +        new_details = self._add_metadata(client_call_details)
      +        return continuation(new_details, request)
      +
      +    def intercept_unary_stream(self, continuation, client_call_details, request):
      +        new_details = self._add_metadata(client_call_details)
      +        return continuation(new_details, request)
      +
      +    def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
      +        new_details = self._add_metadata(client_call_details)
      +        return continuation(new_details, request_iterator)
      +
      +    def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
      +        new_details = self._add_metadata(client_call_details)
      +        return continuation(new_details, request_iterator)
      +
      +

      Affords intercepting unary-unary invocations.

      +

      Ancestors

      +
        +
      • grpc.UnaryUnaryClientInterceptor
      • +
      • grpc.UnaryStreamClientInterceptor
      • +
      • grpc.StreamUnaryClientInterceptor
      • +
      • grpc.StreamStreamClientInterceptor
      • +
      • abc.ABC
      • +
      +

      Methods

      +
      +
      +def intercept_stream_stream(self, continuation, client_call_details, request_iterator) +
      +
      +
      + +Expand source code + +
      def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
      +    new_details = self._add_metadata(client_call_details)
      +    return continuation(new_details, request_iterator)
      +
      +

      Intercepts a stream-stream invocation.

      +

      Args

      +
      +
      continuation
      +
      A function that proceeds with the invocation by +executing the next interceptor in chain or invoking the +actual RPC on the underlying Channel. It is the interceptor's +responsibility to call it if it decides to move the RPC forward. +The interceptor can use +response_iterator = continuation(client_call_details, request_iterator) +to continue with the RPC. continuation returns an object that is +both a Call for the RPC and an iterator for response values. +Drawing response values from the returned Call-iterator may +raise RpcError indicating termination of the RPC with non-OK +status.
      +
      client_call_details
      +
      A ClientCallDetails object describing the +outgoing RPC.
      +
      request_iterator
      +
      An iterator that yields request values for the RPC.
      +
      +

      Returns

      +

      An object that is both a Call for the RPC and an iterator of +response values. Drawing response values from the returned +Call-iterator may raise RpcError indicating termination of +the RPC with non-OK status. This object should also fulfill the +Future interface, though it may not.

      +
      +
      +def intercept_stream_unary(self, continuation, client_call_details, request_iterator) +
      +
      +
      + +Expand source code + +
      def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
      +    new_details = self._add_metadata(client_call_details)
      +    return continuation(new_details, request_iterator)
      +
      +

      Intercepts a stream-unary invocation asynchronously.

      +

      Args

      +
      +
      continuation
      +
      A function that proceeds with the invocation by +executing the next interceptor in chain or invoking the +actual RPC on the underlying Channel. It is the interceptor's +responsibility to call it if it decides to move the RPC forward. +The interceptor can use +response_future = continuation(client_call_details, request_iterator) +to continue with the RPC. continuation returns an object that is +both a Call for the RPC and a Future. In the event of RPC completion, +the return Call-Future's result value will be the response message +of the RPC. Should the event terminate with non-OK status, the +returned Call-Future's exception value will be an RpcError.
      +
      client_call_details
      +
      A ClientCallDetails object describing the +outgoing RPC.
      +
      request_iterator
      +
      An iterator that yields request values for the RPC.
      +
      +

      Returns

      +

      An object that is both a Call for the RPC and a Future. +In the event of RPC completion, the return Call-Future's +result value will be the response message of the RPC. +Should the event terminate with non-OK status, the returned +Call-Future's exception value will be an RpcError.

      +
      +
      +def intercept_unary_stream(self, continuation, client_call_details, request) +
      +
      +
      + +Expand source code + +
      def intercept_unary_stream(self, continuation, client_call_details, request):
      +    new_details = self._add_metadata(client_call_details)
      +    return continuation(new_details, request)
      +
      +

      Intercepts a unary-stream invocation.

      +

      Args

      +
      +
      continuation
      +
      A function that proceeds with the invocation by +executing the next interceptor in chain or invoking the +actual RPC on the underlying Channel. It is the interceptor's +responsibility to call it if it decides to move the RPC forward. +The interceptor can use +response_iterator = continuation(client_call_details, request) +to continue with the RPC. continuation returns an object that is +both a Call for the RPC and an iterator for response values. +Drawing response values from the returned Call-iterator may +raise RpcError indicating termination of the RPC with non-OK +status.
      +
      client_call_details
      +
      A ClientCallDetails object describing the +outgoing RPC.
      +
      request
      +
      The request value for the RPC.
      +
      +

      Returns

      +

      An object that is both a Call for the RPC and an iterator of +response values. Drawing response values from the returned +Call-iterator may raise RpcError indicating termination of +the RPC with non-OK status. This object should also fulfill the +Future interface, though it may not.

      +
      +
      +def intercept_unary_unary(self, continuation, client_call_details, request) +
      +
      +
      + +Expand source code + +
      def intercept_unary_unary(self, continuation, client_call_details, request):
      +    new_details = self._add_metadata(client_call_details)
      +    return continuation(new_details, request)
      +
      +

      Intercepts a unary-unary invocation asynchronously.

      +

      Args

      +
      +
      continuation
      +
      A function that proceeds with the invocation by +executing the next interceptor in chain or invoking the +actual RPC on the underlying Channel. It is the interceptor's +responsibility to call it if it decides to move the RPC forward. +The interceptor can use +response_future = continuation(client_call_details, request) +to continue with the RPC. continuation returns an object that is +both a Call for the RPC and a Future. In the event of RPC +completion, the return Call-Future's result value will be +the response message of the RPC. Should the event terminate +with non-OK status, the returned Call-Future's exception value +will be an RpcError.
      +
      client_call_details
      +
      A ClientCallDetails object describing the +outgoing RPC.
      +
      request
      +
      The request value for the RPC.
      +
      +

      Returns

      +

      An object that is both a Call for the RPC and a Future. +In the event of RPC completion, the return Call-Future's +result value will be the response message of the RPC. +Should the event terminate with non-OK status, the returned +Call-Future's exception value will be an RpcError.

      +
      +
      +
      +
      +class AuthStub +(channel, remote_host) +
      +
      +
      + +Expand source code + +
      class AuthStub:
      +    def __init__(self, channel, remote_host):
      +        self.stub = connpy_pb2_grpc.AuthServiceStub(channel)
      +        self.remote_host = remote_host
      +
      +    @handle_errors
      +    def login(self, username, password):
      +        req = connpy_pb2.LoginRequest(username=username, password=password)
      +        resp = self.stub.login(req)
      +        return {
      +            "token": resp.token,
      +            "username": resp.username,
      +            "expires_at": resp.expires_at
      +        }
      +
      +    @handle_errors
      +    def change_password(self, old_password, new_password):
      +        req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
      +        self.stub.change_password(req)
      +
      +
      +

      Methods

      +
      +
      +def change_password(self, old_password, new_password) +
      +
      +
      + +Expand source code + +
      @handle_errors
      +def change_password(self, old_password, new_password):
      +    req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
      +    self.stub.change_password(req)
      +
      +
      +
      +
      +def login(self, username, password) +
      +
      +
      + +Expand source code + +
      @handle_errors
      +def login(self, username, password):
      +    req = connpy_pb2.LoginRequest(username=username, password=password)
      +    resp = self.stub.login(req)
      +    return {
      +        "token": resp.token,
      +        "username": resp.username,
      +        "expires_at": resp.expires_at
      +    }
      +
      +
      +
      +
      +
      class ConfigStub (channel, remote_host) @@ -1467,16 +1758,18 @@ def set_reserved_names(self, names): self._trigger_local_cache_sync() @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) self.stub.update_node(req) - self._trigger_local_cache_sync() + if save: + self._trigger_local_cache_sync() @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) self.stub.delete_node(req) - self._trigger_local_cache_sync() + if save: + self._trigger_local_cache_sync() @handle_errors def move_node(self, src_id, dst_id, copy=False): @@ -1857,7 +2150,7 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
      -def delete_node(self, unique_id, is_folder=False) +def delete_node(self, unique_id, is_folder=False, save=True)
      @@ -1865,10 +2158,11 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None): Expand source code
      @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)
           self.stub.delete_node(req)
      -    self._trigger_local_cache_sync()
      + if save: + self._trigger_local_cache_sync()
      @@ -2028,7 +2322,7 @@ def set_reserved_names(self, names):
      -def update_node(self, unique_id, data) +def update_node(self, unique_id, data, save=True)
      @@ -2036,10 +2330,11 @@ def set_reserved_names(self, names): Expand source code
      @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)
           self.stub.update_node(req)
      -    self._trigger_local_cache_sync()
      + if save: + self._trigger_local_cache_sync()
      @@ -2532,6 +2827,22 @@ def stop_api(self):
  • +

    AuthClientInterceptor

    + +
  • +
  • +

    AuthStub

    + +
  • +
  • ConfigStub

    • encrypt_password
    • @@ -2618,7 +2929,7 @@ def stop_api(self): diff --git a/docs/connpy/grpc_layer/user_registry.html b/docs/connpy/grpc_layer/user_registry.html new file mode 100644 index 0000000..8f05673 --- /dev/null +++ b/docs/connpy/grpc_layer/user_registry.html @@ -0,0 +1,295 @@ + + + + + + +connpy.grpc_layer.user_registry API documentation + + + + + + + + + + + +
      +
      +
      +

      Module connpy.grpc_layer.user_registry

      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class UserRegistry +(server_config_dir) +
      +
      +
      + +Expand source code + +
      class UserRegistry:
      +    """Holds per-user ServiceProviders in memory, thread-safe with hot-reloading."""
      +    def __init__(self, server_config_dir):
      +        self.server_config_dir = os.path.abspath(server_config_dir)
      +        self.user_service = UserService(self.server_config_dir)
      +        self._providers = {}   # username → ServiceProvider
      +        self._mtimes = {}      # username → last loaded mtime (float)
      +        self._lock = threading.Lock()
      +        
      +        # Load shared/global config
      +        self._shared_conf_file = os.path.join(self.server_config_dir, "config.yaml")
      +        if os.path.exists(self._shared_conf_file):
      +            self._shared_config = configfile(conf=self._shared_conf_file)
      +            self._shared_mtime = os.path.getmtime(self._shared_conf_file)
      +        else:
      +            self._shared_config = None
      +            self._shared_mtime = 0.0
      +
      +    def _refresh_shared(self):
      +        """Hot-reload shared config if the file changed on disk."""
      +        if not os.path.exists(self._shared_conf_file):
      +            return
      +        current_mtime = os.path.getmtime(self._shared_conf_file)
      +        if current_mtime > self._shared_mtime:
      +            try:
      +                self._shared_config = configfile(conf=self._shared_conf_file)
      +                self._shared_mtime = current_mtime
      +                # Clear all user providers so they pick up the new shared config
      +                self._providers.clear()
      +                self._mtimes.clear()
      +            except Exception as e:
      +                from connpy import printer
      +                printer.warning(f"Failed to reload shared config: {e}")
      +    
      +    def get_provider(self, username) -> ServiceProvider:
      +        """Get, lazy-load, or hot-reload a user's full ServiceProvider."""
      +        with self._lock:
      +            # Refresh shared/global config if it has changed
      +            self._refresh_shared()
      +            
      +            # 1. Resolve physical path of the user's config.yaml file
      +            user_data = self.user_service.get_user(username)
      +            config_path = user_data.get("config_path")
      +            if config_path:
      +                conf_file = os.path.join(config_path, "config.yaml")
      +            else:
      +                conf_file = os.path.join(self.server_config_dir, "users", username, "config.yaml")
      +            
      +            # 2. Retrieve actual modification time in disk
      +            current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
      +            
      +            # 3. Validate if initial load or hot-reload is required
      +            if username not in self._providers or self._mtimes.get(username, 0.0) < current_mtime:
      +                old_provider = self._providers.get(username)
      +                
      +                try:
      +                    # Attempt a fresh configuration load
      +                    config = configfile(conf=conf_file, shared_config=self._shared_config)
      +                    new_provider = ServiceProvider(config, mode="local")
      +                    
      +                    # Successfully loaded, clean up the old provider
      +                    if old_provider:
      +                        self._providers.pop(username, None)
      +                        if hasattr(old_provider, "close"):
      +                            try:
      +                                old_provider.close()
      +                            except Exception:
      +                                pass
      +                                
      +                    self._providers[username] = new_provider
      +                    self._mtimes[username] = current_mtime
      +                    
      +                except Exception as e:
      +                    # Log warning but fallback to the old stable provider in memory if available
      +                    from connpy import printer
      +                    printer.warning(f"Failed to hot-reload config for user '{username}' (file may be corrupt/incomplete): {e}")
      +                    if old_provider:
      +                        # Keep serving with the old cached instance to ensure service continuity
      +                        self._mtimes[username] = current_mtime
      +                    else:
      +                        # No fallback exists, propagate the exception
      +                        raise e
      +                    
      +            return self._providers[username]
      +    
      +    def has_users(self) -> bool:
      +        """Check if any users are registered (enables auth enforcement)."""
      +        return bool(self.user_service.list_users())
      +    
      +    def evict(self, username):
      +        """Remove and cleanly shut down cached provider (after delete or password change)."""
      +        with self._lock:
      +            provider = self._providers.pop(username, None)
      +            self._mtimes.pop(username, None)
      +            if provider:
      +                # Explicit cleanup of user-scoped resources if custom close/cleanup exists
      +                if hasattr(provider, "close"):
      +                    try:
      +                        provider.close()
      +                    except Exception:
      +                        pass
      +
      +

      Holds per-user ServiceProviders in memory, thread-safe with hot-reloading.

      +

      Methods

      +
      +
      +def evict(self, username) +
      +
      +
      + +Expand source code + +
      def evict(self, username):
      +    """Remove and cleanly shut down cached provider (after delete or password change)."""
      +    with self._lock:
      +        provider = self._providers.pop(username, None)
      +        self._mtimes.pop(username, None)
      +        if provider:
      +            # Explicit cleanup of user-scoped resources if custom close/cleanup exists
      +            if hasattr(provider, "close"):
      +                try:
      +                    provider.close()
      +                except Exception:
      +                    pass
      +
      +

      Remove and cleanly shut down cached provider (after delete or password change).

      +
      +
      +def get_provider(self, username) ‑> ServiceProvider +
      +
      +
      + +Expand source code + +
      def get_provider(self, username) -> ServiceProvider:
      +    """Get, lazy-load, or hot-reload a user's full ServiceProvider."""
      +    with self._lock:
      +        # Refresh shared/global config if it has changed
      +        self._refresh_shared()
      +        
      +        # 1. Resolve physical path of the user's config.yaml file
      +        user_data = self.user_service.get_user(username)
      +        config_path = user_data.get("config_path")
      +        if config_path:
      +            conf_file = os.path.join(config_path, "config.yaml")
      +        else:
      +            conf_file = os.path.join(self.server_config_dir, "users", username, "config.yaml")
      +        
      +        # 2. Retrieve actual modification time in disk
      +        current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
      +        
      +        # 3. Validate if initial load or hot-reload is required
      +        if username not in self._providers or self._mtimes.get(username, 0.0) < current_mtime:
      +            old_provider = self._providers.get(username)
      +            
      +            try:
      +                # Attempt a fresh configuration load
      +                config = configfile(conf=conf_file, shared_config=self._shared_config)
      +                new_provider = ServiceProvider(config, mode="local")
      +                
      +                # Successfully loaded, clean up the old provider
      +                if old_provider:
      +                    self._providers.pop(username, None)
      +                    if hasattr(old_provider, "close"):
      +                        try:
      +                            old_provider.close()
      +                        except Exception:
      +                            pass
      +                            
      +                self._providers[username] = new_provider
      +                self._mtimes[username] = current_mtime
      +                
      +            except Exception as e:
      +                # Log warning but fallback to the old stable provider in memory if available
      +                from connpy import printer
      +                printer.warning(f"Failed to hot-reload config for user '{username}' (file may be corrupt/incomplete): {e}")
      +                if old_provider:
      +                    # Keep serving with the old cached instance to ensure service continuity
      +                    self._mtimes[username] = current_mtime
      +                else:
      +                    # No fallback exists, propagate the exception
      +                    raise e
      +                
      +        return self._providers[username]
      +
      +

      Get, lazy-load, or hot-reload a user's full ServiceProvider.

      +
      +
      +def has_users(self) ‑> bool +
      +
      +
      + +Expand source code + +
      def has_users(self) -> bool:
      +    """Check if any users are registered (enables auth enforcement)."""
      +    return bool(self.user_service.list_users())
      +
      +

      Check if any users are registered (enables auth enforcement).

      +
      +
      +
      +
      +
      +
      + +
      + + + diff --git a/docs/connpy/grpc_layer/utils.html b/docs/connpy/grpc_layer/utils.html index da5286b..571fbd2 100644 --- a/docs/connpy/grpc_layer/utils.html +++ b/docs/connpy/grpc_layer/utils.html @@ -3,7 +3,7 @@ - + connpy.grpc_layer.utils API documentation @@ -138,7 +138,7 @@ el.replaceWith(d); diff --git a/docs/connpy/index.html b/docs/connpy/index.html index 20af089..91b6f0d 100644 --- a/docs/connpy/index.html +++ b/docs/connpy/index.html @@ -3,7 +3,7 @@ - + connpy API documentation 🤖 AI Copilot (New in v6)

      The AI Copilot is deeply integrated into your terminal workflow: - Terminal Context Awareness: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time. +- Dynamic Context Selection: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators). - Hybrid Multi-Agent System: Automatically escalates complex tasks between the Network Engineer (execution) and the Network Architect (strategy). - MCP Integration: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol. +- Flexible Auth & Keyless AI: Support for advanced LiteLLM credentials (--engineer-auth / --architect-auth) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints. +- Enhanced Session Management: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes. +- Semantic Prompt Integration: Emit standard OSC prompt sequences (]133;B) for real-time remote/web front-end command tracking. - Interactive Chat: Launch with conn ai for a collaborative troubleshooting session.

      Core Features

        @@ -642,8 +646,11 @@ class ai: self.interrupted = False - # 1. Cargar configuración genérica - aiconfig = self.config.config.get("ai", {}) + # 1. Cargar configuración genérica con herencia/merge global + if hasattr(self.config, "get_effective_setting"): + aiconfig = self.config.get_effective_setting("ai", {}) + else: + aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {} # Modelos (Prioridad: Argumento -> Config -> Default) self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite" @@ -1534,9 +1541,11 @@ class ai: @MethodHook def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None): + soft_limit_warned = False is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower() if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless: raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.") + if chat_history is None: chat_history = [] @@ -2144,7 +2153,7 @@ Node: {node_name}"""
        var SAFE_COMMANDS
        -

        The type of the None singleton.

        +

        Instance variables

        @@ -2479,9 +2488,11 @@ Node: {node_name}"""
        @MethodHook
         def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
        +    soft_limit_warned = False
             is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
             if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
                 raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
        +
                 
             if chat_history is None: chat_history = []
             
        @@ -3184,7 +3195,7 @@ def confirm(self, user_input): return True
        class configfile -(conf=None, key=None) +(conf=None, key=None, shared_config=None)
        @@ -3217,7 +3228,8 @@ class configfile: passwords. ''' - def __init__(self, conf = None, key = None): + def __init__(self, conf = None, key = None, shared_config = None): + self._shared_config = shared_config ''' ### Optional Parameters: @@ -3323,6 +3335,42 @@ class configfile: self._generate_nodes_cache() + def get_effective_setting(self, key, default=None): + """Get config setting with shared fallback for inheritable keys.""" + val = self.config.get(key) + if key == "ai": + if val is not None: + if self._shared_config: + import copy + # Deep merge: shared as base, user overrides + base = copy.deepcopy(self._shared_config.config.get(key, {})) + if isinstance(base, dict) and isinstance(val, dict): + # Credential isolation: + # If user defines engineer credentials, discard shared ones + if "engineer_api_key" in val or "engineer_auth" in val: + base.pop("engineer_api_key", None) + base.pop("engineer_auth", None) + # If user defines architect credentials, discard shared ones + if "architect_api_key" in val or "architect_auth" in val: + base.pop("architect_api_key", None) + base.pop("architect_auth", None) + + # Recursive update for inner dictionaries (like mcp_servers or model details) + def deep_merge(d1, d2): + for k, v in d2.items(): + if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict): + deep_merge(d1[k], v) + else: + d1[k] = copy.deepcopy(v) + deep_merge(base, val) + return base + return val + elif self._shared_config: + return self._shared_config.config.get(key, default) + + return val if val is not None else default + + def _validate_config(self, data): """Verify config data has the required structure.""" if not isinstance(data, dict): @@ -3663,7 +3711,8 @@ class configfile: else: printer.error("Filter must be a string or a list of strings") sys.exit(1) - nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)] + flags = re.IGNORECASE if not self.config.get("case", False) else 0 + nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)] return nodes @MethodHook @@ -3786,13 +3835,6 @@ class configfile: - publickey (obj): Object containing the public key to decrypt passwords. - -

        Optional Parameters:

        -
        - conf (str): Path/file to config file. If left empty default
        -              path is ~/.config/conn/config.yaml
        -
        -- key  (str): Path/file to RSA key file. If left empty default
        -              path is ~/.config/conn/.osk
         

        Methods

        @@ -3844,6 +3886,51 @@ def encrypt(self, password, keyfile=None):
        str: Encrypted password.
         
        +
        +def get_effective_setting(self, key, default=None) +
        +
        +
        + +Expand source code + +
        def get_effective_setting(self, key, default=None):
        +    """Get config setting with shared fallback for inheritable keys."""
        +    val = self.config.get(key)
        +    if key == "ai":
        +        if val is not None:
        +            if self._shared_config:
        +                import copy
        +                # Deep merge: shared as base, user overrides
        +                base = copy.deepcopy(self._shared_config.config.get(key, {}))
        +                if isinstance(base, dict) and isinstance(val, dict):
        +                    # Credential isolation:
        +                    # If user defines engineer credentials, discard shared ones
        +                    if "engineer_api_key" in val or "engineer_auth" in val:
        +                        base.pop("engineer_api_key", None)
        +                        base.pop("engineer_auth", None)
        +                    # If user defines architect credentials, discard shared ones
        +                    if "architect_api_key" in val or "architect_auth" in val:
        +                        base.pop("architect_api_key", None)
        +                        base.pop("architect_auth", None)
        +                        
        +                    # Recursive update for inner dictionaries (like mcp_servers or model details)
        +                    def deep_merge(d1, d2):
        +                        for k, v in d2.items():
        +                            if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
        +                                deep_merge(d1[k], v)
        +                            else:
        +                                d1[k] = copy.deepcopy(v)
        +                    deep_merge(base, val)
        +                    return base
        +            return val
        +        elif self._shared_config:
        +            return self._shared_config.config.get(key, default)
        +    
        +    return val if val is not None else default
        +
        +

        Get config setting with shared fallback for inheritable keys.

        +
        def getitem(self, unique, keys=None, extract=False)
        @@ -5000,18 +5087,6 @@ class node: cmd += f" {self.options}" return cmd - @MethodHook - def _generate_ssm_cmd(self): - region = self.tags.get("region", "") if isinstance(self.tags, dict) else "" - profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else "" - cmd = f"aws ssm start-session --target {self.host}" - if region: - cmd += f" --region {region}" - if profile: - cmd += f" --profile {profile}" - if self.options: - cmd += f" {self.options}" - return cmd @MethodHook def _get_cmd(self): @@ -6358,6 +6433,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,

        configfile

        @@ -6384,7 +6460,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None, diff --git a/docs/connpy/mcp_client.html b/docs/connpy/mcp_client.html index 6a50e47..d84cd94 100644 --- a/docs/connpy/mcp_client.html +++ b/docs/connpy/mcp_client.html @@ -3,7 +3,7 @@ - + connpy.mcp_client API documentation @@ -86,7 +86,10 @@ el.replaceWith(d); all_llm_tools = [] try: - mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) + if hasattr(self.config, "get_effective_setting"): + mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {}) + else: + mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {} except Exception: return [] @@ -260,7 +263,10 @@ el.replaceWith(d); all_llm_tools = [] try: - mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) + if hasattr(self.config, "get_effective_setting"): + mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {}) + else: + mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {} except Exception: return [] @@ -343,7 +349,7 @@ el.replaceWith(d); diff --git a/docs/connpy/proto/index.html b/docs/connpy/proto/index.html index 0fc7ddf..573e196 100644 --- a/docs/connpy/proto/index.html +++ b/docs/connpy/proto/index.html @@ -3,7 +3,7 @@ - + connpy.proto API documentation @@ -60,7 +60,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/ai_service.html b/docs/connpy/services/ai_service.html index f308f92..890b06d 100644 --- a/docs/connpy/services/ai_service.html +++ b/docs/connpy/services/ai_service.html @@ -3,7 +3,7 @@ - + connpy.services.ai_service API documentation @@ -359,7 +359,10 @@ el.replaceWith(d); def list_mcp_servers(self) -> dict: """Get the configured MCP servers.""" - ai_settings = self.config.config.get("ai", {}) + if hasattr(self.config, "get_effective_setting"): + ai_settings = self.config.get_effective_setting("ai", {}) + else: + ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {} return ai_settings.get("mcp_servers", {}) def load_session_data(self, session_id): @@ -669,7 +672,10 @@ el.replaceWith(d);
        def list_mcp_servers(self) -> dict:
             """Get the configured MCP servers."""
        -    ai_settings = self.config.config.get("ai", {})
        +    if hasattr(self.config, "get_effective_setting"):
        +        ai_settings = self.config.get_effective_setting("ai", {})
        +    else:
        +        ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
             return ai_settings.get("mcp_servers", {})

        Get the configured MCP servers.

        @@ -826,7 +832,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/base.html b/docs/connpy/services/base.html index e72b7ab..2ff6902 100644 --- a/docs/connpy/services/base.html +++ b/docs/connpy/services/base.html @@ -3,7 +3,7 @@ - + connpy.services.base API documentation @@ -152,7 +152,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/config_service.html b/docs/connpy/services/config_service.html index ad87411..4156c44 100644 --- a/docs/connpy/services/config_service.html +++ b/docs/connpy/services/config_service.html @@ -3,7 +3,7 @@ - + connpy.services.config_service API documentation @@ -319,7 +319,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/context_service.html b/docs/connpy/services/context_service.html index 0a772f7..2161ebb 100644 --- a/docs/connpy/services/context_service.html +++ b/docs/connpy/services/context_service.html @@ -3,7 +3,7 @@ - + connpy.services.context_service API documentation @@ -370,7 +370,7 @@ def current_context(self) -> str: diff --git a/docs/connpy/services/exceptions.html b/docs/connpy/services/exceptions.html index 459d464..164cec5 100644 --- a/docs/connpy/services/exceptions.html +++ b/docs/connpy/services/exceptions.html @@ -3,7 +3,7 @@ - + connpy.services.exceptions API documentation @@ -268,7 +268,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/execution_service.html b/docs/connpy/services/execution_service.html index 26eaa4d..a29e4c6 100644 --- a/docs/connpy/services/execution_service.html +++ b/docs/connpy/services/execution_service.html @@ -3,7 +3,7 @@ - + connpy.services.execution_service API documentation @@ -449,7 +449,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/import_export_service.html b/docs/connpy/services/import_export_service.html index 58e0736..4c98e1b 100644 --- a/docs/connpy/services/import_export_service.html +++ b/docs/connpy/services/import_export_service.html @@ -3,7 +3,7 @@ - + connpy.services.import_export_service API documentation @@ -361,7 +361,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/index.html b/docs/connpy/services/index.html index 46d7ee0..ffbf47b 100644 --- a/docs/connpy/services/index.html +++ b/docs/connpy/services/index.html @@ -3,7 +3,7 @@ - + connpy.services API documentation @@ -92,6 +92,10 @@ el.replaceWith(d);
        +
        connpy.services.user_service
        +
        +
        +
        @@ -414,7 +418,10 @@ el.replaceWith(d); def list_mcp_servers(self) -> dict: """Get the configured MCP servers.""" - ai_settings = self.config.config.get("ai", {}) + if hasattr(self.config, "get_effective_setting"): + ai_settings = self.config.get_effective_setting("ai", {}) + else: + ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {} return ai_settings.get("mcp_servers", {}) def load_session_data(self, session_id): @@ -724,7 +731,10 @@ el.replaceWith(d);
        def list_mcp_servers(self) -> dict:
             """Get the configured MCP servers."""
        -    ai_settings = self.config.config.get("ai", {})
        +    if hasattr(self.config, "get_effective_setting"):
        +        ai_settings = self.config.get_effective_setting("ai", {})
        +    else:
        +        ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
             return ai_settings.get("mcp_servers", {})

        Get the configured MCP servers.

        @@ -2008,7 +2018,7 @@ el.replaceWith(d); self.config._connections_add(**data) 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.""" all_nodes = self.config._getallnodes() if unique_id not in all_nodes: @@ -2022,9 +2032,10 @@ el.replaceWith(d); # config._connections_add actually handles updates if ID exists correctly 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.""" if is_folder: uniques = self.config._explode_unique(unique_id) @@ -2037,7 +2048,8 @@ el.replaceWith(d); raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.") 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): """Interact with a node directly.""" @@ -2267,14 +2279,14 @@ el.replaceWith(d);

        Interact with a node directly.

        -def delete_node(self, unique_id, is_folder=False) +def delete_node(self, unique_id, is_folder=False, save=True)
        Expand source code -
        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."""
             if is_folder:
                 uniques = self.config._explode_unique(unique_id)
        @@ -2287,7 +2299,8 @@ el.replaceWith(d);
                     raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
                 self.config._connections_del(**uniques)
                 
        -    self.config._saveconfig(self.config.file)
        + if save: + self.config._saveconfig(self.config.file)

        Logic for deleting a node or folder.

        @@ -2496,14 +2509,14 @@ el.replaceWith(d);

        Move or copy a node.

        -def update_node(self, unique_id, data) +def update_node(self, unique_id, data, save=True)
        Expand source code -
        def update_node(self, unique_id, data):
        +
        def update_node(self, unique_id, data, save=True):
             """Explicitly update an existing node."""
             all_nodes = self.config._getallnodes()
             if unique_id not in all_nodes:
        @@ -2517,7 +2530,8 @@ el.replaceWith(d);
                 
             # config._connections_add actually handles updates if ID exists correctly
             self.config._connections_add(**data)
        -    self.config._saveconfig(self.config.file)
        + if save: + self.config._saveconfig(self.config.file)

        Explicitly update an existing node.

        @@ -2568,16 +2582,47 @@ el.replaceWith(d);
        class PluginService(BaseService):
             """Business logic for enabling, disabling, and listing plugins."""
         
        +    def _get_plugin_path(self, name, include_disabled=True):
        +        """Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
        +        import os
        +        
        +        # 1. User directory
        +        user_dir = os.path.join(self.config.defaultdir, "plugins")
        +        if os.path.exists(user_dir):
        +            p_file = os.path.join(user_dir, f"{name}.py")
        +            if os.path.exists(p_file):
        +                return p_file, "user", True
        +            if include_disabled:
        +                bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
        +                if os.path.exists(bkp_file):
        +                    return bkp_file, "user", False
        +                    
        +        # 2. Shared/Global directory
        +        if hasattr(self.config, "_shared_config") and self.config._shared_config:
        +            shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
        +            if os.path.exists(shared_dir):
        +                p_file = os.path.join(shared_dir, f"{name}.py")
        +                if os.path.exists(p_file):
        +                    return p_file, "shared", True
        +                if include_disabled:
        +                    bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
        +                    if os.path.exists(bkp_file):
        +                        return bkp_file, "shared", False
        +                        
        +        # 3. Core plugins
        +        core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
        +        p_file = os.path.join(core_dir, f"{name}.py")
        +        if os.path.exists(p_file):
        +            return p_file, "core", True
        +            
        +        return None, None, False
        +
        +
             def list_plugins(self):
                 """List all core and user-defined plugins with their status and hash."""
                 import os
                 import hashlib
                 
        -        # Check for user plugins directory
        -        plugin_dir = os.path.join(self.config.defaultdir, "plugins")
        -        # Check for core plugins directory
        -        core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
        -        
                 all_plugin_info = {}
         
                 def get_hash(path):
        @@ -2587,12 +2632,35 @@ el.replaceWith(d);
                     except Exception:
                         return ""
         
        -        # User plugins
        -        if os.path.exists(plugin_dir):
        -            for f in os.listdir(plugin_dir):
        +        # 1. Scan core plugins (lowest priority)
        +        core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
        +        if os.path.exists(core_dir):
        +            for f in os.listdir(core_dir):
                         if f.endswith(".py"):
                             name = f[:-3]
        -                    path = os.path.join(plugin_dir, f)
        +                    path = os.path.join(core_dir, f)
        +                    all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
        +
        +        # 2. Scan shared plugins (medium priority)
        +        if hasattr(self.config, "_shared_config") and self.config._shared_config:
        +            shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
        +            if os.path.exists(shared_dir):
        +                for f in os.listdir(shared_dir):
        +                    if f.endswith(".py"):
        +                        name = f[:-3]
        +                        path = os.path.join(shared_dir, f)
        +                        all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
        +                    elif f.endswith(".py.bkp"):
        +                        name = f[:-7]
        +                        all_plugin_info[name] = {"enabled": False}
        +
        +        # 3. Scan user plugins (highest priority)
        +        user_dir = os.path.join(self.config.defaultdir, "plugins")
        +        if os.path.exists(user_dir):
        +            for f in os.listdir(user_dir):
        +                if f.endswith(".py"):
        +                    name = f[:-3]
        +                    path = os.path.join(user_dir, f)
                             all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
                         elif f.endswith(".py.bkp"):
                             name = f[:-7]
        @@ -2600,6 +2668,7 @@ el.replaceWith(d);
         
                 return all_plugin_info
         
        +
             def add_plugin(self, name, source_file, update=False):
                 """Add or update a plugin from a local file."""
                 import os
        @@ -2680,6 +2749,10 @@ el.replaceWith(d);
                             raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
                 
                 if not deleted:
        +            # If not deleted from user directory, check if it's in shared or core
        +            path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
        +            if origin in ["shared", "core"]:
        +                raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
                     raise InvalidConfigurationError(f"Plugin '{name}' not found.")
         
             def enable_plugin(self, name):
        @@ -2688,17 +2761,38 @@ el.replaceWith(d);
                 plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
                 disabled_file = f"{plugin_file}.bkp"
                 
        +        if os.path.exists(disabled_file):
        +            # Check if it is a shadow bkp file (0 bytes shadowing shared/core)
        +            is_shadow = False
        +            if os.path.getsize(disabled_file) == 0:
        +                # Resolve without the local bkp file to verify if shared/core has it
        +                path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
        +                if origin in ["shared", "core"]:
        +                    is_shadow = True
        +            
        +            if is_shadow:
        +                # Remove shadow file to restore inheritance
        +                try:
        +                    os.remove(disabled_file)
        +                    return True
        +                except OSError as e:
        +                    raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
        +            else:
        +                try:
        +                    os.rename(disabled_file, plugin_file)
        +                    return True
        +                except OSError as e:
        +                    raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
        +        
                 if os.path.exists(plugin_file):
                     return False # Already enabled
                     
        -        if not os.path.exists(disabled_file):
        -            raise InvalidConfigurationError(f"Plugin '{name}' not found.")
        +        # If it doesn't exist locally, check if it's already an active shared/core plugin
        +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
        +        if origin in ["shared", "core"]:
        +            return False # Already active/enabled through inheritance
                     
        -        try:
        -            os.rename(disabled_file, plugin_file)
        -            return True
        -        except OSError as e:
        -            raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
        +        raise InvalidConfigurationError(f"Plugin '{name}' not found.")
         
             def disable_plugin(self, name):
                 """Deactivate a plugin by renaming it to a backup file."""
        @@ -2706,33 +2800,41 @@ el.replaceWith(d);
                 plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
                 disabled_file = f"{plugin_file}.bkp"
                 
        +        if os.path.exists(plugin_file):
        +            # Regular user-level plugin exists. Rename to bkp
        +            try:
        +                os.rename(plugin_file, disabled_file)
        +                return True
        +            except OSError as e:
        +                raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
        +                
                 if os.path.exists(disabled_file):
                     return False # Already disabled
                     
        -        if not os.path.exists(plugin_file):
        -            raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
        -            
        -        try:
        -            os.rename(plugin_file, disabled_file)
        -            return True
        -        except OSError as e:
        -            raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
        +        # Check if it exists in shared or core
        +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
        +        if origin in ["shared", "core"]:
        +            # Shadow disable it by creating an empty .py.bkp in user plugins dir
        +            plugin_dir = os.path.dirname(plugin_file)
        +            os.makedirs(plugin_dir, exist_ok=True)
        +            try:
        +                with open(disabled_file, "w") as f:
        +                    f.write("")
        +                return True
        +            except OSError as e:
        +                raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
        +                
        +        raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
         
             def get_plugin_source(self, name):
                 import os
                 from ..services.exceptions import InvalidConfigurationError
                 
        -        plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
        -        core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
        -        
        -        if os.path.exists(plugin_file):
        -            target = plugin_file
        -        elif os.path.exists(core_path):
        -            target = core_path
        -        else:
        +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
        +        if not path:
                     raise InvalidConfigurationError(f"Plugin '{name}' not found")
                 
        -        with open(target, "r") as f:
        +        with open(path, "r") as f:
                     return f.read()
         
             def invoke_plugin(self, name, args_dict):
        @@ -2772,17 +2874,12 @@ el.replaceWith(d);
                 
                 p_manager = Plugins()
                 import os
        -        plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
        -        core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
                 
        -        if os.path.exists(plugin_file):
        -            target = plugin_file
        -        elif os.path.exists(core_path):
        -            target = core_path
        -        else:
        +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
        +        if not path:
                     raise InvalidConfigurationError(f"Plugin '{name}' not found")
                     
        -        module = p_manager._import_from_path(target)
        +        module = p_manager._import_from_path(path)
                 parser = module.Parser().parser if hasattr(module, "Parser") else None
                 
                 if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
        @@ -2935,6 +3032,10 @@ el.replaceWith(d);
                         raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
             
             if not deleted:
        +        # If not deleted from user directory, check if it's in shared or core
        +        path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
        +        if origin in ["shared", "core"]:
        +            raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
                 raise InvalidConfigurationError(f"Plugin '{name}' not found.")

        Remove a plugin file permanently.

        @@ -2953,17 +3054,31 @@ el.replaceWith(d); plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") disabled_file = f"{plugin_file}.bkp" + if os.path.exists(plugin_file): + # Regular user-level plugin exists. Rename to bkp + try: + os.rename(plugin_file, disabled_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") + if os.path.exists(disabled_file): return False # Already disabled - if not os.path.exists(plugin_file): - raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.") - - try: - os.rename(plugin_file, disabled_file) - return True - except OSError as e: - raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") + # Check if it exists in shared or core + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + # Shadow disable it by creating an empty .py.bkp in user plugins dir + plugin_dir = os.path.dirname(plugin_file) + os.makedirs(plugin_dir, exist_ok=True) + try: + with open(disabled_file, "w") as f: + f.write("") + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}") + + raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")

        Deactivate a plugin by renaming it to a backup file.

        @@ -2981,17 +3096,38 @@ el.replaceWith(d); plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") disabled_file = f"{plugin_file}.bkp" + if os.path.exists(disabled_file): + # Check if it is a shadow bkp file (0 bytes shadowing shared/core) + is_shadow = False + if os.path.getsize(disabled_file) == 0: + # Resolve without the local bkp file to verify if shared/core has it + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + is_shadow = True + + if is_shadow: + # Remove shadow file to restore inheritance + try: + os.remove(disabled_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}") + else: + try: + os.rename(disabled_file, plugin_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}") + if os.path.exists(plugin_file): return False # Already enabled - if not os.path.exists(disabled_file): - raise InvalidConfigurationError(f"Plugin '{name}' not found.") + # If it doesn't exist locally, check if it's already an active shared/core plugin + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + return False # Already active/enabled through inheritance - try: - os.rename(disabled_file, plugin_file) - return True - except OSError as e: - raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}") + raise InvalidConfigurationError(f"Plugin '{name}' not found.")

        Activate a plugin by renaming its backup file.

        @@ -3007,17 +3143,11 @@ el.replaceWith(d); import os from ..services.exceptions import InvalidConfigurationError - plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") - core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" - - if os.path.exists(plugin_file): - target = plugin_file - elif os.path.exists(core_path): - target = core_path - else: + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if not path: raise InvalidConfigurationError(f"Plugin '{name}' not found") - with open(target, "r") as f: + with open(path, "r") as f: return f.read()
        @@ -3067,17 +3197,12 @@ el.replaceWith(d); p_manager = Plugins() import os - plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") - core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" - if os.path.exists(plugin_file): - target = plugin_file - elif os.path.exists(core_path): - target = core_path - else: + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if not path: raise InvalidConfigurationError(f"Plugin '{name}' not found") - module = p_manager._import_from_path(target) + module = p_manager._import_from_path(path) parser = module.Parser().parser if hasattr(module, "Parser") else None if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]): @@ -3146,11 +3271,6 @@ el.replaceWith(d); import os import hashlib - # Check for user plugins directory - plugin_dir = os.path.join(self.config.defaultdir, "plugins") - # Check for core plugins directory - core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins") - all_plugin_info = {} def get_hash(path): @@ -3160,12 +3280,35 @@ el.replaceWith(d); except Exception: return "" - # User plugins - if os.path.exists(plugin_dir): - for f in os.listdir(plugin_dir): + # 1. Scan core plugins (lowest priority) + core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins") + if os.path.exists(core_dir): + for f in os.listdir(core_dir): if f.endswith(".py"): name = f[:-3] - path = os.path.join(plugin_dir, f) + path = os.path.join(core_dir, f) + all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} + + # 2. Scan shared plugins (medium priority) + if hasattr(self.config, "_shared_config") and self.config._shared_config: + shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins") + if os.path.exists(shared_dir): + for f in os.listdir(shared_dir): + if f.endswith(".py"): + name = f[:-3] + path = os.path.join(shared_dir, f) + all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} + elif f.endswith(".py.bkp"): + name = f[:-7] + all_plugin_info[name] = {"enabled": False} + + # 3. Scan user plugins (highest priority) + user_dir = os.path.join(self.config.defaultdir, "plugins") + if os.path.exists(user_dir): + for f in os.listdir(user_dir): + if f.endswith(".py"): + name = f[:-3] + path = os.path.join(user_dir, f) all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} elif f.endswith(".py.bkp"): name = f[:-7] @@ -3854,6 +3997,7 @@ el.replaceWith(d);
      • connpy.services.provider
      • connpy.services.sync_service
      • connpy.services.system_service
      • +
      • connpy.services.user_service
    • Classes

      @@ -3984,7 +4128,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/node_service.html b/docs/connpy/services/node_service.html index 0a66d09..0e53a2a 100644 --- a/docs/connpy/services/node_service.html +++ b/docs/connpy/services/node_service.html @@ -3,7 +3,7 @@ - + connpy.services.node_service API documentation @@ -198,7 +198,7 @@ el.replaceWith(d); self.config._connections_add(**data) 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.""" all_nodes = self.config._getallnodes() if unique_id not in all_nodes: @@ -212,9 +212,10 @@ el.replaceWith(d); # config._connections_add actually handles updates if ID exists correctly 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.""" if is_folder: uniques = self.config._explode_unique(unique_id) @@ -227,7 +228,8 @@ el.replaceWith(d); raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.") 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): """Interact with a node directly.""" @@ -457,14 +459,14 @@ el.replaceWith(d);

      Interact with a node directly.

      -def delete_node(self, unique_id, is_folder=False) +def delete_node(self, unique_id, is_folder=False, save=True)
      Expand source code -
      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."""
           if is_folder:
               uniques = self.config._explode_unique(unique_id)
      @@ -477,7 +479,8 @@ el.replaceWith(d);
                   raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
               self.config._connections_del(**uniques)
               
      -    self.config._saveconfig(self.config.file)
      + if save: + self.config._saveconfig(self.config.file)

      Logic for deleting a node or folder.

      @@ -686,14 +689,14 @@ el.replaceWith(d);

      Move or copy a node.

      -def update_node(self, unique_id, data) +def update_node(self, unique_id, data, save=True)
      Expand source code -
      def update_node(self, unique_id, data):
      +
      def update_node(self, unique_id, data, save=True):
           """Explicitly update an existing node."""
           all_nodes = self.config._getallnodes()
           if unique_id not in all_nodes:
      @@ -707,7 +710,8 @@ el.replaceWith(d);
               
           # config._connections_add actually handles updates if ID exists correctly
           self.config._connections_add(**data)
      -    self.config._saveconfig(self.config.file)
      + if save: + self.config._saveconfig(self.config.file)

      Explicitly update an existing node.

      @@ -786,7 +790,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/plugin_service.html b/docs/connpy/services/plugin_service.html index e2d6e17..9d6b251 100644 --- a/docs/connpy/services/plugin_service.html +++ b/docs/connpy/services/plugin_service.html @@ -3,7 +3,7 @@ - + connpy.services.plugin_service API documentation @@ -58,16 +58,47 @@ el.replaceWith(d);
      class PluginService(BaseService):
           """Business logic for enabling, disabling, and listing plugins."""
       
      +    def _get_plugin_path(self, name, include_disabled=True):
      +        """Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
      +        import os
      +        
      +        # 1. User directory
      +        user_dir = os.path.join(self.config.defaultdir, "plugins")
      +        if os.path.exists(user_dir):
      +            p_file = os.path.join(user_dir, f"{name}.py")
      +            if os.path.exists(p_file):
      +                return p_file, "user", True
      +            if include_disabled:
      +                bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
      +                if os.path.exists(bkp_file):
      +                    return bkp_file, "user", False
      +                    
      +        # 2. Shared/Global directory
      +        if hasattr(self.config, "_shared_config") and self.config._shared_config:
      +            shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
      +            if os.path.exists(shared_dir):
      +                p_file = os.path.join(shared_dir, f"{name}.py")
      +                if os.path.exists(p_file):
      +                    return p_file, "shared", True
      +                if include_disabled:
      +                    bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
      +                    if os.path.exists(bkp_file):
      +                        return bkp_file, "shared", False
      +                        
      +        # 3. Core plugins
      +        core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
      +        p_file = os.path.join(core_dir, f"{name}.py")
      +        if os.path.exists(p_file):
      +            return p_file, "core", True
      +            
      +        return None, None, False
      +
      +
           def list_plugins(self):
               """List all core and user-defined plugins with their status and hash."""
               import os
               import hashlib
               
      -        # Check for user plugins directory
      -        plugin_dir = os.path.join(self.config.defaultdir, "plugins")
      -        # Check for core plugins directory
      -        core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
      -        
               all_plugin_info = {}
       
               def get_hash(path):
      @@ -77,12 +108,35 @@ el.replaceWith(d);
                   except Exception:
                       return ""
       
      -        # User plugins
      -        if os.path.exists(plugin_dir):
      -            for f in os.listdir(plugin_dir):
      +        # 1. Scan core plugins (lowest priority)
      +        core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
      +        if os.path.exists(core_dir):
      +            for f in os.listdir(core_dir):
                       if f.endswith(".py"):
                           name = f[:-3]
      -                    path = os.path.join(plugin_dir, f)
      +                    path = os.path.join(core_dir, f)
      +                    all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
      +
      +        # 2. Scan shared plugins (medium priority)
      +        if hasattr(self.config, "_shared_config") and self.config._shared_config:
      +            shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
      +            if os.path.exists(shared_dir):
      +                for f in os.listdir(shared_dir):
      +                    if f.endswith(".py"):
      +                        name = f[:-3]
      +                        path = os.path.join(shared_dir, f)
      +                        all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
      +                    elif f.endswith(".py.bkp"):
      +                        name = f[:-7]
      +                        all_plugin_info[name] = {"enabled": False}
      +
      +        # 3. Scan user plugins (highest priority)
      +        user_dir = os.path.join(self.config.defaultdir, "plugins")
      +        if os.path.exists(user_dir):
      +            for f in os.listdir(user_dir):
      +                if f.endswith(".py"):
      +                    name = f[:-3]
      +                    path = os.path.join(user_dir, f)
                           all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
                       elif f.endswith(".py.bkp"):
                           name = f[:-7]
      @@ -90,6 +144,7 @@ el.replaceWith(d);
       
               return all_plugin_info
       
      +
           def add_plugin(self, name, source_file, update=False):
               """Add or update a plugin from a local file."""
               import os
      @@ -170,6 +225,10 @@ el.replaceWith(d);
                           raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
               
               if not deleted:
      +            # If not deleted from user directory, check if it's in shared or core
      +            path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
      +            if origin in ["shared", "core"]:
      +                raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
                   raise InvalidConfigurationError(f"Plugin '{name}' not found.")
       
           def enable_plugin(self, name):
      @@ -178,17 +237,38 @@ el.replaceWith(d);
               plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
               disabled_file = f"{plugin_file}.bkp"
               
      +        if os.path.exists(disabled_file):
      +            # Check if it is a shadow bkp file (0 bytes shadowing shared/core)
      +            is_shadow = False
      +            if os.path.getsize(disabled_file) == 0:
      +                # Resolve without the local bkp file to verify if shared/core has it
      +                path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
      +                if origin in ["shared", "core"]:
      +                    is_shadow = True
      +            
      +            if is_shadow:
      +                # Remove shadow file to restore inheritance
      +                try:
      +                    os.remove(disabled_file)
      +                    return True
      +                except OSError as e:
      +                    raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
      +            else:
      +                try:
      +                    os.rename(disabled_file, plugin_file)
      +                    return True
      +                except OSError as e:
      +                    raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
      +        
               if os.path.exists(plugin_file):
                   return False # Already enabled
                   
      -        if not os.path.exists(disabled_file):
      -            raise InvalidConfigurationError(f"Plugin '{name}' not found.")
      +        # If it doesn't exist locally, check if it's already an active shared/core plugin
      +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
      +        if origin in ["shared", "core"]:
      +            return False # Already active/enabled through inheritance
                   
      -        try:
      -            os.rename(disabled_file, plugin_file)
      -            return True
      -        except OSError as e:
      -            raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
      +        raise InvalidConfigurationError(f"Plugin '{name}' not found.")
       
           def disable_plugin(self, name):
               """Deactivate a plugin by renaming it to a backup file."""
      @@ -196,33 +276,41 @@ el.replaceWith(d);
               plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
               disabled_file = f"{plugin_file}.bkp"
               
      +        if os.path.exists(plugin_file):
      +            # Regular user-level plugin exists. Rename to bkp
      +            try:
      +                os.rename(plugin_file, disabled_file)
      +                return True
      +            except OSError as e:
      +                raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
      +                
               if os.path.exists(disabled_file):
                   return False # Already disabled
                   
      -        if not os.path.exists(plugin_file):
      -            raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
      -            
      -        try:
      -            os.rename(plugin_file, disabled_file)
      -            return True
      -        except OSError as e:
      -            raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
      +        # Check if it exists in shared or core
      +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
      +        if origin in ["shared", "core"]:
      +            # Shadow disable it by creating an empty .py.bkp in user plugins dir
      +            plugin_dir = os.path.dirname(plugin_file)
      +            os.makedirs(plugin_dir, exist_ok=True)
      +            try:
      +                with open(disabled_file, "w") as f:
      +                    f.write("")
      +                return True
      +            except OSError as e:
      +                raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
      +                
      +        raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
       
           def get_plugin_source(self, name):
               import os
               from ..services.exceptions import InvalidConfigurationError
               
      -        plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
      -        core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
      -        
      -        if os.path.exists(plugin_file):
      -            target = plugin_file
      -        elif os.path.exists(core_path):
      -            target = core_path
      -        else:
      +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
      +        if not path:
                   raise InvalidConfigurationError(f"Plugin '{name}' not found")
               
      -        with open(target, "r") as f:
      +        with open(path, "r") as f:
                   return f.read()
       
           def invoke_plugin(self, name, args_dict):
      @@ -262,17 +350,12 @@ el.replaceWith(d);
               
               p_manager = Plugins()
               import os
      -        plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
      -        core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
               
      -        if os.path.exists(plugin_file):
      -            target = plugin_file
      -        elif os.path.exists(core_path):
      -            target = core_path
      -        else:
      +        path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
      +        if not path:
                   raise InvalidConfigurationError(f"Plugin '{name}' not found")
                   
      -        module = p_manager._import_from_path(target)
      +        module = p_manager._import_from_path(path)
               parser = module.Parser().parser if hasattr(module, "Parser") else None
               
               if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
      @@ -425,6 +508,10 @@ el.replaceWith(d);
                       raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
           
           if not deleted:
      +        # If not deleted from user directory, check if it's in shared or core
      +        path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
      +        if origin in ["shared", "core"]:
      +            raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
               raise InvalidConfigurationError(f"Plugin '{name}' not found.")

      Remove a plugin file permanently.

      @@ -443,17 +530,31 @@ el.replaceWith(d); plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") disabled_file = f"{plugin_file}.bkp" + if os.path.exists(plugin_file): + # Regular user-level plugin exists. Rename to bkp + try: + os.rename(plugin_file, disabled_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") + if os.path.exists(disabled_file): return False # Already disabled - if not os.path.exists(plugin_file): - raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.") - - try: - os.rename(plugin_file, disabled_file) - return True - except OSError as e: - raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") + # Check if it exists in shared or core + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + # Shadow disable it by creating an empty .py.bkp in user plugins dir + plugin_dir = os.path.dirname(plugin_file) + os.makedirs(plugin_dir, exist_ok=True) + try: + with open(disabled_file, "w") as f: + f.write("") + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}") + + raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")

      Deactivate a plugin by renaming it to a backup file.

      @@ -471,17 +572,38 @@ el.replaceWith(d); plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") disabled_file = f"{plugin_file}.bkp" + if os.path.exists(disabled_file): + # Check if it is a shadow bkp file (0 bytes shadowing shared/core) + is_shadow = False + if os.path.getsize(disabled_file) == 0: + # Resolve without the local bkp file to verify if shared/core has it + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + is_shadow = True + + if is_shadow: + # Remove shadow file to restore inheritance + try: + os.remove(disabled_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}") + else: + try: + os.rename(disabled_file, plugin_file) + return True + except OSError as e: + raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}") + if os.path.exists(plugin_file): return False # Already enabled - if not os.path.exists(disabled_file): - raise InvalidConfigurationError(f"Plugin '{name}' not found.") + # If it doesn't exist locally, check if it's already an active shared/core plugin + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if origin in ["shared", "core"]: + return False # Already active/enabled through inheritance - try: - os.rename(disabled_file, plugin_file) - return True - except OSError as e: - raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}") + raise InvalidConfigurationError(f"Plugin '{name}' not found.")

      Activate a plugin by renaming its backup file.

      @@ -497,17 +619,11 @@ el.replaceWith(d); import os from ..services.exceptions import InvalidConfigurationError - plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") - core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" - - if os.path.exists(plugin_file): - target = plugin_file - elif os.path.exists(core_path): - target = core_path - else: + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if not path: raise InvalidConfigurationError(f"Plugin '{name}' not found") - with open(target, "r") as f: + with open(path, "r") as f: return f.read()
      @@ -557,17 +673,12 @@ el.replaceWith(d); p_manager = Plugins() import os - plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") - core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" - if os.path.exists(plugin_file): - target = plugin_file - elif os.path.exists(core_path): - target = core_path - else: + path, origin, enabled = self._get_plugin_path(name, include_disabled=False) + if not path: raise InvalidConfigurationError(f"Plugin '{name}' not found") - module = p_manager._import_from_path(target) + module = p_manager._import_from_path(path) parser = module.Parser().parser if hasattr(module, "Parser") else None if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]): @@ -636,11 +747,6 @@ el.replaceWith(d); import os import hashlib - # Check for user plugins directory - plugin_dir = os.path.join(self.config.defaultdir, "plugins") - # Check for core plugins directory - core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins") - all_plugin_info = {} def get_hash(path): @@ -650,12 +756,35 @@ el.replaceWith(d); except Exception: return "" - # User plugins - if os.path.exists(plugin_dir): - for f in os.listdir(plugin_dir): + # 1. Scan core plugins (lowest priority) + core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins") + if os.path.exists(core_dir): + for f in os.listdir(core_dir): if f.endswith(".py"): name = f[:-3] - path = os.path.join(plugin_dir, f) + path = os.path.join(core_dir, f) + all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} + + # 2. Scan shared plugins (medium priority) + if hasattr(self.config, "_shared_config") and self.config._shared_config: + shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins") + if os.path.exists(shared_dir): + for f in os.listdir(shared_dir): + if f.endswith(".py"): + name = f[:-3] + path = os.path.join(shared_dir, f) + all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} + elif f.endswith(".py.bkp"): + name = f[:-7] + all_plugin_info[name] = {"enabled": False} + + # 3. Scan user plugins (highest priority) + user_dir = os.path.join(self.config.defaultdir, "plugins") + if os.path.exists(user_dir): + for f in os.listdir(user_dir): + if f.endswith(".py"): + name = f[:-3] + path = os.path.join(user_dir, f) all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} elif f.endswith(".py.bkp"): name = f[:-7] @@ -709,7 +838,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/profile_service.html b/docs/connpy/services/profile_service.html index 568aec0..e3f746c 100644 --- a/docs/connpy/services/profile_service.html +++ b/docs/connpy/services/profile_service.html @@ -3,7 +3,7 @@ - + connpy.services.profile_service API documentation @@ -429,7 +429,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/provider.html b/docs/connpy/services/provider.html index fa72924..f51bb13 100644 --- a/docs/connpy/services/provider.html +++ b/docs/connpy/services/provider.html @@ -3,7 +3,7 @@ - + connpy.services.provider API documentation @@ -98,6 +98,7 @@ el.replaceWith(d); from .import_export_service import ImportExportService from .context_service import ContextService from .sync_service import SyncService + from .user_service import UserService self.nodes = NodeService(self.config) self.profiles = ProfileService(self.config) @@ -109,6 +110,7 @@ el.replaceWith(d); self.import_export = ImportExportService(self.config) self.context = ContextService(self.config) self.sync = SyncService(self.config) + self.users = UserService(self.config.defaultdir) def _init_remote(self): # Allow ConfigService to work locally so the user can revert the mode @@ -118,22 +120,46 @@ el.replaceWith(d); self.config_svc = ConfigService(self.config) self.context = ContextService(self.config) self.sync = SyncService(self.config) + self.users = None if not self.remote_host: raise InvalidConfigurationError("Remote host must be specified in remote mode") import grpc - from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub + import os + from ..grpc_layer.stubs import ( + NodeStub, ProfileStub, PluginStub, AIStub, + ExecutionStub, ImportExportStub, SystemStub, + ConfigStub, AuthClientInterceptor, AuthStub + ) + def get_token(): + token_path = os.path.join(self.config.defaultdir, ".token") + if os.path.exists(token_path): + try: + with open(token_path, "r") as f: + return f.read().strip() + except Exception: + pass + return None + channel = grpc.insecure_channel(self.remote_host) + interceptor = AuthClientInterceptor(get_token) + channel = grpc.intercept_channel(channel, interceptor) + # Surgical fix: Keep ConfigService local for mode/theme management, + # but delegate encryption to the server stub. + config_remote = ConfigStub(channel, remote_host=self.remote_host) + self.config_svc.encrypt_password = config_remote.encrypt_password + self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config) self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes) self.plugins = PluginStub(channel, remote_host=self.remote_host) self.ai = AIStub(channel, remote_host=self.remote_host) self.system = SystemStub(channel, remote_host=self.remote_host) self.execution = ExecutionStub(channel, remote_host=self.remote_host) - self.import_export = ImportExportStub(channel, remote_host=self.remote_host) + self.import_export = ImportExportStub(channel, remote_host=self.remote_host) + self.auth = AuthStub(channel, remote_host=self.remote_host)

      Dynamic service backend. Transparently provides local or remote services.

      @@ -164,7 +190,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/sync_service.html b/docs/connpy/services/sync_service.html index 602c65a..aac676f 100644 --- a/docs/connpy/services/sync_service.html +++ b/docs/connpy/services/sync_service.html @@ -3,7 +3,7 @@ - + connpy.services.sync_service API documentation @@ -964,7 +964,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/system_service.html b/docs/connpy/services/system_service.html index ded62e1..95f059e 100644 --- a/docs/connpy/services/system_service.html +++ b/docs/connpy/services/system_service.html @@ -3,7 +3,7 @@ - + connpy.services.system_service API documentation @@ -325,7 +325,7 @@ el.replaceWith(d); diff --git a/docs/connpy/services/user_service.html b/docs/connpy/services/user_service.html new file mode 100644 index 0000000..2d6ffd5 --- /dev/null +++ b/docs/connpy/services/user_service.html @@ -0,0 +1,595 @@ + + + + + + +connpy.services.user_service API documentation + + + + + + + + + + + +
      +
      +
      +

      Module connpy.services.user_service

      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +

      Classes

      +
      +
      +class UserService +(config_dir) +
      +
      +
      + +Expand source code + +
      class UserService:
      +    def __init__(self, config_dir):
      +        self.config_dir = os.path.abspath(config_dir)
      +        self.users_dir = os.path.join(self.config_dir, "users")
      +        self.registry_file = os.path.join(self.users_dir, "registry.yaml")
      +        
      +        # Ensure users directory exists
      +        os.makedirs(self.users_dir, exist_ok=True)
      +
      +    def _load_registry(self) -> dict:
      +        """Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
      +        if not os.path.exists(self.registry_file):
      +            registry = {
      +                "jwt_secret": secrets.token_hex(32),
      +                "users": {}
      +            }
      +            self._save_registry(registry)
      +            return registry
      +        
      +        try:
      +            with open(self.registry_file, "r") as f:
      +                registry = yaml.safe_load(f) or {}
      +        except Exception:
      +            registry = {}
      +            
      +        if not isinstance(registry, dict):
      +            registry = {}
      +            
      +        if "jwt_secret" not in registry:
      +            registry["jwt_secret"] = secrets.token_hex(32)
      +            
      +        if "users" not in registry or not isinstance(registry["users"], dict):
      +            registry["users"] = {}
      +            
      +        return registry
      +
      +    def _save_registry(self, data: dict):
      +        """Safely saves registry structure to registry.yaml."""
      +        tmp_file = self.registry_file + ".tmp"
      +        try:
      +            with open(tmp_file, "w") as f:
      +                yaml.dump(data, f, default_flow_style=False, sort_keys=False)
      +            os.replace(tmp_file, self.registry_file)
      +            os.chmod(self.registry_file, 0o600)
      +        except Exception as e:
      +            if os.path.exists(tmp_file):
      +                try:
      +                    os.remove(tmp_file)
      +                except OSError:
      +                    pass
      +            raise e
      +
      +    def create_user(self, username, password, config_path=None) -> dict:
      +        """Creates a new user with bcrypt-hashed credentials.
      +        
      +        Mode A: config_path=None (fresh user) -> Generates config.yaml and .osk key.
      +        Mode B: config_path set -> Reuses existing directory after validating its structure.
      +        """
      +        if not username or not isinstance(username, str):
      +            raise ValueError("Username cannot be empty")
      +            
      +        if not re.match(r"^[a-zA-Z0-9_-]+$", username):
      +            raise ValueError("Username must contain only alphanumeric characters, dashes, or underscores")
      +            
      +        if not password or not isinstance(password, str):
      +            raise ValueError("Password cannot be empty")
      +            
      +        registry = self._load_registry()
      +        if username in registry["users"]:
      +            raise ValueError(f"User '{username}' already exists")
      +            
      +        # Resolve path and initialize configuration
      +        if config_path is None:
      +            user_dir = os.path.join(self.users_dir, username)
      +            os.makedirs(user_dir, exist_ok=True)
      +            
      +            # Create subdirs for plugins and sessions
      +            os.makedirs(os.path.join(user_dir, "plugins"), exist_ok=True)
      +            os.makedirs(os.path.join(user_dir, "ai_sessions"), exist_ok=True)
      +            
      +            # Create default config.yaml & .osk key via configfile
      +            conf_file = os.path.join(user_dir, "config.yaml")
      +            configfile(conf=conf_file)
      +            
      +            stored_config_path = None
      +        else:
      +            abs_config_path = os.path.abspath(config_path)
      +            os.makedirs(abs_config_path, exist_ok=True)
      +            
      +            # Create subdirs for plugins and sessions in the custom path
      +            os.makedirs(os.path.join(abs_config_path, "plugins"), exist_ok=True)
      +            os.makedirs(os.path.join(abs_config_path, "ai_sessions"), exist_ok=True)
      +            
      +            # Create default config.yaml & .osk key via configfile if config.yaml is not present
      +            conf_file = os.path.join(abs_config_path, "config.yaml")
      +            if not os.path.exists(conf_file):
      +                configfile(conf=conf_file)
      +                
      +            stored_config_path = abs_config_path
      +
      +        # Hash password securely
      +        password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +        
      +        user_entry = {
      +            "password_hash": password_hash,
      +            "config_path": stored_config_path,
      +            "created": datetime.datetime.now(datetime.timezone.utc).isoformat()
      +        }
      +        
      +        registry["users"][username] = user_entry
      +        self._save_registry(registry)
      +        
      +        return {
      +            "username": username,
      +            "config_path": stored_config_path,
      +            "created": user_entry["created"]
      +        }
      +
      +    def delete_user(self, username):
      +        """Removes user from the registry and cleans up config directory if server-managed."""
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            raise ValueError(f"User '{username}' not found")
      +            
      +        user_data = registry["users"][username]
      +        config_path = user_data.get("config_path")
      +        
      +        if config_path is None:
      +            user_dir = os.path.join(self.users_dir, username)
      +            if os.path.exists(user_dir):
      +                shutil.rmtree(user_dir, ignore_errors=True)
      +                
      +        del registry["users"][username]
      +        self._save_registry(registry)
      +
      +    def list_users(self) -> list[dict]:
      +        """Lists all registered users with metadata."""
      +        registry = self._load_registry()
      +        return [
      +            {
      +                "username": name,
      +                "config_path": data.get("config_path"),
      +                "created": data.get("created")
      +            }
      +            for name, data in registry.get("users", {}).items()
      +        ]
      +
      +    def get_user(self, username) -> dict:
      +        """Retrieves raw metadata for a specific user."""
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            raise ValueError(f"User '{username}' not found")
      +            
      +        data = registry["users"][username]
      +        return {
      +            "username": username,
      +            "config_path": data.get("config_path"),
      +            "created": data.get("created"),
      +            "password_hash": data.get("password_hash")
      +        }
      +
      +    def change_password(self, username, old_password, new_password):
      +        """Verifies old password and updates registry with new hashed password."""
      +        if not new_password or not isinstance(new_password, str):
      +            raise ValueError("New password cannot be empty")
      +            
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            raise ValueError(f"User '{username}' not found")
      +            
      +        user_data = registry["users"][username]
      +        if not bcrypt.checkpw(old_password.encode("utf-8"), user_data["password_hash"].encode("utf-8")):
      +            raise ValueError("Invalid credentials")
      +            
      +        # Update hash
      +        user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +        self._save_registry(registry)
      +
      +    def admin_change_password(self, username, new_password):
      +        """Administrative password override (does not require old password)."""
      +        if not new_password or not isinstance(new_password, str):
      +            raise ValueError("New password cannot be empty")
      +            
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            raise ValueError(f"User '{username}' not found")
      +            
      +        user_data = registry["users"][username]
      +        user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +        self._save_registry(registry)
      +
      +    def authenticate(self, username, password) -> bool:
      +        """Verifies if the credentials are valid using bcrypt."""
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            return False
      +            
      +        user_data = registry["users"][username]
      +        return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
      +
      +    def generate_jwt(self, username) -> str:
      +        """Generates a secure JSON Web Token for the user expiring in 8 hours."""
      +        registry = self._load_registry()
      +        if username not in registry["users"]:
      +            raise ValueError(f"User '{username}' not found")
      +            
      +        expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
      +        payload = {
      +            "sub": username,
      +            "exp": expiration
      +        }
      +        
      +        token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
      +        if isinstance(token, bytes):
      +            token = token.decode("utf-8")
      +            
      +        return token
      +
      +    def verify_jwt(self, token) -> str | None:
      +        """Decodes JWT and returns username if token is valid and unexpired."""
      +        registry = self._load_registry()
      +        try:
      +            payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
      +            return payload.get("sub")
      +        except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
      +            return None
      +
      +
      +

      Methods

      +
      +
      +def admin_change_password(self, username, new_password) +
      +
      +
      + +Expand source code + +
      def admin_change_password(self, username, new_password):
      +    """Administrative password override (does not require old password)."""
      +    if not new_password or not isinstance(new_password, str):
      +        raise ValueError("New password cannot be empty")
      +        
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        raise ValueError(f"User '{username}' not found")
      +        
      +    user_data = registry["users"][username]
      +    user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +    self._save_registry(registry)
      +
      +

      Administrative password override (does not require old password).

      +
      +
      +def authenticate(self, username, password) ‑> bool +
      +
      +
      + +Expand source code + +
      def authenticate(self, username, password) -> bool:
      +    """Verifies if the credentials are valid using bcrypt."""
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        return False
      +        
      +    user_data = registry["users"][username]
      +    return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
      +
      +

      Verifies if the credentials are valid using bcrypt.

      +
      +
      +def change_password(self, username, old_password, new_password) +
      +
      +
      + +Expand source code + +
      def change_password(self, username, old_password, new_password):
      +    """Verifies old password and updates registry with new hashed password."""
      +    if not new_password or not isinstance(new_password, str):
      +        raise ValueError("New password cannot be empty")
      +        
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        raise ValueError(f"User '{username}' not found")
      +        
      +    user_data = registry["users"][username]
      +    if not bcrypt.checkpw(old_password.encode("utf-8"), user_data["password_hash"].encode("utf-8")):
      +        raise ValueError("Invalid credentials")
      +        
      +    # Update hash
      +    user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +    self._save_registry(registry)
      +
      +

      Verifies old password and updates registry with new hashed password.

      +
      +
      +def create_user(self, username, password, config_path=None) ‑> dict +
      +
      +
      + +Expand source code + +
      def create_user(self, username, password, config_path=None) -> dict:
      +    """Creates a new user with bcrypt-hashed credentials.
      +    
      +    Mode A: config_path=None (fresh user) -> Generates config.yaml and .osk key.
      +    Mode B: config_path set -> Reuses existing directory after validating its structure.
      +    """
      +    if not username or not isinstance(username, str):
      +        raise ValueError("Username cannot be empty")
      +        
      +    if not re.match(r"^[a-zA-Z0-9_-]+$", username):
      +        raise ValueError("Username must contain only alphanumeric characters, dashes, or underscores")
      +        
      +    if not password or not isinstance(password, str):
      +        raise ValueError("Password cannot be empty")
      +        
      +    registry = self._load_registry()
      +    if username in registry["users"]:
      +        raise ValueError(f"User '{username}' already exists")
      +        
      +    # Resolve path and initialize configuration
      +    if config_path is None:
      +        user_dir = os.path.join(self.users_dir, username)
      +        os.makedirs(user_dir, exist_ok=True)
      +        
      +        # Create subdirs for plugins and sessions
      +        os.makedirs(os.path.join(user_dir, "plugins"), exist_ok=True)
      +        os.makedirs(os.path.join(user_dir, "ai_sessions"), exist_ok=True)
      +        
      +        # Create default config.yaml & .osk key via configfile
      +        conf_file = os.path.join(user_dir, "config.yaml")
      +        configfile(conf=conf_file)
      +        
      +        stored_config_path = None
      +    else:
      +        abs_config_path = os.path.abspath(config_path)
      +        os.makedirs(abs_config_path, exist_ok=True)
      +        
      +        # Create subdirs for plugins and sessions in the custom path
      +        os.makedirs(os.path.join(abs_config_path, "plugins"), exist_ok=True)
      +        os.makedirs(os.path.join(abs_config_path, "ai_sessions"), exist_ok=True)
      +        
      +        # Create default config.yaml & .osk key via configfile if config.yaml is not present
      +        conf_file = os.path.join(abs_config_path, "config.yaml")
      +        if not os.path.exists(conf_file):
      +            configfile(conf=conf_file)
      +            
      +        stored_config_path = abs_config_path
      +
      +    # Hash password securely
      +    password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
      +    
      +    user_entry = {
      +        "password_hash": password_hash,
      +        "config_path": stored_config_path,
      +        "created": datetime.datetime.now(datetime.timezone.utc).isoformat()
      +    }
      +    
      +    registry["users"][username] = user_entry
      +    self._save_registry(registry)
      +    
      +    return {
      +        "username": username,
      +        "config_path": stored_config_path,
      +        "created": user_entry["created"]
      +    }
      +
      +

      Creates a new user with bcrypt-hashed credentials.

      +

      Mode A: config_path=None (fresh user) -> Generates config.yaml and .osk key. +Mode B: config_path set -> Reuses existing directory after validating its structure.

      +
      +
      +def delete_user(self, username) +
      +
      +
      + +Expand source code + +
      def delete_user(self, username):
      +    """Removes user from the registry and cleans up config directory if server-managed."""
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        raise ValueError(f"User '{username}' not found")
      +        
      +    user_data = registry["users"][username]
      +    config_path = user_data.get("config_path")
      +    
      +    if config_path is None:
      +        user_dir = os.path.join(self.users_dir, username)
      +        if os.path.exists(user_dir):
      +            shutil.rmtree(user_dir, ignore_errors=True)
      +            
      +    del registry["users"][username]
      +    self._save_registry(registry)
      +
      +

      Removes user from the registry and cleans up config directory if server-managed.

      +
      +
      +def generate_jwt(self, username) ‑> str +
      +
      +
      + +Expand source code + +
      def generate_jwt(self, username) -> str:
      +    """Generates a secure JSON Web Token for the user expiring in 8 hours."""
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        raise ValueError(f"User '{username}' not found")
      +        
      +    expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
      +    payload = {
      +        "sub": username,
      +        "exp": expiration
      +    }
      +    
      +    token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
      +    if isinstance(token, bytes):
      +        token = token.decode("utf-8")
      +        
      +    return token
      +
      +

      Generates a secure JSON Web Token for the user expiring in 8 hours.

      +
      +
      +def get_user(self, username) ‑> dict +
      +
      +
      + +Expand source code + +
      def get_user(self, username) -> dict:
      +    """Retrieves raw metadata for a specific user."""
      +    registry = self._load_registry()
      +    if username not in registry["users"]:
      +        raise ValueError(f"User '{username}' not found")
      +        
      +    data = registry["users"][username]
      +    return {
      +        "username": username,
      +        "config_path": data.get("config_path"),
      +        "created": data.get("created"),
      +        "password_hash": data.get("password_hash")
      +    }
      +
      +

      Retrieves raw metadata for a specific user.

      +
      +
      +def list_users(self) ‑> list[dict] +
      +
      +
      + +Expand source code + +
      def list_users(self) -> list[dict]:
      +    """Lists all registered users with metadata."""
      +    registry = self._load_registry()
      +    return [
      +        {
      +            "username": name,
      +            "config_path": data.get("config_path"),
      +            "created": data.get("created")
      +        }
      +        for name, data in registry.get("users", {}).items()
      +    ]
      +
      +

      Lists all registered users with metadata.

      +
      +
      +def verify_jwt(self, token) ‑> str | None +
      +
      +
      + +Expand source code + +
      def verify_jwt(self, token) -> str | None:
      +    """Decodes JWT and returns username if token is valid and unexpired."""
      +    registry = self._load_registry()
      +    try:
      +        payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
      +        return payload.get("sub")
      +    except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
      +        return None
      +
      +

      Decodes JWT and returns username if token is valid and unexpired.

      +
      +
      +
      +
      +
      +
      + +
      + + + diff --git a/docs/connpy/tunnels.html b/docs/connpy/tunnels.html index 48fdef9..8c1df81 100644 --- a/docs/connpy/tunnels.html +++ b/docs/connpy/tunnels.html @@ -3,7 +3,7 @@ - + connpy.tunnels API documentation @@ -545,7 +545,7 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.

      diff --git a/docs/connpy/utils.html b/docs/connpy/utils.html index 4584ba1..ac7a610 100644 --- a/docs/connpy/utils.html +++ b/docs/connpy/utils.html @@ -3,7 +3,7 @@ - + connpy.utils API documentation @@ -147,7 +147,7 @@ el.replaceWith(d);