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

    +

    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