Compare commits

..
5 Commits
Author SHA1 Message Date
fluzzi32 0632a510ad feat(cli,core): add local interactive shell (conn shell), Ctrl+Space passthrough and bump to v6.1.0
- Add `conn shell` CLI subcommand for spawning local interactive shells with AI Copilot support.

- Implement `protocol="local"` on core `node` class with PTY process spawning, dynamic PWD tracking (/proc/PID/cwd + OSC 7), and AI metadata mapping.

- Implement `_is_child_connpy_active()` to inspect PTY slave foreground process group (tcgetpgrp + /proc/PGID/cmdline).

- Automatically pass `Ctrl+Space` (b'\x00') down to child foreground `conn` / `connpy` processes when running nested sessions in local shell.

- Add `--shell-command`, `--shell-prompt`, and `--shell-os` configuration options to `conn config`.

- Update `README.md` and `connpy/__init__.py` documentation to include `conn shell` and PAT API Token management.

- Add comprehensive unit tests in `test_local_shell.py` and `test_completion.py`.

- Bump version to v6.1.0 and regenerate full HTML documentation in `docs/` via pdoc.
2026-08-12 12:03:59 -03:00
fluzzi32 835283eba6 perf(ai): decouple AI suite from startup and enable lazy loading across services and CLI
- Decouple connapp.ai from get_parser() via DeferredAIProxy to queue plugin modifications without importing connpy.ai or Pydantic v2 upfront.
- Guard connapp.start() finally block to invoke ai.cleanup() only if connpy.ai was actively imported.
- Defer CopilotInterface and AIService imports in core.py to execute strictly upon receiving Copilot hotkey (\x00).
- Convert heavy services (ai, sync, users, import_export, system, execution) into lazy properties on ServiceProvider and services/__init__.py.
- Implement O(1) globals() dict caching in module __getattr__ (PEP 562) across connpy/__init__.py and services/__init__.py.
- Defer Google Drive SDK in sync_service.py and interactive CLI components (inquirer) across handlers.
- Reduced CLI startup & parser initialization latency from ~1.47s to ~183ms (~87% improvement).
2026-08-11 19:31:55 -03:00
fluzzi32 bf5bddda5a perf(core): implement lazy loading for heavy services, Google SDK and CLI forms
- Defer instantiation of heavy services (SyncService, UserService,
  ImportExportService, SystemService, ExecutionService) using cached
  properties in ServiceProvider and module __getattr__ in services.
- Defer Google Drive SDK imports in sync_service.py using module-level
  __getattr__ and _get_google_libs() helper while retaining mock patchability.
- Lazy load inquirer, Forms, Validators and Blessed theme adapters in
  connpy/cli/* to prevent loading CLI form dependencies at startup.
- Clean up unused top-level service imports in connapp.py.
2026-08-11 17:06:40 -03:00
fluzzi32 fe189885d4 feat(auth,ai): add Personal Access Tokens support and persist
AI copilot context state

    - Implement Personal Access Token (PAT) management in UserService with SHA-256
  hash storage and O(1) lookup.
    - Add gRPC endpoints (CreateApiToken, ListApiTokens, RevokeApiToken) and CLI
  commands.
    - Allow authentication using CONNPY_TOKEN environment variable in
  ServiceProvider.
    - Persist AI Copilot context mode and accumulation ranges across prompt
  sessions in terminal_ui.
    - Propagate node_info_json metadata over Copilot gRPC tunnel stream.
    - Add unit tests for PAT lifecycle and Copilot context state persistence (430
  passing tests).
2026-08-11 11:40:12 -03:00
fluzzi32 e94bb3a341 feat(cli): add multiline input support for AI prompts, update docs and bump to v6.0.5 2026-07-27 15:47:51 -03:00
61 changed files with 9007 additions and 3230 deletions
+24 -4
View File
@@ -3,7 +3,7 @@
</p> </p>
# Connpy (v6.0.3) # Connpy (v6.1.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
@@ -44,6 +44,21 @@ Connect to external data sources and tools dynamically via the Model Context Pro
conn ai --mcp conn ai --mcp
``` ```
### 1d. Local Interactive Shell (conn shell)
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
```bash
conn shell # Start local shell (default: $SHELL or /bin/bash)
conn shell -c /bin/zsh # Override shell executable
conn shell --capture session.log # Log session output to file
```
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
```bash
conn config --shell-command /bin/zsh
conn config --shell-prompt "\$\s*$"
conn config --shell-os ubuntu
```
--- ---
@@ -179,14 +194,19 @@ conn config --service-mode remote
conn config --remote localhost:50051 conn config --remote localhost:50051
``` ```
### 8c. User Management ### 8c. User Management & API Tokens
Manage server-side user credentials for distributed setups: Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
```bash ```bash
conn user --add username conn user --add username
conn user --list conn user --list
conn user --regen-password username conn user --regen-password username
# Personal Access Tokens (PAT) for non-interactive API access
conn user --create-token "CI/CD Token" --expires-in 30
conn user --list-tokens
conn user --revoke-token <token_id>
``` ```
Use `--path` to specify custom configuration folders in server Mode B. Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
### 8d. SSO / OIDC ### 8d. SSO / OIDC
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard: Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
+32 -6
View File
@@ -5,7 +5,7 @@
</p> </p>
# Connpy (v6.0.3) # Connpy (v6.1.0)
[![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/v/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/pyversions/connpy.svg?style=flat-square)](https://pypi.org/pypi/connpy/)
[![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/) [![](https://img.shields.io/pypi/dm/connpy.svg?style=flat-square&cacheSeconds=86400)](https://pypi.org/pypi/connpy/)
@@ -46,6 +46,21 @@ Connect to external data sources and tools dynamically via the Model Context Pro
conn ai --mcp conn ai --mcp
``` ```
### 1d. Local Interactive Shell (conn shell)
Launch a local interactive shell with AI Copilot support enabled directly on your host machine:
```bash
conn shell # Start local shell (default: $SHELL or /bin/bash)
conn shell -c /bin/zsh # Override shell executable
conn shell --capture session.log # Log session output to file
```
* **Nested Sessions & Passthrough**: Supports running nested `conn` / `connpy` connections inside `conn shell`. Automatically detects foreground `conn` processes and forwards `Ctrl+Space` down to the active device connection instead of triggering the local Copilot.
* **Shell Configuration**: Configure default shell command, prompt regex, or OS type via `conn config`:
```bash
conn config --shell-command /bin/zsh
conn config --shell-prompt "\$\s*$"
conn config --shell-os ubuntu
```
--- ---
@@ -181,14 +196,19 @@ conn config --service-mode remote
conn config --remote localhost:50051 conn config --remote localhost:50051
``` ```
### 8c. User Management ### 8c. User Management & API Tokens
Manage server-side user credentials for distributed setups: Manage server-side user credentials and Personal Access Tokens (PAT) for automated setups:
```bash ```bash
conn user --add username conn user --add username
conn user --list conn user --list
conn user --regen-password username conn user --regen-password username
# Personal Access Tokens (PAT) for non-interactive API access
conn user --create-token "CI/CD Token" --expires-in 30
conn user --list-tokens
conn user --revoke-token <token_id>
``` ```
Use `--path` to specify custom configuration folders in server Mode B. Use `--path` to specify custom configuration folders in server Mode B. Pass API tokens via `CONNPY_TOKEN` environment variable.
### 8d. SSO / OIDC ### 8d. SSO / OIDC
Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard: Configure identity providers (e.g. Authelia, Keycloak) for SSO gRPC authentication using the interactive wizard:
@@ -282,12 +302,18 @@ from .core import node,nodes
from .configfile import configfile from .configfile import configfile
from .connapp import connapp from .connapp import connapp
from .api import * from .api import *
from .ai import ai
from .plugins import Plugins from .plugins import Plugins
from ._version import __version__ from ._version import __version__
from . import printer from . import printer
__all__ = ["node", "nodes", "configfile", "connapp", "ai", "Plugins", "printer"] def __getattr__(name: str):
if name == "ai":
from .ai import ai
globals()["ai"] = ai
return ai
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = ["node", "nodes", "configfile", "connapp", "Plugins", "printer"]
__author__ = "Federico Luzzi" __author__ = "Federico Luzzi"
__pdoc__ = { __pdoc__ = {
'core': False, 'core': False,
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import sys import sys
from connpy import * from connpy import configfile, connapp
def main(): def main():
conf = configfile() conf = configfile()
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.4" __version__ = "6.1.0"
+3 -2
View File
@@ -1159,8 +1159,9 @@ class ai:
for msg in chat_history[-self.max_history:]: for msg in chat_history[-self.max_history:]:
if msg.get('role') != 'system': if msg.get('role') != 'system':
messages.append(msg) messages.append(msg)
# Add current user request # Add current user request with a system note to prevent infinite escalation loops
messages.append({"role": "user", "content": clean_input}) fallback_msg = clean_input + "\n\n[SYSTEM NOTE: The Architect is currently unavailable/failed to respond. You must handle the user's request directly as the Network Engineer. Do NOT attempt to escalate to the Architect again.]"
messages.append({"role": "user", "content": fallback_msg})
continue continue
else: else:
return {"response": f"Error: Both engines failed. {str(e)}", "chat_history": messages[1:], "usage": usage} return {"response": f"Error: Both engines failed. {str(e)}", "chat_history": messages[1:], "usage": usage}
+20 -1
View File
@@ -26,7 +26,10 @@ class ConfigHandler:
"trusted_commands": self.set_ai_config, "trusted_commands": self.set_ai_config,
"service_mode": self.set_service_mode, "service_mode": self.set_service_mode,
"remote_host": self.set_remote_host, "remote_host": self.set_remote_host,
"sync_remote": self.set_sync_remote "sync_remote": self.set_sync_remote,
"shell_command": self.set_shell_config,
"shell_prompt": self.set_shell_config,
"shell_os": self.set_shell_config
} }
handler = actions.get(getattr(args, "command", None)) handler = actions.get(getattr(args, "command", None))
if handler: if handler:
@@ -183,3 +186,19 @@ class ConfigHandler:
except Exception: except Exception:
raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.") raise InvalidConfigurationError("Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.")
def set_shell_config(self, args):
key = args.command.replace("shell_", "")
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get("shell", {}) if isinstance(settings.get("shell"), dict) else {}
if str(val).lower() in ["none", "clear", ""]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting("shell", shell_cfg)
printer.success("Config saved")
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))
+5 -1
View File
@@ -1,5 +1,4 @@
import ast import ast
import inquirer
from .validators import Validators from .validators import Validators
class Forms: class Forms:
@@ -8,6 +7,7 @@ class Forms:
self.validators = Validators(app) self.validators = Validators(app)
def questions_edit(self): def questions_edit(self):
import inquirer
questions = [] questions = []
questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?")) questions.append(inquirer.Confirm("host", message="Edit Hostname/IP?"))
questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?")) questions.append(inquirer.Confirm("protocol", message="Edit Protocol/app?"))
@@ -21,6 +21,7 @@ class Forms:
return inquirer.prompt(questions) return inquirer.prompt(questions)
def questions_nodes(self, unique, uniques=None, edit=None): def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try: try:
defaults = self.app.services.nodes.get_node_details(unique) defaults = self.app.services.nodes.get_node_details(unique)
if "tags" not in defaults: if "tags" not in defaults:
@@ -98,6 +99,7 @@ class Forms:
return result return result
def questions_profiles(self, unique, edit=None): def questions_profiles(self, unique, edit=None):
import inquirer
try: try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False) defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if "tags" not in defaults: if "tags" not in defaults:
@@ -163,6 +165,7 @@ class Forms:
return result return result
def questions_bulk(self, nodes="", hosts=""): def questions_bulk(self, nodes="", hosts=""):
import inquirer
questions = [] questions = []
questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation)) questions.append(inquirer.Text("ids", message="add a comma separated list of nodes to add", default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation)) questions.append(inquirer.Text("location", message="Add a @folder, @subfolder@folder or leave empty", validate=self.validators.bulk_folder_validation))
@@ -200,6 +203,7 @@ class Forms:
def mcp_wizard(self, mcp_servers): def mcp_wizard(self, mcp_servers):
"""Interactive wizard to manage MCP servers.""" """Interactive wizard to manage MCP servers."""
import inquirer
from .helpers import theme from .helpers import theme
while True: while True:
+6 -5
View File
@@ -1,6 +1,4 @@
import os import os
import inquirer
from inquirer.themes import Default, term
try: try:
from pyfzf.pyfzf import FzfPrompt from pyfzf.pyfzf import FzfPrompt
@@ -9,6 +7,7 @@ except ImportError:
def hex_to_blessed(hex_str): def hex_to_blessed(hex_str):
"""Convert hex color string to blessed/ansi format.""" """Convert hex color string to blessed/ansi format."""
from inquirer.themes import term
if not hex_str or not isinstance(hex_str, str): if not hex_str or not isinstance(hex_str, str):
return term.normal return term.normal
@@ -42,7 +41,10 @@ def hex_to_blessed(hex_str):
except: except:
return prefix + term.normal return prefix + term.normal
# Custom inquirer theme matching connpy colors def get_theme():
"""Returns a fresh instance of the theme with current colors."""
from inquirer.themes import Default, term
class ConnpyTheme(Default): class ConnpyTheme(Default):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -61,8 +63,6 @@ class ConnpyTheme(Default):
self.List.selection_color = term.bold_cyan self.List.selection_color = term.bold_cyan
self.List.selection_cursor = ">" self.List.selection_cursor = ">"
def get_theme():
"""Returns a fresh instance of the theme with current colors."""
return ConnpyTheme() return ConnpyTheme()
class ThemeProxy: class ThemeProxy:
@@ -126,6 +126,7 @@ def choose(app, list_, name, action):
else: else:
return answer[0] return answer[0]
else: else:
import inquirer
questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)] questions = [inquirer.List(name, message="Pick {} to {}:".format(name,action), choices=list_, carousel=True)]
answer = inquirer.prompt(questions, theme=theme) answer = inquirer.prompt(questions, theme=theme)
if answer == None: if answer == None:
+13 -3
View File
@@ -1,19 +1,29 @@
import os import os
import sys import sys
import inquirer
from .. import printer from .. import printer
from ..services.exceptions import ConnpyError from ..services.exceptions import ConnpyError
from .forms import Forms
class ImportExportHandler: class ImportExportHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch_import(self, args): def dispatch_import(self, args):
file_path = args.data[0] file_path = args.data[0]
try: try:
printer.warning("This could overwrite your current configuration!") printer.warning("This could overwrite your current configuration!")
import inquirer
question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")] question = [inquirer.Confirm("import", message=f"Are you sure you want to import {file_path}?")]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm["import"]: if confirm == None or not confirm["import"]:
+102
View File
@@ -19,6 +19,14 @@ class LoginHandler:
sys.exit(1) sys.exit(1)
def login(self, args): def login(self, args):
# Handle token management actions first
if getattr(args, "create_token", None):
return self.create_token(args)
if getattr(args, "list_tokens", False):
return self.list_tokens(args)
if getattr(args, "revoke_token", None):
return self.revoke_token(args)
if getattr(args, "status", False): if getattr(args, "status", False):
return self.show_status() return self.show_status()
@@ -141,3 +149,97 @@ class LoginHandler:
printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}") printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
except Exception as e: except Exception as e:
printer.error(f"Failed to check local session status: {e}") printer.error(f"Failed to check local session status: {e}")
def _get_auth_service(self):
"""Gets an authenticated auth service stub, reusing existing or creating one."""
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:
# Load existing session token for authentication
token_path = os.path.join(self.app.config.defaultdir, ".token")
if not os.path.exists(token_path):
printer.error("No active session. Please log in first using 'connpy login'.")
sys.exit(1)
with open(token_path, "r") as f:
session_token = f.read().strip()
from ..grpc_layer.stubs import AuthClientInterceptor
interceptor = AuthClientInterceptor(lambda: session_token)
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f"Failed to connect to remote server: {e}")
sys.exit(1)
return auth_service
def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, "expires_days", 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f"API token '{name}' created successfully.")
printer.warning("⚠ Copy this token now. It will NOT be shown again:")
printer.data("Token", result["raw_token"])
printer.info(f"Token ID: {result['token_id']}")
if expires_days > 0:
printer.info(f"Expires in: {expires_days} days")
else:
printer.info("Expires: Never (permanent)")
except ConnpyError as e:
printer.error(f"Failed to create token: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to create token: {e}")
sys.exit(1)
def list_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info("No API tokens found.")
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
"token_id": t["token_id"],
"name": t["name"],
"prefix": t["token_prefix"],
"created": t["created_at"] or "N/A",
"last_used": t["last_used_at"] or "Never",
"expires": t["expires_at"] or "Never",
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data("API Tokens", yaml_str)
except ConnpyError as e:
printer.error(f"Failed to list tokens: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to list tokens: {e}")
sys.exit(1)
def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f"Token '{token_id}' revoked successfully.")
except ConnpyError as e:
printer.error(f"Failed to revoke token: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Failed to revoke token: {e}")
sys.exit(1)
+13 -3
View File
@@ -1,18 +1,27 @@
import sys import sys
import yaml import yaml
import inquirer
from rich.markdown import Markdown from rich.markdown import Markdown
from .. import printer from .. import printer
from ..services.exceptions import ConnpyError, InvalidConfigurationError from ..services.exceptions import ConnpyError, InvalidConfigurationError
from .helpers import choose from .helpers import choose
from .forms import Forms
from .help_text import get_instructions from .help_text import get_instructions
class NodeHandler: class NodeHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def _filter_exact_match(self, matches, query): def _filter_exact_match(self, matches, query):
if not query or len(matches) <= 1: if not query or len(matches) <= 1:
@@ -100,6 +109,7 @@ class NodeHandler:
sys.exit(2) sys.exit(2)
printer.info(f"Removing: {matches}") printer.info(f"Removing: {matches}")
import inquirer
question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")] question = [inquirer.Confirm("delete", message="Are you sure you want to continue?")]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm["delete"]: if confirm == None or not confirm["delete"]:
+13 -3
View File
@@ -1,15 +1,24 @@
import sys import sys
import yaml import yaml
import inquirer
from .. import printer from .. import printer
from ..services.exceptions import ConnpyError, ProfileNotFoundError from ..services.exceptions import ConnpyError, ProfileNotFoundError
from .forms import Forms
class ProfileHandler: class ProfileHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch(self, args): def dispatch(self, args):
if not self.app.case: if not self.app.case:
@@ -29,6 +38,7 @@ class ProfileHandler:
printer.error("Can't delete default profile") printer.error("Can't delete default profile")
sys.exit(6) sys.exit(6)
import inquirer
question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")] question = [inquirer.Confirm("delete", message=f"Are you sure you want to delete {name}?")]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm["delete"]: if confirm == None or not confirm["delete"]:
+34 -2
View File
@@ -379,6 +379,35 @@ class RunHandler:
from rich.rule import Rule from rich.rule import Rule
from rich.panel import Panel from rich.panel import Panel
from rich.syntax import Syntax from rich.syntax import Syntax
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
# Helper to get active theme color
def get_theme_color(style_name, fallback="white"):
try:
style = printer.connpy_theme.styles.get(style_name)
if style and style.color:
if style.color.is_default: return fallback
return style.color.triplet.hex if style.color.triplet else style.color.name
except: pass
return fallback
user_color = get_theme_color("user_prompt", "#00afd7")
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
kb = KeyBindings()
@kb.add('enter')
def _(event):
event.current_buffer.validate_and_handle()
@kb.add('c-j')
@kb.add('escape', 'enter')
def _(event):
event.current_buffer.insert_text('\n')
session = PromptSession(key_bindings=kb)
dest_file = args.data[0] dest_file = args.data[0]
if os.path.exists(dest_file): if os.path.exists(dest_file):
@@ -390,12 +419,15 @@ class RunHandler:
# Consistent layout opening matching global AI (engineer style) # Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown from rich.markdown import Markdown
printer.console.print(Rule(style="engineer")) printer.console.print(Rule(style="engineer"))
printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n")) printer.console.print(Markdown("**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n"))
printer.console.print(Rule(style="engineer")) printer.console.print(Rule(style="engineer"))
while True: while True:
try: try:
user_prompt = Prompt.ask("[user_prompt]User[/user_prompt]") user_prompt = session.prompt(
HTML(f'<style fg="{user_color}">User (Enter to submit, Ctrl+Enter for newline):</style>\n'),
multiline=True
)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
printer.console.print() printer.console.print()
printer.warning("Operation cancelled by user.") printer.warning("Operation cancelled by user.")
+55
View File
@@ -0,0 +1,55 @@
import os
import sys
import shutil
import shlex
import socket
from .. import printer
from ..core import node
class ShellHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
shell_config = self.app.config.config.get("shell", {}) if hasattr(self.app.config, "config") else {}
command = getattr(args, 'command_override', None) or shell_config.get("command") or os.environ.get("SHELL", "/bin/bash")
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f"Shell command executable not found: {exe}")
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
"os": node_info["os"],
"prompt": node_info["prompt"]
}
n = node(
unique=node_info["name"],
host=command,
protocol="local",
config=self.app.config,
tags=tags
)
capture_file = getattr(args, 'capture_file', None)
if capture_file:
n.logs = capture_file
elif shell_config.get("logging"):
n.logs = shell_config.get("log_path", os.path.expanduser("~/.config/conn/shell_logs/session.log"))
n.interact(debug=getattr(args, 'debug', False))
def _build_local_identity(self, shell_config):
return {
"name": "local-shell",
"host": socket.gethostname(),
"os": shell_config.get("os", "linux"),
"prompt": shell_config.get("prompt", r'\$\s*$|#\s*$')
}
+2 -1
View File
@@ -1,6 +1,5 @@
import sys import sys
import yaml import yaml
import inquirer
from .. import printer from .. import printer
class SSOHandler: class SSOHandler:
@@ -40,6 +39,7 @@ class SSOHandler:
sys.exit(1) sys.exit(1)
def add_provider(self, args): def add_provider(self, args):
import inquirer
provider = args.provider provider = args.provider
sso = self.app.config.config.get("sso", {}) sso = self.app.config.config.get("sso", {})
providers = sso.setdefault("providers", {}) providers = sso.setdefault("providers", {})
@@ -113,6 +113,7 @@ class SSOHandler:
sys.exit(1) sys.exit(1)
# Confirm delete # Confirm delete
import inquirer
questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)] questions = [inquirer.Confirm("confirm", message=f"Are you sure you want to delete SSO Provider '{provider}'?", default=False)]
answers = inquirer.prompt(questions) answers = inquirer.prompt(questions)
if not answers or not answers["confirm"]: if not answers or not answers["confirm"]:
+83 -17
View File
@@ -14,34 +14,49 @@ from rich.panel import Panel
from rich.markdown import Markdown from rich.markdown import Markdown
from prompt_toolkit import PromptSession from prompt_toolkit import PromptSession
from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.filters import has_completions
from prompt_toolkit.formatted_text import HTML from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.history import InMemoryHistory
from ..printer import connpy_theme from ..printer import connpy_theme
from connpy.utils import log_cleaner from connpy.utils import log_cleaner
from ..services.ai_service import AIService
class CopilotInterface: class CopilotInterface:
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None): def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
from ..services.ai_service import AIService
self.config = config self.config = config
self.history = history or InMemoryHistory() self.history = history or InMemoryHistory()
self.pt_input = pt_input self.pt_input = pt_input
self.pt_output = pt_output self.pt_output = pt_output
self.rich_file = rich_file
self.ai_service = AIService(config) self.ai_service = AIService(config)
self.session_state = session_state if session_state is not None else { self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
'persona': 'engineer',
'trust_mode': False, self.session_state = session_state if session_state is not None else {}
'memories': [], self.session_state.setdefault('persona', 'engineer')
'os': None, self.session_state.setdefault('trust_mode', False)
'prompt': None self.session_state.setdefault('memories', [])
} self.session_state.setdefault('os', None)
self.session_state.setdefault('prompt', None)
self.session_state.setdefault('context_mode', self.mode_range)
self.session_state.setdefault('context_cmd', 1)
self.session_state.setdefault('context_lines', 50)
self.session_state.setdefault('last_total_cmds', None)
self.session_state.setdefault('last_total_lines', None)
if rich_file: if rich_file:
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file) self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
else: else:
self.console = Console(theme=connpy_theme) self.console = Console(theme=connpy_theme)
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2 def _sync_session_context(self, state: dict):
"""Persist current context mode, depth, total commands, and total lines into session_state."""
self.session_state['context_mode'] = state['context_mode']
self.session_state['context_cmd'] = state['context_cmd']
self.session_state['context_lines'] = state['context_lines']
self.session_state['last_total_cmds'] = state['total_cmds']
self.session_state['last_total_lines'] = state['total_lines']
def _get_theme_color(self, style_name: str, fallback: str = "white") -> str: def _get_theme_color(self, style_name: str, fallback: str = "white") -> str:
"""Extract Hex or ANSI color name from the active rich theme.""" """Extract Hex or ANSI color name from the active rich theme."""
@@ -75,16 +90,52 @@ class CopilotInterface:
last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)" last_line = buffer.split('\n')[-1].strip() if buffer.strip() else "(prompt)"
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
total_cmds = len(blocks)
total_lines = len(buffer.split('\n'))
saved_mode = self.session_state.get('context_mode', self.mode_range)
saved_cmd = self.session_state.get('context_cmd', 1)
saved_lines = self.session_state.get('context_lines', min(50, total_lines))
last_total_cmds = self.session_state.get('last_total_cmds', None)
last_total_lines = self.session_state.get('last_total_lines', None)
is_range = saved_mode in (self.mode_range, 0, 'RANGE', 'range')
is_lines = saved_mode in (self.mode_lines, 2, 'LINES', 'lines')
is_single = saved_mode in (self.mode_single, 1, 'SINGLE', 'single')
if is_range or is_single:
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
state = { state = {
'context_cmd': 1, 'context_cmd': min(max(1, initial_cmd), max(1, total_cmds)),
'total_cmds': len(blocks), 'total_cmds': total_cmds,
'total_lines': len(buffer.split('\n')), 'total_lines': total_lines,
'context_lines': min(50, len(buffer.split('\n'))), 'context_lines': min(max(1, initial_lines), max(1, total_lines)),
'context_mode': self.mode_range, 'context_mode': saved_mode,
'cancelled': False, 'cancelled': False,
'toolbar_msg': '', 'toolbar_msg': '',
'msg_expiry': 0 'msg_expiry': 0
} }
self.session_state['context_mode'] = saved_mode
self.session_state['context_cmd'] = max(1, initial_cmd)
self.session_state['context_lines'] = max(1, initial_lines)
self.session_state['last_total_cmds'] = total_cmds
self.session_state['last_total_lines'] = total_lines
# 1. Visual Separation # 1. Visual Separation
self.console.print("") # Real line break self.console.print("") # Real line break
@@ -103,6 +154,7 @@ class CopilotInterface:
state['context_lines'] = min(state['context_lines'] + 50, state['total_lines']) state['context_lines'] = min(state['context_lines'] + 50, state['total_lines'])
else: else:
state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds']) state['context_cmd'] = min(state['context_cmd'] + 1, state['total_cmds'])
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add('c-down') @bindings.add('c-down')
def _(event): def _(event):
@@ -110,6 +162,7 @@ class CopilotInterface:
state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines'])) state['context_lines'] = max(state['context_lines'] - 50, min(50, state['total_lines']))
else: else:
state['context_cmd'] = max(state['context_cmd'] - 1, 1) state['context_cmd'] = max(state['context_cmd'] - 1, 1)
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add('tab') @bindings.add('tab')
def _(event): def _(event):
@@ -119,6 +172,7 @@ class CopilotInterface:
buf.complete_next() buf.complete_next()
else: else:
state['context_mode'] = (state['context_mode'] + 1) % 3 state['context_mode'] = (state['context_mode'] + 1) % 3
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add('escape', eager=True) @bindings.add('escape', eager=True)
@bindings.add('c-c') @bindings.add('c-c')
@@ -126,6 +180,16 @@ class CopilotInterface:
state['cancelled'] = True state['cancelled'] = True
event.app.exit(result='') event.app.exit(result='')
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
@bindings.add('enter', filter=~has_completions)
def _(event):
event.current_buffer.validate_and_handle()
@bindings.add('c-j')
@bindings.add('escape', 'enter')
def _(event):
event.current_buffer.insert_text('\n')
def get_active_buffer(): def get_active_buffer():
if state['context_mode'] == self.mode_lines: if state['context_mode'] == self.mode_lines:
return '\n'.join(buffer.split('\n')[-state['context_lines']:]) return '\n'.join(buffer.split('\n')[-state['context_lines']:])
@@ -271,7 +335,8 @@ class CopilotInterface:
question = await session.prompt_async( question = await session.prompt_async(
get_prompt_text, get_prompt_text,
key_bindings=bindings, key_bindings=bindings,
bottom_toolbar=get_toolbar bottom_toolbar=get_toolbar,
multiline=True
) )
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
state['cancelled'] = True state['cancelled'] = True
@@ -284,13 +349,14 @@ class CopilotInterface:
directive = self.ai_service.process_copilot_input(question, self.session_state) directive = self.ai_service.process_copilot_input(question, self.session_state)
if directive["action"] == "state_update": if directive["action"] == "state_update":
state['toolbar_msg'] = directive['message'] msg = directive['message']
state['toolbar_msg'] = msg
state['msg_expiry'] = time.time() + 3 # 3 seconds timeout state['msg_expiry'] = time.time() + 3 # 3 seconds timeout
async def delayed_refresh(): async def delayed_refresh():
await asyncio.sleep(3.1) await asyncio.sleep(3.1)
# Only invalidate if the message hasn't been replaced by a newer one # Only invalidate if the message hasn't been replaced by a newer one
if state.get('toolbar_msg') == directive['message']: if state.get('toolbar_msg') == msg:
state['toolbar_msg'] = '' # Explicitly clear state['toolbar_msg'] = '' # Explicitly clear
try: try:
from prompt_toolkit.application.current import get_app from prompt_toolkit.application.current import get_app
+27 -24
View File
@@ -1,6 +1,9 @@
import re import re
import ast import ast
def _raise_val_err(reason):
import inquirer import inquirer
raise inquirer.errors.ValidationError("", reason=reason)
class Validators: class Validators:
def __init__(self, app): def __init__(self, app):
@@ -8,61 +11,61 @@ class Validators:
def host_validation(self, answers, current, regex = "^.+$"): def host_validation(self, answers, current, regex = "^.+$"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty") _raise_val_err("Host cannot be empty")
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
return True return True
def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"): def profile_protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm or leave empty") _raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm or leave empty")
return True return True
def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"): def protocol_validation(self, answers, current, regex = "(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile") _raise_val_err("Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile")
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
return True return True
def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"): def profile_port_validation(self, answers, current, regex = "(^[0-9]*$)"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty") _raise_val_err("Pick a port between 1-65535, @profile o leave empty")
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current != "" and not 1 <= int(port) <= 65535: if current != "" and not 1 <= int(port) <= 65535:
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535 or leave empty") _raise_val_err("Pick a port between 1-65535 or leave empty")
return True return True
def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"): def port_validation(self, answers, current, regex = "(^[0-9]*$|^@.+$)"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile or leave empty") _raise_val_err("Pick a port between 1-65535, @profile or leave empty")
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
elif current != "" and not 1 <= int(port) <= 65535: elif current != "" and not 1 <= int(port) <= 65535:
raise inquirer.errors.ValidationError("", reason="Pick a port between 1-65535, @profile o leave empty") _raise_val_err("Pick a port between 1-65535, @profile o leave empty")
return True return True
def pass_validation(self, answers, current, regex = "(^@.+$)"): def pass_validation(self, answers, current, regex = "(^@.+$)"):
profiles = current.split(",") profiles = current.split(",")
for i in profiles: for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles: if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(i)) _raise_val_err("Profile {} don't exist".format(i))
return True return True
def tags_validation(self, answers, current): def tags_validation(self, answers, current):
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
elif current != "": elif current != "":
isdict = False isdict = False
try: try:
@@ -70,7 +73,7 @@ class Validators:
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current)) _raise_val_err("Tags should be a python dictionary.".format(current))
return True return True
def profile_tags_validation(self, answers, current): def profile_tags_validation(self, answers, current):
@@ -81,36 +84,36 @@ class Validators:
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError("", reason="Tags should be a python dictionary.".format(current)) _raise_val_err("Tags should be a python dictionary.".format(current))
return True return True
def jumphost_validation(self, answers, current): def jumphost_validation(self, answers, current):
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
elif current != "": elif current != "":
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current)) _raise_val_err("Node {} don't exist.".format(current))
return True return True
def profile_jumphost_validation(self, answers, current): def profile_jumphost_validation(self, answers, current):
if current != "": if current != "":
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError("", reason="Node {} don't exist.".format(current)) _raise_val_err("Node {} don't exist.".format(current))
return True return True
def default_validation(self, answers, current): def default_validation(self, answers, current):
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
return True return True
def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"): def bulk_node_validation(self, answers, current, regex = "^[0-9a-zA-Z_.,$#-]+$"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty") _raise_val_err("Host cannot be empty")
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
return True return True
def bulk_folder_validation(self, answers, current): def bulk_folder_validation(self, answers, current):
@@ -123,17 +126,17 @@ class Validators:
matches = list(filter(lambda k: k == candidate, self.app.folders)) matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != "" and len(matches) == 0: if current != "" and len(matches) == 0:
raise inquirer.errors.ValidationError("", reason="Location {} don't exist".format(current)) _raise_val_err("Location {} don't exist".format(current))
return True return True
def bulk_host_validation(self, answers, current, regex = "^.+$"): def bulk_host_validation(self, answers, current, regex = "^.+$"):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError("", reason="Host cannot be empty") _raise_val_err("Host cannot be empty")
if current.startswith("@"): if current.startswith("@"):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError("", reason="Profile {} don't exist".format(current)) _raise_val_err("Profile {} don't exist".format(current))
hosts = current.split(",") hosts = current.split(",")
nodes = answers["ids"].split(",") nodes = answers["ids"].split(",")
if len(hosts) > 1 and len(hosts) != len(nodes): if len(hosts) > 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError("", reason="Hosts list should be the same length of nodes list") _raise_val_err("Hosts list should be the same length of nodes list")
return True return True
+18 -2
View File
@@ -238,12 +238,22 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"--sync-remote": ["true", "false"], "--sync-remote": ["true", "false"],
"--help": None, "-h": None, "--help": None, "-h": None,
} }
for opt in ["--keepalive", "--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key", "--theme", "--remote", "--trusted-commands"]: for opt in ["--keepalive", "--engineer-model", "--engineer-api-key", "--architect-model", "--architect-api-key", "--theme", "--remote", "--trusted-commands", "--shell-command", "--shell-prompt", "--shell-os"]:
config_dict[opt] = {"*": config_dict} config_dict[opt] = {"*": config_dict}
config_dict["--configfolder"] = {"__extra__": lambda w: get_cwd(w, "--configfolder", True), "*": config_dict} config_dict["--configfolder"] = {"__extra__": lambda w: get_cwd(w, "--configfolder", True), "*": config_dict}
config_dict["--engineer-auth"] = {"__extra__": lambda w: get_cwd(w, "--engineer-auth"), "*": config_dict} config_dict["--engineer-auth"] = {"__extra__": lambda w: get_cwd(w, "--engineer-auth"), "*": config_dict}
config_dict["--architect-auth"] = {"__extra__": lambda w: get_cwd(w, "--architect-auth"), "*": config_dict} config_dict["--architect-auth"] = {"__extra__": lambda w: get_cwd(w, "--architect-auth"), "*": config_dict}
shell_dict = {
"--command": {"*": None},
"-c": {"*": None},
"--capture": {"__extra__": lambda w: get_cwd(w, "--capture")},
"--debug": None,
"-d": None,
"--help": None,
"-h": None
}
_users = lambda w=None: _get_users(configdir) _users = lambda w=None: _get_users(configdir)
user_dict = { user_dict = {
@@ -368,9 +378,15 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
}, },
"user": user_dict, "user": user_dict,
"sso": sso_dict, "sso": sso_dict,
"login": {"--help": None, "-h": None, "*": None}, "login": {
"--status": None, "-s": None,
"--create-token": None, "--list-tokens": None,
"--revoke-token": None, "--expires-days": None,
"--help": None, "-h": None, "*": None
},
"logout": {"--help": None, "-h": None}, "logout": {"--help": None, "-h": None},
"config": config_dict, "config": config_dict,
"shell": shell_dict,
"sync": { "sync": {
"--login": None, "--logout": None, "--login": None, "--logout": None,
"--status": None, "--list": None, "--status": None, "--list": None,
+47 -11
View File
@@ -10,15 +10,9 @@ from .core import node,nodes
from ._version import __version__ from ._version import __version__
from . import printer from . import printer
from .api import start_api,stop_api,debug_api from .api import start_api,stop_api,debug_api
from .ai import ai
from .plugins import Plugins from .plugins import Plugins
from .services import ( from .services.exceptions import ConnpyError, ProfileNotFoundError, ReservedNameError
NodeService, ProfileService, ConfigService,
PluginService, AIService, SystemService,
ExecutionService, ImportExportService, ConnpyError,
ProfileNotFoundError, ReservedNameError
)
from rich_argparse import RichHelpFormatter from rich_argparse import RichHelpFormatter
# Bridge rich-argparse with our design system # Bridge rich-argparse with our design system
@@ -46,6 +40,32 @@ console = printer.console
#functions and classes #functions and classes
class DeferredAIProxy:
"""Proxy for connapp.ai that defers importing connpy.ai until ai is actually invoked or accessed."""
def __init__(self):
self._deferred_modifications = []
self._real_ai = None
def _load_real_ai(self):
if self._real_ai is None:
from .ai import ai
self._real_ai = ai
for mod in self._deferred_modifications:
self._real_ai.modify(mod)
return self._real_ai
def modify(self, modification_func):
if self._real_ai is not None:
self._real_ai.modify(modification_func)
else:
self._deferred_modifications.append(modification_func)
def __call__(self, *args, **kwargs):
return self._load_real_ai()(*args, **kwargs)
def __getattr__(self, name):
return getattr(self._load_real_ai(), name)
class connapp: class connapp:
''' This class starts the connection manager app. It's normally used by connection manager but you can use it on a script to run the connection manager your way and use a different configfile and key. ''' This class starts the connection manager app. It's normally used by connection manager but you can use it on a script to run the connection manager your way and use a different configfile and key.
''' '''
@@ -77,7 +97,7 @@ class connapp:
self.start_api = start_api self.start_api = start_api
self.stop_api = stop_api # Using SystemService logic eventually self.stop_api = stop_api # Using SystemService logic eventually
self.debug_api = debug_api self.debug_api = debug_api
self.ai = ai self.ai = DeferredAIProxy()
# Register context filtering hooks (only on Client CLI, bypass on gRPC Server) # Register context filtering hooks (only on Client CLI, bypass on gRPC Server)
is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api" is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api"
@@ -142,6 +162,7 @@ class connapp:
from .cli.user_handler import UserHandler from .cli.user_handler import UserHandler
from .cli.login_handler import LoginHandler from .cli.login_handler import LoginHandler
from .cli.sso_handler import SSOHandler from .cli.sso_handler import SSOHandler
from .cli.shell_handler import ShellHandler
# Instantiate Handlers # Instantiate Handlers
self._node = NodeHandler(self) self._node = NodeHandler(self)
@@ -153,6 +174,7 @@ class connapp:
self._plugin = PluginHandler(self) self._plugin = PluginHandler(self)
self._context = ContextHandler(self) self._context = ContextHandler(self)
self._import_export = ImportExportHandler(self) self._import_export = ImportExportHandler(self)
self._shell = ShellHandler(self)
self._sync = SyncHandler(self) self._sync = SyncHandler(self)
self._user = UserHandler(self) self._user = UserHandler(self)
self._login = LoginHandler(self) self._login = LoginHandler(self)
@@ -364,10 +386,19 @@ class connapp:
configcrud.add_argument("--architect-api-key", dest="architect_api_key", nargs=1, action=self._store_type, help="Set architect api_key", metavar="API_KEY") configcrud.add_argument("--architect-api-key", dest="architect_api_key", nargs=1, action=self._store_type, help="Set architect api_key", metavar="API_KEY")
configcrud.add_argument("--architect-auth", dest="architect_auth", nargs=1, action=self._store_type, help="Set architect auth (inline JSON/YAML or file path)", metavar="AUTH") configcrud.add_argument("--architect-auth", dest="architect_auth", nargs=1, action=self._store_type, help="Set architect auth (inline JSON/YAML or file path)", metavar="AUTH")
configcrud.add_argument("--sync-remote", dest="sync_remote", nargs=1, action=self._store_type, help="Sync remote nodes to Google Drive", choices=["true","false"]) configcrud.add_argument("--sync-remote", dest="sync_remote", nargs=1, action=self._store_type, help="Sync remote nodes to Google Drive", choices=["true","false"])
configcrud.add_argument("--shell-command", dest="shell_command", nargs=1, action=self._store_type, help="Set default shell command", metavar="COMMAND")
configcrud.add_argument("--shell-prompt", dest="shell_prompt", nargs=1, action=self._store_type, help="Set shell prompt regex for AI", metavar="REGEX")
configcrud.add_argument("--shell-os", dest="shell_os", nargs=1, action=self._store_type, help="Set shell OS hint for AI", metavar="OS")
configparser.add_argument("--trusted-commands", dest="trusted_commands", nargs=1, action=self._store_type, help="Set custom trusted commands regexes (comma separated)", metavar="REGEX,REGEX") configparser.add_argument("--trusted-commands", dest="trusted_commands", nargs=1, action=self._store_type, help="Set custom trusted commands regexes (comma separated)", metavar="REGEX,REGEX")
configparser.set_defaults(func=self._config.dispatch) configparser.set_defaults(func=self._config.dispatch)
#USERPARSER #SHELLPARSER
shellparser = subparsers.add_parser("shell", help="Start local interactive shell with copilot", formatter_class=RichHelpFormatter)
shellparser.error = self._custom_error
shellparser.add_argument("--command", "-c", dest="command_override", help="Override shell command")
shellparser.add_argument("--capture", dest="capture_file", help="Capture session to file")
shellparser.add_argument("-d", "--debug", action="store_true", help="Debug mode")
shellparser.set_defaults(func=self._shell.dispatch)
userparser = subparsers.add_parser("user", help="Manage server users", description="Manage server users", formatter_class=RichHelpFormatter) userparser = subparsers.add_parser("user", help="Manage server users", description="Manage server users", formatter_class=RichHelpFormatter)
userparser.error = self._custom_error userparser.error = self._custom_error
usercrud = userparser.add_mutually_exclusive_group(required=True) usercrud = userparser.add_mutually_exclusive_group(required=True)
@@ -395,6 +426,10 @@ class connapp:
loginparser.error = self._custom_error loginparser.error = self._custom_error
loginparser.add_argument("username", nargs='?', default=None, help="Username to authenticate") loginparser.add_argument("username", nargs='?', default=None, help="Username to authenticate")
loginparser.add_argument("-s", "--status", action="store_true", help="Check current login status") loginparser.add_argument("-s", "--status", action="store_true", help="Check current login status")
loginparser.add_argument("--create-token", dest="create_token", metavar="NAME", help="Create a permanent API token with the given name")
loginparser.add_argument("--list-tokens", dest="list_tokens", action="store_true", help="List all active API tokens")
loginparser.add_argument("--revoke-token", dest="revoke_token", metavar="TOKEN_ID", help="Revoke an API token by its ID")
loginparser.add_argument("--expires-days", dest="expires_days", type=int, default=0, metavar="DAYS", help="Optional expiration in days for --create-token (default: permanent)")
loginparser.set_defaults(func=self._login.dispatch, action="login") loginparser.set_defaults(func=self._login.dispatch, action="login")
#LOGOUTPARSER #LOGOUTPARSER
@@ -525,11 +560,12 @@ class connapp:
printer.warning("Operation cancelled by user.") printer.warning("Operation cancelled by user.")
sys.exit(130) sys.exit(130)
finally: finally:
# Safely cleanup AI sessions (litellm) # Safely cleanup AI sessions (litellm) if AI was loaded
if "connpy.ai" in sys.modules:
try: try:
from .ai import cleanup from .ai import cleanup
cleanup() cleanup()
except ImportError: except (ImportError, Exception):
pass pass
class _store_type(argparse.Action): class _store_type(argparse.Action):
+78 -3
View File
@@ -374,7 +374,7 @@ class node:
self.child.setwinsize(int(size.group(2)),int(size.group(1))) self.child.setwinsize(int(size.group(2)),int(size.group(1)))
except OSError: except OSError:
pass pass
if logger: if logger and self.protocol != "local":
port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else "" port_str = f":{self.port}" if self.port and self.protocol not in ["ssm", "kubectl", "docker"] else ""
logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}") logger("success", f"Connected to {self.unique} at {self.host}{port_str} via: {self.protocol}")
@@ -408,6 +408,34 @@ class node:
with open(self.logfile, "w") as f: with open(self.logfile, "w") as f:
f.write(self._logclean(self.mylog.getvalue().decode(), True)) f.write(self._logclean(self.mylog.getvalue().decode(), True))
def _is_child_connpy_active(self, child_fd: int) -> bool:
if self.protocol != "local":
return False
try:
fg_pgid = os.tcgetpgrp(child_fd)
if fg_pgid <= 0:
return False
cmdline_path = f"/proc/{fg_pgid}/cmdline"
if os.path.exists(cmdline_path):
with open(cmdline_path, "rb") as f:
raw_args = f.read().split(b"\x00")
cmdline_str = " ".join([arg.decode(errors="ignore") for arg in raw_args if arg])
is_active = False
for raw_arg in raw_args:
arg_str = raw_arg.decode(errors="ignore")
if not arg_str:
continue
base_name = os.path.basename(arg_str)
if base_name in ["conn", "connpy", "connapp"] or "connpy" in arg_str or "connapp" in arg_str:
is_active = True
break
return is_active
except Exception:
pass
return False
async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None): async def _async_interact_loop(self, local_stream, resize_callback, copilot_handler=None):
local_stream.setup(resize_callback=resize_callback) local_stream.setup(resize_callback=resize_callback)
self.current_local_stream = local_stream self.current_local_stream = local_stream
@@ -472,6 +500,14 @@ class node:
# Copilot interception # Copilot interception
if copilot_handler and b'\x00' in data: if copilot_handler and b'\x00' in data:
if self._is_child_connpy_active(child_fd):
try:
os.write(child_fd, data)
except OSError:
break
self.lastinput = time()
continue
# Build node info from available metadata and ensure values are strings (not bytes) # Build node info from available metadata and ensure values are strings (not bytes)
def to_str(val): def to_str(val):
if isinstance(val, bytes): if isinstance(val, bytes):
@@ -579,12 +615,39 @@ class node:
except Exception: except Exception:
pass pass
async def pwd_tracker_task():
import socket
hostname = socket.gethostname()
last_cwd = None
while True:
await asyncio.sleep(0.3)
try:
child_pid = getattr(self.child, 'pid', None)
if child_pid:
new_cwd = os.readlink(f"/proc/{child_pid}/cwd")
if new_cwd != last_cwd:
last_cwd = new_cwd
try:
os.chdir(new_cwd)
except Exception:
pass
try:
await local_stream.write(f"\033]7;file://{hostname}{new_cwd}\007".encode())
except Exception:
pass
if isinstance(self.tags, dict):
self.tags["cwd"] = new_cwd
except Exception:
pass
try: try:
# We wait for either the user (ingress) or the child (egress) to finish # We wait for either the user (ingress) or the child (egress) to finish
tasks = [ tasks = [
asyncio.create_task(ingress_task()), asyncio.create_task(ingress_task()),
asyncio.create_task(egress_task()) asyncio.create_task(egress_task())
] ]
if self.protocol == "local":
tasks.append(asyncio.create_task(pwd_tracker_task()))
if self.idletime > 0: if self.idletime > 0:
tasks.append(asyncio.create_task(keepalive_task())) tasks.append(asyncio.create_task(keepalive_task()))
if hasattr(self, 'logfile') and hasattr(self, 'mylog'): if hasattr(self, 'logfile') and hasattr(self, 'mylog'):
@@ -712,13 +775,14 @@ class node:
def _copilot_handler(self, config): def _copilot_handler(self, config):
"""Unified copilot handler for local session.""" """Unified copilot handler for local session."""
from .cli.terminal_ui import CopilotInterface
from .services.ai_service import AIService
import asyncio import asyncio
import os import os
async def handler(buffer, node_info, stream, child_fd, cmd_byte_positions=None): async def handler(buffer, node_info, stream, child_fd, cmd_byte_positions=None):
try: try:
from .cli.terminal_ui import CopilotInterface
from .services.ai_service import AIService
interface = CopilotInterface( interface = CopilotInterface(
config, config,
history=getattr(stream, 'copilot_history', None), history=getattr(stream, 'copilot_history', None),
@@ -1105,12 +1169,23 @@ class node:
return self._generate_docker_cmd() return self._generate_docker_cmd()
elif self.protocol == "ssm": elif self.protocol == "ssm":
return self._generate_ssm_cmd() return self._generate_ssm_cmd()
elif self.protocol == "local":
return self.host
else: else:
printer.error(f"Invalid protocol: {self.protocol}") printer.error(f"Invalid protocol: {self.protocol}")
sys.exit(1) sys.exit(1)
@MethodHook @MethodHook
def _connect(self, debug=False, timeout=10, max_attempts=3, logger=None): def _connect(self, debug=False, timeout=10, max_attempts=3, logger=None):
if self.protocol == "local":
cmd = self._get_cmd()
args = shlex.split(cmd)
self.child = pexpect.spawn(args[0], args[1:], env=os.environ.copy())
from pexpect import fdpexpect
self.raw_child = fdpexpect.fdspawn(self.child.child_fd)
if self.logs != '':
self.logfile = self._logfile()
return True
cmd = self._get_cmd() cmd = self._get_cmd()
passwords = self._passtx(self.password) if self.password and any(self.password) else [] passwords = self._passtx(self.password) if self.password and any(self.password) else []
File diff suppressed because one or more lines are too long
+129
View File
@@ -2652,6 +2652,21 @@ class AuthServiceStub(object):
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString, response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True) _registered_method=True)
self.create_api_token = channel.unary_unary(
'/connpy.AuthService/create_api_token',
request_serializer=connpy__pb2.CreateApiTokenRequest.SerializeToString,
response_deserializer=connpy__pb2.CreateApiTokenResponse.FromString,
_registered_method=True)
self.list_api_tokens = channel.unary_unary(
'/connpy.AuthService/list_api_tokens',
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.ListApiTokensResponse.FromString,
_registered_method=True)
self.revoke_api_token = channel.unary_unary(
'/connpy.AuthService/revoke_api_token',
request_serializer=connpy__pb2.RevokeApiTokenRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
class AuthServiceServicer(object): class AuthServiceServicer(object):
@@ -2681,6 +2696,24 @@ class AuthServiceServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def create_api_token(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 list_api_tokens(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 revoke_api_token(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 add_AuthServiceServicer_to_server(servicer, server): def add_AuthServiceServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
@@ -2704,6 +2737,21 @@ def add_AuthServiceServicer_to_server(servicer, server):
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString, response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
), ),
'create_api_token': grpc.unary_unary_rpc_method_handler(
servicer.create_api_token,
request_deserializer=connpy__pb2.CreateApiTokenRequest.FromString,
response_serializer=connpy__pb2.CreateApiTokenResponse.SerializeToString,
),
'list_api_tokens': grpc.unary_unary_rpc_method_handler(
servicer.list_api_tokens,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.ListApiTokensResponse.SerializeToString,
),
'revoke_api_token': grpc.unary_unary_rpc_method_handler(
servicer.revoke_api_token,
request_deserializer=connpy__pb2.RevokeApiTokenRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
'connpy.AuthService', rpc_method_handlers) 'connpy.AuthService', rpc_method_handlers)
@@ -2822,3 +2870,84 @@ class AuthService(object):
timeout, timeout,
metadata, metadata,
_registered_method=True) _registered_method=True)
@staticmethod
def create_api_token(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/create_api_token',
connpy__pb2.CreateApiTokenRequest.SerializeToString,
connpy__pb2.CreateApiTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def list_api_tokens(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/list_api_tokens',
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.ListApiTokensResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def revoke_api_token(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/revoke_api_token',
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+124 -8
View File
@@ -249,10 +249,59 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
raw_bytes = str(raw_bytes).encode() raw_bytes = str(raw_bytes).encode()
from connpy.utils import log_cleaner from connpy.utils import log_cleaner
last_line = log_cleaner(raw_bytes.decode(errors='replace')).split('\n')[-1].strip() cleaned_buffer = log_cleaner(raw_bytes.decode(errors='replace'))
last_line = cleaned_buffer.split('\n')[-1].strip() if cleaned_buffer.strip() else "(prompt)"
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line) blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
node_info["context_blocks"] = blocks node_info["context_blocks"] = blocks
total_cmds = len(blocks)
total_lines = len(cleaned_buffer.split('\n'))
if not hasattr(remote_stream, 'copilot_state') or remote_stream.copilot_state is None:
remote_stream.copilot_state = {}
session_state = remote_stream.copilot_state
if isinstance(node_info, dict):
for k, v in node_info.items():
if k in ('context_mode', 'context_cmd', 'context_lines', 'persona', 'trust', 'os', 'prompt'):
session_state[k] = v
saved_mode = session_state.get('context_mode', 0)
saved_cmd = session_state.get('context_cmd', 1)
saved_lines = session_state.get('context_lines', 50)
last_total_cmds = session_state.get('last_total_cmds', None)
last_total_lines = session_state.get('last_total_lines', None)
is_range = saved_mode in (0, 'RANGE', 'range')
is_lines = saved_mode in (2, 'LINES', 'lines')
is_single = saved_mode in (1, 'SINGLE', 'single')
if is_range or is_single:
if last_total_cmds is not None and total_cmds > last_total_cmds and saved_cmd > 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines > last_total_lines and saved_lines > 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
session_state['context_cmd'] = max(1, initial_cmd)
session_state['context_lines'] = max(1, initial_lines)
session_state['last_total_cmds'] = total_cmds
session_state['last_total_lines'] = total_lines
node_info.update(session_state)
node_info['context_cmd'] = min(session_state['context_cmd'], max(1, total_cmds))
node_info['context_lines'] = min(session_state['context_lines'], max(1, total_lines))
node_info_json = json.dumps(node_info) node_info_json = json.dumps(node_info)
# Convert buffer to string if it's bytes for the preview # Convert buffer to string if it's bytes for the preview
@@ -297,6 +346,17 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if req_session_id and req_session_id != copilot_session_id: if req_session_id and req_session_id != copilot_session_id:
continue # Ignore stale request from a previous session continue # Ignore stale request from a previous session
merged_node_info_str = req_data.get("node_info_json", "")
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
node_info.update(merged_node_info)
# Sync context state from frontend into session_state for persistence
for k in ('context_mode', 'context_cmd', 'context_lines'):
if k in merged_node_info:
session_state[k] = merged_node_info[k]
except: pass
if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") in ("cancel", "web_cancel"): if "question" not in req_data or not req_data["question"] or req_data["question"] == "CANCEL" or req_data.get("action") in ("cancel", "web_cancel"):
if req_data.get("action") == "web_cancel": if req_data.get("action") == "web_cancel":
os.write(child_fd, b'\x05') os.write(child_fd, b'\x05')
@@ -305,13 +365,6 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
return return
question = req_data["question"] question = req_data["question"]
merged_node_info_str = req_data.get("node_info_json", "")
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
node_info.update(merged_node_info)
except: pass
context_buffer = req_data.get("context_buffer", "") context_buffer = req_data.get("context_buffer", "")
if context_buffer.startswith('{"context_start_pos"'): if context_buffer.startswith('{"context_start_pos"'):
try: try:
@@ -373,6 +426,15 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if not action_data: return if not action_data: return
action = action_data.get("action", "cancel") action = action_data.get("action", "cancel")
merged_node_info_str = action_data.get("node_info_json", "")
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
for k in ('context_mode', 'context_cmd', 'context_lines'):
if k in merged_node_info:
session_state[k] = merged_node_info[k]
except: pass
if action == "continue": if action == "continue":
continue # Loop back for next question continue # Loop back for next question
@@ -1426,6 +1488,58 @@ class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
return Empty() return Empty()
@handle_errors
def create_api_token(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
try:
expires_in_days = request.expires_in_days if request.expires_in_days > 0 else None
result = self.registry.user_service.create_api_token(
username, request.name, expires_in_days=expires_in_days
)
except ValueError as e:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
return connpy_pb2.CreateApiTokenResponse(
token_id=result["token_id"],
raw_token=result["raw_token"],
name=result["name"],
)
@handle_errors
def list_api_tokens(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
tokens = self.registry.user_service.list_api_tokens(username)
token_infos = [
connpy_pb2.ApiTokenInfo(
token_id=t["token_id"],
name=t.get("name") or "",
token_prefix=t.get("token_prefix") or "",
created_at=t.get("created_at") or "",
last_used_at=t.get("last_used_at") or "",
expires_at=t.get("expires_at") or "",
)
for t in tokens
]
return connpy_pb2.ListApiTokensResponse(tokens=token_infos)
@handle_errors
def revoke_api_token(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
removed = self.registry.user_service.revoke_api_token(username, request.token_id)
if not removed:
context.abort(grpc.StatusCode.NOT_FOUND, f"Token '{request.token_id}' not found")
return Empty()
class AuthInterceptor(grpc.ServerInterceptor): class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"] OPEN_METHODS = ["/connpy.AuthService/login", "/connpy.AuthService/login_sso", "/connpy.AuthService/get_sso_providers"]
@@ -1445,6 +1559,8 @@ class AuthInterceptor(grpc.ServerInterceptor):
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing") return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
username = self.registry.user_service.verify_jwt(token) username = self.registry.user_service.verify_jwt(token)
if not username and token.startswith("cnp_pat_"):
username = self.registry.user_service.verify_api_token(token)
if not username: if not username:
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token") return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
+30
View File
@@ -1148,3 +1148,33 @@ class AuthStub:
def change_password(self, old_password, new_password): def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password) req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req) self.stub.change_password(req)
@handle_errors
def create_api_token(self, name, expires_in_days=0):
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
resp = self.stub.create_api_token(req)
return {
"token_id": resp.token_id,
"raw_token": resp.raw_token,
"name": resp.name,
}
@handle_errors
def list_api_tokens(self):
resp = self.stub.list_api_tokens(Empty())
return [
{
"token_id": t.token_id,
"name": t.name,
"token_prefix": t.token_prefix,
"created_at": t.created_at,
"last_used_at": t.last_used_at,
"expires_at": t.expires_at,
}
for t in resp.tokens
]
@handle_errors
def revoke_api_token(self, token_id):
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
self.stub.revoke_api_token(req)
+31
View File
@@ -304,6 +304,9 @@ service AuthService {
rpc login_sso (LoginSSORequest) returns (LoginResponse) {} rpc login_sso (LoginSSORequest) returns (LoginResponse) {}
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {} rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {} rpc get_sso_providers (google.protobuf.Empty) returns (SSOProvidersResponse) {}
rpc create_api_token (CreateApiTokenRequest) returns (CreateApiTokenResponse) {}
rpc list_api_tokens (google.protobuf.Empty) returns (ListApiTokensResponse) {}
rpc revoke_api_token (RevokeApiTokenRequest) returns (google.protobuf.Empty) {}
} }
message SSOProvidersResponse { message SSOProvidersResponse {
@@ -332,6 +335,34 @@ message ChangePasswordRequest {
string new_password = 2; string new_password = 2;
} }
message CreateApiTokenRequest {
string name = 1;
int32 expires_in_days = 2;
}
message CreateApiTokenResponse {
string token_id = 1;
string raw_token = 2;
string name = 3;
}
message ApiTokenInfo {
string token_id = 1;
string name = 2;
string token_prefix = 3;
string created_at = 4;
string last_used_at = 5;
string expires_at = 6;
}
message ListApiTokensResponse {
repeated ApiTokenInfo tokens = 1;
}
message RevokeApiTokenRequest {
string token_id = 1;
}
message AnalyzeRequest { message AnalyzeRequest {
google.protobuf.Struct results = 1; google.protobuf.Struct results = 1;
string query = 2; string query = 2;
+28 -3
View File
@@ -1,12 +1,35 @@
from .exceptions import * from .exceptions import *
from .node_service import NodeService from .node_service import NodeService
from .profile_service import ProfileService from .profile_service import ProfileService
from .execution_service import ExecutionService
from .import_export_service import ImportExportService
from .ai_service import AIService
from .plugin_service import PluginService from .plugin_service import PluginService
from .config_service import ConfigService from .config_service import ConfigService
def __getattr__(name: str):
if name == "ExecutionService":
from .execution_service import ExecutionService
globals()["ExecutionService"] = ExecutionService
return ExecutionService
elif name == "ImportExportService":
from .import_export_service import ImportExportService
globals()["ImportExportService"] = ImportExportService
return ImportExportService
elif name == "SystemService":
from .system_service import SystemService from .system_service import SystemService
globals()["SystemService"] = SystemService
return SystemService
elif name == "SyncService":
from .sync_service import SyncService
globals()["SyncService"] = SyncService
return SyncService
elif name == "UserService":
from .user_service import UserService
globals()["UserService"] = UserService
return UserService
elif name == "AIService":
from .ai_service import AIService
globals()["AIService"] = AIService
return AIService
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
__all__ = [ __all__ = [
'NodeService', 'NodeService',
@@ -17,6 +40,8 @@ __all__ = [
'PluginService', 'PluginService',
'ConfigService', 'ConfigService',
'SystemService', 'SystemService',
'SyncService',
'UserService',
'ConnpyError', 'ConnpyError',
'NodeNotFoundError', 'NodeNotFoundError',
'NodeAlreadyExistsError', 'NodeAlreadyExistsError',
+2 -2
View File
@@ -271,13 +271,13 @@ class PluginService(BaseService):
is_mock = True is_mock = True
def __init__(self, config): def __init__(self, config):
from ..core import node, nodes from ..core import node, nodes
from ..ai import ai from ..connapp import DeferredAIProxy
from ..services.provider import ServiceProvider from ..services.provider import ServiceProvider
self.config = config self.config = config
self.node = node self.node = node
self.nodes = nodes self.nodes = nodes
self.ai = ai self.ai = DeferredAIProxy()
self.services = ServiceProvider(config, mode="local") self.services = ServiceProvider(config, mode="local")
+75 -15
View File
@@ -14,6 +14,12 @@ class ServiceProvider:
self.mode = mode self.mode = mode
self.config = config self.config = config
self.remote_host = remote_host self.remote_host = remote_host
self._system = None
self._execution = None
self._import_export = None
self._sync = None
self._users = None
self._ai = None
if mode == "local": if mode == "local":
self._init_local() self._init_local()
@@ -27,35 +33,20 @@ class ServiceProvider:
from .profile_service import ProfileService from .profile_service import ProfileService
from .config_service import ConfigService from .config_service import ConfigService
from .plugin_service import PluginService from .plugin_service import PluginService
from .ai_service import AIService
from .system_service import SystemService
from .execution_service import ExecutionService
from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config) self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config) self.profiles = ProfileService(self.config)
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.plugins = PluginService(self.config) self.plugins = PluginService(self.config)
self.ai = AIService(self.config)
self.system = SystemService(self.config)
self.execution = ExecutionService(self.config)
self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self): def _init_remote(self):
# Allow ConfigService to work locally so the user can revert the mode # Allow ConfigService to work locally so the user can revert the mode
from .config_service import ConfigService from .config_service import ConfigService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = None
if not self.remote_host: if not self.remote_host:
raise InvalidConfigurationError("Remote host must be specified in remote mode") raise InvalidConfigurationError("Remote host must be specified in remote mode")
@@ -69,6 +60,9 @@ class ServiceProvider:
) )
def get_token(): def get_token():
env_token = os.environ.get("CONNPY_TOKEN")
if env_token:
return env_token
token_path = os.path.join(self.config.defaultdir, ".token") token_path = os.path.join(self.config.defaultdir, ".token")
if os.path.exists(token_path): if os.path.exists(token_path):
try: try:
@@ -95,3 +89,69 @@ class ServiceProvider:
self.execution = ExecutionStub(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) self.auth = AuthStub(channel, remote_host=self.remote_host)
@property
def system(self):
if self._system is None and self.mode == "local":
from .system_service import SystemService
self._system = SystemService(self.config)
return self._system
@system.setter
def system(self, value):
self._system = value
@property
def execution(self):
if self._execution is None and self.mode == "local":
from .execution_service import ExecutionService
self._execution = ExecutionService(self.config)
return self._execution
@execution.setter
def execution(self, value):
self._execution = value
@property
def import_export(self):
if self._import_export is None and self.mode == "local":
from .import_export_service import ImportExportService
self._import_export = ImportExportService(self.config)
return self._import_export
@import_export.setter
def import_export(self, value):
self._import_export = value
@property
def sync(self):
if self._sync is None:
from .sync_service import SyncService
self._sync = SyncService(self.config)
return self._sync
@sync.setter
def sync(self, value):
self._sync = value
@property
def users(self):
if self._users is None and self.mode == "local":
from .user_service import UserService
self._users = UserService(self.config.defaultdir)
return self._users
@users.setter
def users(self, value):
self._users = value
@property
def ai(self):
if self._ai is None and self.mode == "local":
from .ai_service import AIService
self._ai = AIService(self.config)
return self._ai
@ai.setter
def ai(self, value):
self._ai = value
+42 -1
View File
@@ -1,3 +1,4 @@
import sys
import os import os
import time import time
import zipfile import zipfile
@@ -6,13 +7,46 @@ import io
import yaml import yaml
import threading import threading
from datetime import datetime from datetime import datetime
def __getattr__(name: str):
if name == "Credentials":
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
return Credentials
elif name == "Request":
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
return Request
elif name == "build":
from googleapiclient.discovery import build from googleapiclient.discovery import build
return build
elif name == "RefreshError":
from google.auth.exceptions import RefreshError from google.auth.exceptions import RefreshError
return RefreshError
elif name == "InstalledAppFlow":
from google_auth_oauthlib.flow import InstalledAppFlow from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload return InstalledAppFlow
elif name == "MediaFileUpload":
from googleapiclient.http import MediaFileUpload
return MediaFileUpload
elif name == "MediaIoBaseDownload":
from googleapiclient.http import MediaIoBaseDownload
return MediaIoBaseDownload
elif name == "HttpError":
from googleapiclient.errors import HttpError from googleapiclient.errors import HttpError
return HttpError
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def _get_google_libs():
mod = sys.modules[__name__]
return (
getattr(mod, "Credentials"),
getattr(mod, "Request"),
getattr(mod, "build"),
getattr(mod, "RefreshError"),
getattr(mod, "InstalledAppFlow"),
getattr(mod, "MediaFileUpload"),
getattr(mod, "MediaIoBaseDownload"),
getattr(mod, "HttpError"),
)
from .base import BaseService from .base import BaseService
from .. import printer from .. import printer
@@ -44,6 +78,7 @@ class SyncService(BaseService):
def login(self): def login(self):
"""Authenticate with Google Drive.""" """Authenticate with Google Drive."""
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
creds = None creds = None
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
@@ -81,6 +116,7 @@ class SyncService(BaseService):
def get_credentials(self): def get_credentials(self):
"""Get valid credentials, refreshing if necessary.""" """Get valid credentials, refreshing if necessary."""
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
else: else:
@@ -98,6 +134,7 @@ class SyncService(BaseService):
def check_login_status(self): def check_login_status(self):
"""Check if logged in to Google Drive.""" """Check if logged in to Google Drive."""
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file) creds = Credentials.from_authorized_user_file(self.token_file)
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
@@ -110,6 +147,7 @@ class SyncService(BaseService):
def list_backups(self): def list_backups(self):
"""List files in Google Drive appDataFolder.""" """List files in Google Drive appDataFolder."""
_, _, build, _, _, _, _, HttpError = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: if not creds:
printer.error("Not logged in to Google Drive.") printer.error("Not logged in to Google Drive.")
@@ -168,6 +206,7 @@ class SyncService(BaseService):
def upload_file(self, file_path, timestamp): def upload_file(self, file_path, timestamp):
"""Internal method to upload to Drive.""" """Internal method to upload to Drive."""
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
@@ -193,6 +232,7 @@ class SyncService(BaseService):
def delete_backup(self, file_id): def delete_backup(self, file_id):
"""Delete a backup from Drive.""" """Delete a backup from Drive."""
_, _, build, _, _, _, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
@@ -226,6 +266,7 @@ class SyncService(BaseService):
def download_file(self, file_id, dest): def download_file(self, file_id, dest):
"""Internal method to download from Drive.""" """Internal method to download from Drive."""
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
+136
View File
@@ -1,4 +1,5 @@
import os import os
import hashlib
import re import re
import shutil import shutil
import secrets import secrets
@@ -18,6 +19,9 @@ class UserService:
# Ensure users directory exists # Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True) os.makedirs(self.users_dir, exist_ok=True)
# Reverse index cache: token_hash -> (username, token_id)
self._token_index: dict[str, tuple[str, str]] = {}
def _load_registry(self) -> dict: def _load_registry(self) -> dict:
"""Loads registry from file. If it doesn't exist, initializes it with a new JWT secret.""" """Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
if not os.path.exists(self.registry_file): if not os.path.exists(self.registry_file):
@@ -61,6 +65,16 @@ class UserService:
pass pass
raise e raise e
def _build_token_index(self, registry: dict) -> dict[str, tuple[str, str]]:
"""Builds a reverse index of token_hash -> (username, token_id) for O(1) PAT lookup."""
index = {}
for username, user_data in registry.get("users", {}).items():
for token_id, token_meta in user_data.get("api_tokens", {}).items():
token_hash = token_meta.get("token_hash")
if token_hash:
index[token_hash] = (username, token_id)
return index
def create_user(self, username, password, config_path=None) -> dict: def create_user(self, username, password, config_path=None) -> dict:
"""Creates a new user with bcrypt-hashed credentials. """Creates a new user with bcrypt-hashed credentials.
@@ -237,3 +251,125 @@ class UserService:
return payload.get("sub") return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError): except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None return None
# --- Personal Access Token (PAT) Management ---
def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -> dict:
"""Creates a Personal Access Token for the user.
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
"""
if not name or not isinstance(name, str):
raise ValueError("Token name 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 "api_tokens" not in user_data:
user_data["api_tokens"] = {}
# Generate cryptographically secure token with recognizable prefix
raw_secret = secrets.token_hex(32)
raw_token = f"cnp_pat_{raw_secret}"
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
token_id = f"tok_{secrets.token_hex(4)}"
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = None
if expires_in_days and expires_in_days > 0:
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
user_data["api_tokens"][token_id] = {
"name": name,
"token_hash": token_hash,
"token_prefix": raw_token[:16],
"created_at": now.isoformat(),
"last_used_at": None,
"expires_at": expires_at,
}
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return {
"token_id": token_id,
"raw_token": raw_token,
"name": name,
}
def list_api_tokens(self, username: str) -> list[dict]:
"""Lists all active API tokens for a user (without sensitive data)."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
tokens = registry["users"][username].get("api_tokens", {})
return [
{
"token_id": tid,
"name": meta.get("name"),
"token_prefix": meta.get("token_prefix"),
"created_at": meta.get("created_at"),
"last_used_at": meta.get("last_used_at"),
"expires_at": meta.get("expires_at"),
}
for tid, meta in tokens.items()
]
def revoke_api_token(self, username: str, token_id: str) -> bool:
"""Revokes (deletes) a specific API token. Returns True if found and removed."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
tokens = registry["users"][username].get("api_tokens", {})
if token_id not in tokens:
return False
del tokens[token_id]
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return True
def verify_api_token(self, raw_token: str) -> str | None:
"""Validates a PAT by hashing it and looking up the reverse index.
Returns username if valid and not expired, None otherwise.
"""
token_hash = hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
# Rebuild index if empty (cold start or after process restart)
if not self._token_index:
registry = self._load_registry()
self._token_index = self._build_token_index(registry)
match = self._token_index.get(token_hash)
if not match:
return None
username, token_id = match
# Validate token still exists and check expiration
registry = self._load_registry()
user_data = registry.get("users", {}).get(username, {})
token_meta = user_data.get("api_tokens", {}).get(token_id)
if not token_meta:
# Token was revoked between index build and now
self._token_index = self._build_token_index(registry)
return None
# Check expiration
expires_at = token_meta.get("expires_at")
if expires_at:
exp_dt = datetime.datetime.fromisoformat(expires_at)
if datetime.datetime.now(datetime.timezone.utc) > exp_dt:
return None
# Update last_used_at
token_meta["last_used_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
self._save_registry(registry)
return username
+161
View File
@@ -400,3 +400,164 @@ def test_build_context_blocks_pager_scrolling_6wind_escapes():
def test_copilot_context_state_persistence():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
session_state = {}
interface = CopilotInterface(MockConfig(), session_state=session_state)
raw_bytes = b"router# show ip\r\nrouter# show run\r\nrouter# "
blocks = [
(0, 15, "router# show ip"),
(15, 30, "router# show run"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
kb = kwargs.get('key_bindings')
if kb:
class DummyApp:
def invalidate(self): pass
class DummyEvent:
app = DummyApp()
current_buffer = type('Buf', (), {'text': ''})()
# Trigger TAB key ('c-i' or 'tab') to switch mode from RANGE (0) to SINGLE (1)
for b in kb.bindings:
if any(k in ('c-i', 'tab') or 'tab' in str(k).lower() or 'c-i' in str(k).lower() for k in b.keys):
b.handler(DummyEvent())
break
return "test question"
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface.run_session(
raw_bytes=raw_bytes,
node_info={"name": "test"},
on_ai_call=mock_ai_call,
blocks=blocks
))
assert interface.session_state.get('context_mode') == interface.mode_single
assert interface.session_state.get('last_total_cmds') == len(blocks)
def test_copilot_range_mode_accumulation():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
blocks = [
(0, 10, "router# cmd1"),
(10, 20, "router# cmd2"),
(20, 30, "router# cmd3"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: RANGE mode at default (saved_cmd = 1) -> stays 1 (does not expand)
session_state_default = {'context_mode': 0, 'context_cmd': 1, 'last_total_cmds': 2}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_cmd') == 1
# Test 2: RANGE mode at expanded (saved_cmd = 2 > 1) -> expands to 2 + 2 = 4
session_state_expanded = {'context_mode': 0, 'context_cmd': 2, 'last_total_cmds': 2}
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_expanded.session_state.get('context_cmd') == 4
def test_copilot_lines_mode_accumulation():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = ("line\n" * 130).encode()
blocks = [(0, 10, "router#")]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: LINES mode at default 50 lines -> stays 50
session_state_default = {'context_mode': 2, 'context_lines': 50, 'last_total_lines': 100}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_lines') == 50
# Test 2: LINES mode at expanded 100 lines -> expands beyond 100
session_state_expanded = {'context_mode': 2, 'context_lines': 100, 'last_total_lines': 100}
interface_expanded = CopilotInterface(MockConfig(), session_state=session_state_expanded)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_expanded.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_expanded.session_state.get('context_lines') > 100
def test_copilot_single_mode_retains_command_block():
from connpy.cli.terminal_ui import CopilotInterface
class MockConfig:
def __init__(self):
self.config = {"ai": {}}
self.defaultdir = "/tmp"
raw_bytes = b"router# cmd1\r\nrouter# cmd2\r\nrouter# cmd3\r\nrouter# "
blocks = [
(0, 10, "router# cmd1"),
(10, 20, "router# cmd2"),
(20, 30, "router# cmd3"),
(30, 40, "router#")
]
async def mock_ai_call(active_buffer, question, on_chunk, node_info):
return {"guide": "Ok", "commands": [], "risk_level": "low"}
async def mock_prompt_async(self, *args, **kwargs):
return "cancel"
# Test 1: In SINGLE mode at default (context_cmd = 1), stays at 1
session_state_default = {'context_mode': 1, 'context_cmd': 1, 'last_total_cmds': 2}
interface_default = CopilotInterface(MockConfig(), session_state=session_state_default)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_default.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_default.session_state.get('context_cmd') == 1
# Test 2: In SINGLE mode at past command (context_cmd = 2 > 1), becomes 2 + 2 = 4 to stay locked on past command
session_state_custom = {'context_mode': 1, 'context_cmd': 2, 'last_total_cmds': 2}
interface_custom = CopilotInterface(MockConfig(), session_state=session_state_custom)
with patch('prompt_toolkit.PromptSession.prompt_async', mock_prompt_async):
asyncio.run(interface_custom.run_session(raw_bytes=raw_bytes, node_info={"name": "test"}, on_ai_call=mock_ai_call, blocks=blocks))
assert interface_custom.session_state.get('context_cmd') == 4
+5 -3
View File
@@ -100,8 +100,9 @@ def test_ai_generate_wizard_save(app, tmp_path):
}) })
app.services.ai.build_playbook_chat = mock_chat app.services.ai.build_playbook_chat = mock_chat
# Mock rich.prompt.Prompt.ask to simulate User inputting prompt and then 'y' to save # Mock prompt_toolkit PromptSession.prompt for user input, and Prompt.ask for save confirmation
with patch("rich.prompt.Prompt.ask", side_effect=["create a basic task", "y"]): with patch("prompt_toolkit.PromptSession.prompt", return_value="create a basic task"):
with patch("rich.prompt.Prompt.ask", return_value="y"):
app.start(["run", "--generate-ai", str(dest_yaml)]) app.start(["run", "--generate-ai", str(dest_yaml)])
mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY) mock_chat.assert_called_once_with("create a basic task", chat_history=[], chunk_callback=ANY)
@@ -121,7 +122,8 @@ def test_ai_generate_wizard_run(app, tmp_path):
}) })
app.services.ai.build_playbook_chat = mock_chat app.services.ai.build_playbook_chat = mock_chat
with patch("rich.prompt.Prompt.ask", side_effect=["create task", "run"]): with patch("prompt_toolkit.PromptSession.prompt", return_value="create task"):
with patch("rich.prompt.Prompt.ask", return_value="run"):
with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run: with patch("connpy.cli.run_handler.RunHandler.yaml_run") as mock_yaml_run:
app.start(["run", "--generate-ai", str(dest_yaml)]) app.start(["run", "--generate-ai", str(dest_yaml)])
+17
View File
@@ -77,6 +77,9 @@ class TestTreeCompletions:
config_completions = resolve_completion(["config", ""], tree) config_completions = resolve_completion(["config", ""], tree)
assert "--engineer-auth" in config_completions assert "--engineer-auth" in config_completions
assert "--architect-auth" in config_completions assert "--architect-auth" in config_completions
assert "--shell-command" in config_completions
assert "--shell-prompt" in config_completions
assert "--shell-os" in config_completions
# Resolve when --engineer-auth is chosen in config # Resolve when --engineer-auth is chosen in config
auth_comp = resolve_completion(["config", "--engineer-auth", ""], tree) auth_comp = resolve_completion(["config", "--engineer-auth", ""], tree)
@@ -88,6 +91,20 @@ class TestTreeCompletions:
loop_back_comp = resolve_completion(["config", "--engineer-auth", "some_val", ""], tree) loop_back_comp = resolve_completion(["config", "--engineer-auth", "some_val", ""], tree)
assert "--architect-auth" in loop_back_comp assert "--architect-auth" in loop_back_comp
assert "--engineer-auth" in loop_back_comp assert "--engineer-auth" in loop_back_comp
assert "--shell-command" in loop_back_comp
def test_shell_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
shell_completions = resolve_completion(["shell", ""], tree)
assert "--command" in shell_completions
assert "--capture" in shell_completions
assert "--debug" in shell_completions
assert "--help" in shell_completions
# Short flags must NOT be recommended
assert "-c" not in shell_completions
assert "-d" not in shell_completions
assert "-h" not in shell_completions
def test_ai_auth_completions(self): def test_ai_auth_completions(self):
from connpy.completion import _build_tree, resolve_completion from connpy.completion import _build_tree, resolve_completion
+80
View File
@@ -0,0 +1,80 @@
import os
import sys
import pytest
from unittest.mock import patch, MagicMock
from connpy.core import node
from connpy.cli.shell_handler import ShellHandler
from connpy.cli.validators import Validators
def test_local_protocol_get_cmd_and_connect():
"""Test protocol=local in node returns command string and spawns process."""
n = node(unique="test_local", host="echo hello", protocol="local")
assert n._get_cmd() == "echo hello"
with patch("pexpect.spawn") as mock_spawn:
mock_child = MagicMock()
mock_child.child_fd = 10
mock_spawn.return_value = mock_child
with patch("pexpect.fdpexpect.fdspawn") as mock_fdspawn:
res = n._connect()
assert res is True
mock_spawn.assert_called_once()
def test_shell_handler_dispatch(tmp_path):
"""Test ShellHandler builds transient node and calls interact."""
app_mock = MagicMock()
app_mock.config.config = {"shell": {"os": "ubuntu", "prompt": r"\$\s*$"}}
handler = ShellHandler(app_mock)
args = MagicMock()
args.command_override = None
args.capture_file = str(tmp_path / "session.log")
args.debug = False
with patch("connpy.cli.shell_handler.node") as mock_node_cls:
mock_node = MagicMock()
mock_node_cls.return_value = mock_node
handler.dispatch(args)
mock_node_cls.assert_called_once()
kwargs = mock_node_cls.call_args.kwargs
assert kwargs["protocol"] == "local"
assert kwargs["unique"] == "local-shell"
assert mock_node.interact.called
def test_validator_excludes_local_protocol():
"""Ensure protocol_validation does NOT accept 'local' for inventory forms."""
validators = Validators(MagicMock())
with pytest.raises(Exception):
validators.protocol_validation({}, "local")
def test_is_child_connpy_active_non_local_protocol():
"""Ensure non-local protocols (e.g. ssh) never check for sub-child processes."""
n = node(unique="ssh_node", host="10.0.0.1", protocol="ssh")
with patch("os.tcgetpgrp") as mock_tcgetpgrp:
assert n._is_child_connpy_active(10) is False
mock_tcgetpgrp.assert_not_called()
def test_is_child_connpy_active_local_protocol():
"""Test detection of child connpy process when protocol=local."""
n = node(unique="local_node", host="/bin/bash", protocol="local")
# Case 1: child is connpy
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"python3\x00/usr/local/bin/connpy\x00connect\x00r1")))):
assert n._is_child_connpy_active(10) is True
# Case 2: child is regular bash
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/bin/bash\x00")))):
assert n._is_child_connpy_active(10) is False
# Case 3: child is conn entry point (e.g. /home/fluzzi32/.local/bin/conn xr)
with patch("os.tcgetpgrp", return_value=1234), \
patch("os.path.exists", return_value=True), \
patch("builtins.open", MagicMock(return_value=MagicMock(__enter__=lambda s: MagicMock(read=lambda: b"/usr/bin/python3\x00/home/fluzzi32/.local/bin/conn\x00xr")))):
assert n._is_child_connpy_active(10) is True
+216
View File
@@ -0,0 +1,216 @@
import os
import datetime
import hashlib
import pytest
import yaml
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def user_service(test_config_dir):
"""Initializes UserService pointing to a temporary directory."""
return UserService(str(test_config_dir))
@pytest.fixture
def user_with_token(user_service):
"""Creates a user and returns (user_service, username, token_result)."""
username = "tokenuser"
user_service.create_user(username, "password123")
result = user_service.create_api_token(username, "Test Token")
return user_service, username, result
class TestApiTokenCreation:
def test_create_api_token_returns_raw_token(self, user_service):
"""Verifies that create_api_token returns a raw token with the correct prefix."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "CI Pipeline")
assert "raw_token" in result
assert result["raw_token"].startswith("cnp_pat_")
assert len(result["raw_token"]) > 16
assert "token_id" in result
assert result["token_id"].startswith("tok_")
assert result["name"] == "CI Pipeline"
def test_create_api_token_stores_hash_not_plaintext(self, user_service):
"""Ensures only the SHA-256 hash is persisted, never the raw token."""
user_service.create_user("bob", "pass")
result = user_service.create_api_token("bob", "My App")
registry = user_service._load_registry()
tokens = registry["users"]["bob"]["api_tokens"]
assert len(tokens) == 1
token_meta = list(tokens.values())[0]
expected_hash = hashlib.sha256(result["raw_token"].encode("utf-8")).hexdigest()
assert token_meta["token_hash"] == expected_hash
# Raw token must NOT be stored
assert result["raw_token"] not in str(token_meta)
def test_create_api_token_with_expiration(self, user_service):
"""Verifies that expires_at is set correctly when expires_in_days is provided."""
user_service.create_user("charlie", "pass")
user_service.create_api_token("charlie", "Temp Token", expires_in_days=30)
registry = user_service._load_registry()
token_meta = list(registry["users"]["charlie"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is not None
exp_dt = datetime.datetime.fromisoformat(token_meta["expires_at"])
now = datetime.datetime.now(datetime.timezone.utc)
delta = exp_dt - now
assert 29 <= delta.days <= 30
def test_create_api_token_permanent_by_default(self, user_service):
"""Verifies that expires_at is None when no expiration is specified."""
user_service.create_user("dave", "pass")
user_service.create_api_token("dave", "Permanent Token")
registry = user_service._load_registry()
token_meta = list(registry["users"]["dave"]["api_tokens"].values())[0]
assert token_meta["expires_at"] is None
def test_create_api_token_nonexistent_user(self, user_service):
"""Ensures creating a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.create_api_token("ghost", "Token")
def test_create_api_token_empty_name(self, user_service):
"""Ensures empty token names are rejected."""
user_service.create_user("eve", "pass")
with pytest.raises(ValueError, match="cannot be empty"):
user_service.create_api_token("eve", "")
def test_create_multiple_tokens(self, user_service):
"""Verifies a user can have multiple tokens."""
user_service.create_user("frank", "pass")
t1 = user_service.create_api_token("frank", "Token 1")
t2 = user_service.create_api_token("frank", "Token 2")
assert t1["token_id"] != t2["token_id"]
assert t1["raw_token"] != t2["raw_token"]
tokens = user_service.list_api_tokens("frank")
assert len(tokens) == 2
class TestApiTokenVerification:
def test_verify_valid_token(self, user_with_token):
"""Verifies that a valid raw token authenticates correctly."""
svc, username, result = user_with_token
verified = svc.verify_api_token(result["raw_token"])
assert verified == username
def test_verify_invalid_token(self, user_service):
"""Verifies that a random/invalid token returns None."""
user_service.create_user("alice", "pass")
assert user_service.verify_api_token("cnp_pat_invalid_token_here") is None
def test_verify_expired_token(self, user_service):
"""Verifies that an expired token returns None."""
user_service.create_user("alice", "pass")
result = user_service.create_api_token("alice", "Expiring", expires_in_days=1)
# Manually set expires_at to the past
registry = user_service._load_registry()
token_meta = list(registry["users"]["alice"]["api_tokens"].values())[0]
token_meta["expires_at"] = (
datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=1)
).isoformat()
user_service._save_registry(registry)
# Invalidate cache so verify_api_token re-reads
user_service._token_index = {}
assert user_service.verify_api_token(result["raw_token"]) is None
def test_verify_updates_last_used_at(self, user_with_token):
"""Verifies that last_used_at is updated upon successful verification."""
svc, username, result = user_with_token
# Initially last_used_at should be None
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is None
# Verify the token
svc.verify_api_token(result["raw_token"])
# Now last_used_at should be set
registry = svc._load_registry()
token_meta = list(registry["users"][username]["api_tokens"].values())[0]
assert token_meta["last_used_at"] is not None
class TestApiTokenListing:
def test_list_tokens_returns_metadata(self, user_with_token):
"""Verifies list returns metadata without sensitive data."""
svc, username, result = user_with_token
tokens = svc.list_api_tokens(username)
assert len(tokens) == 1
t = tokens[0]
assert t["token_id"] == result["token_id"]
assert t["name"] == "Test Token"
assert t["token_prefix"].startswith("cnp_pat_")
assert "created_at" in t
# Must NOT expose token_hash or raw_token
assert "token_hash" not in t
assert "raw_token" not in t
def test_list_tokens_empty(self, user_service):
"""Verifies listing tokens for a user with none returns empty list."""
user_service.create_user("alice", "pass")
assert user_service.list_api_tokens("alice") == []
def test_list_tokens_nonexistent_user(self, user_service):
"""Ensures listing tokens for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.list_api_tokens("ghost")
class TestApiTokenRevocation:
def test_revoke_token(self, user_with_token):
"""Verifies that a revoked token is immediately invalid."""
svc, username, result = user_with_token
# Token works before revocation
assert svc.verify_api_token(result["raw_token"]) == username
# Revoke
removed = svc.revoke_api_token(username, result["token_id"])
assert removed is True
# Token must fail after revocation
assert svc.verify_api_token(result["raw_token"]) is None
# List should be empty
assert svc.list_api_tokens(username) == []
def test_revoke_nonexistent_token(self, user_service):
"""Verifies revoking a non-existent token returns False."""
user_service.create_user("alice", "pass")
assert user_service.revoke_api_token("alice", "tok_nonexistent") is False
def test_revoke_nonexistent_user(self, user_service):
"""Ensures revoking a token for a non-existent user raises ValueError."""
with pytest.raises(ValueError, match="not found"):
user_service.revoke_api_token("ghost", "tok_abc")
class TestJwtUnchanged:
def test_jwt_still_works(self, user_service):
"""Confirms that existing JWT session tokens still authenticate correctly."""
user_service.create_user("jwtuser", "pass")
token = user_service.generate_jwt("jwtuser")
verified = user_service.verify_jwt(token)
assert verified == "jwtuser"
+2
View File
@@ -172,6 +172,8 @@ class RemoteStream:
}) })
if getattr(req, "copilot_action", ""): if getattr(req, "copilot_action", ""):
copilot_msg["action"] = req.copilot_action copilot_msg["action"] = req.copilot_action
if getattr(req, "copilot_node_info_json", ""):
copilot_msg["node_info_json"] = req.copilot_node_info_json
if copilot_msg: if copilot_msg:
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg) self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
+3190
View File
File diff suppressed because it is too large Load Diff
+52 -3
View File
@@ -77,7 +77,10 @@ el.replaceWith(d);
&#34;trusted_commands&#34;: self.set_ai_config, &#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode, &#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host, &#34;remote_host&#34;: self.set_remote_host,
&#34;sync_remote&#34;: self.set_sync_remote &#34;sync_remote&#34;: self.set_sync_remote,
&#34;shell_command&#34;: self.set_shell_config,
&#34;shell_prompt&#34;: self.set_shell_config,
&#34;shell_os&#34;: self.set_shell_config
} }
handler = actions.get(getattr(args, &#34;command&#34;, None)) handler = actions.get(getattr(args, &#34;command&#34;, None))
if handler: if handler:
@@ -232,7 +235,23 @@ el.replaceWith(d);
return parsed return parsed
raise ValueError() raise ValueError()
except Exception: except Exception:
raise InvalidConfigurationError(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)</code></pre> raise InvalidConfigurationError(&#34;Auth parameter must be a valid JSON/YAML string, or a path to a JSON/YAML file.&#34;)
def set_shell_config(self, args):
key = args.command.replace(&#34;shell_&#34;, &#34;&#34;)
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get(&#34;shell&#34;, {}) if isinstance(settings.get(&#34;shell&#34;), dict) else {}
if str(val).lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting(&#34;shell&#34;, shell_cfg)
printer.success(&#34;Config saved&#34;)
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -263,7 +282,10 @@ el.replaceWith(d);
&#34;trusted_commands&#34;: self.set_ai_config, &#34;trusted_commands&#34;: self.set_ai_config,
&#34;service_mode&#34;: self.set_service_mode, &#34;service_mode&#34;: self.set_service_mode,
&#34;remote_host&#34;: self.set_remote_host, &#34;remote_host&#34;: self.set_remote_host,
&#34;sync_remote&#34;: self.set_sync_remote &#34;sync_remote&#34;: self.set_sync_remote,
&#34;shell_command&#34;: self.set_shell_config,
&#34;shell_prompt&#34;: self.set_shell_config,
&#34;shell_os&#34;: self.set_shell_config
} }
handler = actions.get(getattr(args, &#34;command&#34;, None)) handler = actions.get(getattr(args, &#34;command&#34;, None))
if handler: if handler:
@@ -434,6 +456,32 @@ el.replaceWith(d);
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.cli.config_handler.ConfigHandler.set_shell_config"><code class="name flex">
<span>def <span class="ident">set_shell_config</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def set_shell_config(self, args):
key = args.command.replace(&#34;shell_&#34;, &#34;&#34;)
val = args.data[0] if isinstance(args.data, list) else args.data
try:
settings = self.app.services.config_svc.get_settings()
shell_cfg = settings.get(&#34;shell&#34;, {}) if isinstance(settings.get(&#34;shell&#34;), dict) else {}
if str(val).lower() in [&#34;none&#34;, &#34;clear&#34;, &#34;&#34;]:
if key in shell_cfg:
del shell_cfg[key]
else:
shell_cfg[key] = val
self.app.services.config_svc.update_setting(&#34;shell&#34;, shell_cfg)
printer.success(&#34;Config saved&#34;)
except (ConnpyError, InvalidConfigurationError) as e:
printer.error(str(e))</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.config_handler.ConfigHandler.set_sync_remote"><code class="name flex"> <dt id="connpy.cli.config_handler.ConfigHandler.set_sync_remote"><code class="name flex">
<span>def <span class="ident">set_sync_remote</span></span>(<span>self, args)</span> <span>def <span class="ident">set_sync_remote</span></span>(<span>self, args)</span>
</code></dt> </code></dt>
@@ -538,6 +586,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_idletime" href="#connpy.cli.config_handler.ConfigHandler.set_idletime">set_idletime</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.set_idletime" href="#connpy.cli.config_handler.ConfigHandler.set_idletime">set_idletime</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_remote_host" href="#connpy.cli.config_handler.ConfigHandler.set_remote_host">set_remote_host</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.set_remote_host" href="#connpy.cli.config_handler.ConfigHandler.set_remote_host">set_remote_host</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_service_mode" href="#connpy.cli.config_handler.ConfigHandler.set_service_mode">set_service_mode</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.set_service_mode" href="#connpy.cli.config_handler.ConfigHandler.set_service_mode">set_service_mode</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_shell_config" href="#connpy.cli.config_handler.ConfigHandler.set_shell_config">set_shell_config</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_sync_remote" href="#connpy.cli.config_handler.ConfigHandler.set_sync_remote">set_sync_remote</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.set_sync_remote" href="#connpy.cli.config_handler.ConfigHandler.set_sync_remote">set_sync_remote</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.set_theme" href="#connpy.cli.config_handler.ConfigHandler.set_theme">set_theme</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.set_theme" href="#connpy.cli.config_handler.ConfigHandler.set_theme">set_theme</a></code></li>
<li><code><a title="connpy.cli.config_handler.ConfigHandler.show_completion" href="#connpy.cli.config_handler.ConfigHandler.show_completion">show_completion</a></code></li> <li><code><a title="connpy.cli.config_handler.ConfigHandler.show_completion" href="#connpy.cli.config_handler.ConfigHandler.show_completion">show_completion</a></code></li>
+10
View File
@@ -61,6 +61,7 @@ el.replaceWith(d);
self.validators = Validators(app) self.validators = Validators(app)
def questions_edit(self): def questions_edit(self):
import inquirer
questions = [] questions = []
questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;)) questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;))
questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;)) questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;))
@@ -74,6 +75,7 @@ el.replaceWith(d);
return inquirer.prompt(questions) return inquirer.prompt(questions)
def questions_nodes(self, unique, uniques=None, edit=None): def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try: try:
defaults = self.app.services.nodes.get_node_details(unique) defaults = self.app.services.nodes.get_node_details(unique)
if &#34;tags&#34; not in defaults: if &#34;tags&#34; not in defaults:
@@ -151,6 +153,7 @@ el.replaceWith(d);
return result return result
def questions_profiles(self, unique, edit=None): def questions_profiles(self, unique, edit=None):
import inquirer
try: try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False) defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if &#34;tags&#34; not in defaults: if &#34;tags&#34; not in defaults:
@@ -216,6 +219,7 @@ el.replaceWith(d);
return result return result
def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;): def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;):
import inquirer
questions = [] questions = []
questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation)) questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation)) questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation))
@@ -253,6 +257,7 @@ el.replaceWith(d);
def mcp_wizard(self, mcp_servers): def mcp_wizard(self, mcp_servers):
&#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34;
import inquirer
from .helpers import theme from .helpers import theme
while True: while True:
@@ -345,6 +350,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def mcp_wizard(self, mcp_servers): <pre><code class="python">def mcp_wizard(self, mcp_servers):
&#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Interactive wizard to manage MCP servers.&#34;&#34;&#34;
import inquirer
from .helpers import theme from .helpers import theme
while True: while True:
@@ -435,6 +441,7 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;): <pre><code class="python">def questions_bulk(self, nodes=&#34;&#34;, hosts=&#34;&#34;):
import inquirer
questions = [] questions = []
questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation)) questions.append(inquirer.Text(&#34;ids&#34;, message=&#34;add a comma separated list of nodes to add&#34;, default=nodes, validate=self.validators.bulk_node_validation))
questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation)) questions.append(inquirer.Text(&#34;location&#34;, message=&#34;Add a @folder, @subfolder@folder or leave empty&#34;, validate=self.validators.bulk_folder_validation))
@@ -481,6 +488,7 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def questions_edit(self): <pre><code class="python">def questions_edit(self):
import inquirer
questions = [] questions = []
questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;)) questions.append(inquirer.Confirm(&#34;host&#34;, message=&#34;Edit Hostname/IP?&#34;))
questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;)) questions.append(inquirer.Confirm(&#34;protocol&#34;, message=&#34;Edit Protocol/app?&#34;))
@@ -504,6 +512,7 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def questions_nodes(self, unique, uniques=None, edit=None): <pre><code class="python">def questions_nodes(self, unique, uniques=None, edit=None):
import inquirer
try: try:
defaults = self.app.services.nodes.get_node_details(unique) defaults = self.app.services.nodes.get_node_details(unique)
if &#34;tags&#34; not in defaults: if &#34;tags&#34; not in defaults:
@@ -591,6 +600,7 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def questions_profiles(self, unique, edit=None): <pre><code class="python">def questions_profiles(self, unique, edit=None):
import inquirer
try: try:
defaults = self.app.services.profiles.get_profile(unique, resolve=False) defaults = self.app.services.profiles.get_profile(unique, resolve=False)
if &#34;tags&#34; not in defaults: if &#34;tags&#34; not in defaults:
+22 -36
View File
@@ -68,6 +68,7 @@ el.replaceWith(d);
else: else:
return answer[0] return answer[0]
else: else:
import inquirer
questions = [inquirer.List(name, message=&#34;Pick {} to {}:&#34;.format(name,action), choices=list_, carousel=True)] questions = [inquirer.List(name, message=&#34;Pick {} to {}:&#34;.format(name,action), choices=list_, carousel=True)]
answer = inquirer.prompt(questions, theme=theme) answer = inquirer.prompt(questions, theme=theme)
if answer == None: if answer == None:
@@ -125,6 +126,26 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def get_theme(): <pre><code class="python">def get_theme():
&#34;&#34;&#34;Returns a fresh instance of the theme with current colors.&#34;&#34;&#34; &#34;&#34;&#34;Returns a fresh instance of the theme with current colors.&#34;&#34;&#34;
from inquirer.themes import Default, term
class ConnpyTheme(Default):
def __init__(self):
super().__init__()
try:
from ..printer import _global_active_styles
# Use user_prompt as primary accent, fallback to info/cyan
accent = _global_active_styles.get(&#34;user_prompt&#34;, _global_active_styles.get(&#34;info&#34;, &#34;cyan&#34;))
accent_color = hex_to_blessed(accent)
self.Question.mark_color = accent_color
self.List.selection_color = accent_color
self.List.selection_cursor = &#34;&gt;&#34;
except:
# Absolute fallback to standard cyan
self.Question.mark_color = term.cyan
self.List.selection_color = term.bold_cyan
self.List.selection_cursor = &#34;&gt;&#34;
return ConnpyTheme()</code></pre> return ConnpyTheme()</code></pre>
</details> </details>
<div class="desc"><p>Returns a fresh instance of the theme with current colors.</p></div> <div class="desc"><p>Returns a fresh instance of the theme with current colors.</p></div>
@@ -139,6 +160,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def hex_to_blessed(hex_str): <pre><code class="python">def hex_to_blessed(hex_str):
&#34;&#34;&#34;Convert hex color string to blessed/ansi format.&#34;&#34;&#34; &#34;&#34;&#34;Convert hex color string to blessed/ansi format.&#34;&#34;&#34;
from inquirer.themes import term
if not hex_str or not isinstance(hex_str, str): if not hex_str or not isinstance(hex_str, str):
return term.normal return term.normal
@@ -242,39 +264,6 @@ el.replaceWith(d);
<section> <section>
<h2 class="section-title" id="header-classes">Classes</h2> <h2 class="section-title" id="header-classes">Classes</h2>
<dl> <dl>
<dt id="connpy.cli.helpers.ConnpyTheme"><code class="flex name class">
<span>class <span class="ident">ConnpyTheme</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class ConnpyTheme(Default):
def __init__(self):
super().__init__()
try:
from ..printer import _global_active_styles
# Use user_prompt as primary accent, fallback to info/cyan
accent = _global_active_styles.get(&#34;user_prompt&#34;, _global_active_styles.get(&#34;info&#34;, &#34;cyan&#34;))
accent_color = hex_to_blessed(accent)
self.Question.mark_color = accent_color
self.List.selection_color = accent_color
self.List.selection_cursor = &#34;&gt;&#34;
except:
# Absolute fallback to standard cyan
self.Question.mark_color = term.cyan
self.List.selection_color = term.bold_cyan
self.List.selection_cursor = &#34;&gt;&#34;</code></pre>
</details>
<div class="desc"></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>inquirer.themes.Default</li>
<li>inquirer.themes.Theme</li>
</ul>
</dd>
<dt id="connpy.cli.helpers.ThemeProxy"><code class="flex name class"> <dt id="connpy.cli.helpers.ThemeProxy"><code class="flex name class">
<span>class <span class="ident">ThemeProxy</span></span> <span>class <span class="ident">ThemeProxy</span></span>
</code></dt> </code></dt>
@@ -322,9 +311,6 @@ el.replaceWith(d);
<li><h3><a href="#header-classes">Classes</a></h3> <li><h3><a href="#header-classes">Classes</a></h3>
<ul> <ul>
<li> <li>
<h4><code><a title="connpy.cli.helpers.ConnpyTheme" href="#connpy.cli.helpers.ConnpyTheme">ConnpyTheme</a></code></h4>
</li>
<li>
<h4><code><a title="connpy.cli.helpers.ThemeProxy" href="#connpy.cli.helpers.ThemeProxy">ThemeProxy</a></code></h4> <h4><code><a title="connpy.cli.helpers.ThemeProxy" href="#connpy.cli.helpers.ThemeProxy">ThemeProxy</a></code></h4>
</li> </li>
</ul> </ul>
+33 -1
View File
@@ -58,12 +58,24 @@ el.replaceWith(d);
<pre><code class="python">class ImportExportHandler: <pre><code class="python">class ImportExportHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch_import(self, args): def dispatch_import(self, args):
file_path = args.data[0] file_path = args.data[0]
try: try:
printer.warning(&#34;This could overwrite your current configuration!&#34;) printer.warning(&#34;This could overwrite your current configuration!&#34;)
import inquirer
question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)] question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;import&#34;]: if confirm == None or not confirm[&#34;import&#34;]:
@@ -135,6 +147,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre> sys.exit(1)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.import_export_handler.ImportExportHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.cli.import_export_handler.ImportExportHandler.bulk"><code class="name flex"> <dt id="connpy.cli.import_export_handler.ImportExportHandler.bulk"><code class="name flex">
@@ -228,6 +258,7 @@ el.replaceWith(d);
file_path = args.data[0] file_path = args.data[0]
try: try:
printer.warning(&#34;This could overwrite your current configuration!&#34;) printer.warning(&#34;This could overwrite your current configuration!&#34;)
import inquirer
question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)] question = [inquirer.Confirm(&#34;import&#34;, message=f&#34;Are you sure you want to import {file_path}?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;import&#34;]: if confirm == None or not confirm[&#34;import&#34;]:
@@ -264,6 +295,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.bulk" href="#connpy.cli.import_export_handler.ImportExportHandler.bulk">bulk</a></code></li> <li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.bulk" href="#connpy.cli.import_export_handler.ImportExportHandler.bulk">bulk</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_export" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_export">dispatch_export</a></code></li> <li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_export" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_export">dispatch_export</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_import" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_import">dispatch_import</a></code></li> <li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.dispatch_import" href="#connpy.cli.import_export_handler.ImportExportHandler.dispatch_import">dispatch_import</a></code></li>
<li><code><a title="connpy.cli.import_export_handler.ImportExportHandler.forms" href="#connpy.cli.import_export_handler.ImportExportHandler.forms">forms</a></code></li>
</ul> </ul>
</li> </li>
</ul> </ul>
+5
View File
@@ -92,6 +92,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.cli.shell_handler" href="shell_handler.html">connpy.cli.shell_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt> <dt><code class="name"><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -146,6 +150,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li> <li><code><a title="connpy.cli.plugin_handler" href="plugin_handler.html">connpy.cli.plugin_handler</a></code></li>
<li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li> <li><code><a title="connpy.cli.profile_handler" href="profile_handler.html">connpy.cli.profile_handler</a></code></li>
<li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li> <li><code><a title="connpy.cli.run_handler" href="run_handler.html">connpy.cli.run_handler</a></code></li>
<li><code><a title="connpy.cli.shell_handler" href="shell_handler.html">connpy.cli.shell_handler</a></code></li>
<li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li> <li><code><a title="connpy.cli.sso_handler" href="sso_handler.html">connpy.cli.sso_handler</a></code></li>
<li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li> <li><code><a title="connpy.cli.sync_handler" href="sync_handler.html">connpy.cli.sync_handler</a></code></li>
<li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li> <li><code><a title="connpy.cli.terminal_ui" href="terminal_ui.html">connpy.cli.terminal_ui</a></code></li>
+211 -2
View File
@@ -70,6 +70,14 @@ el.replaceWith(d);
sys.exit(1) sys.exit(1)
def login(self, args): def login(self, args):
# Handle token management actions first
if getattr(args, &#34;create_token&#34;, None):
return self.create_token(args)
if getattr(args, &#34;list_tokens&#34;, False):
return self.list_tokens(args)
if getattr(args, &#34;revoke_token&#34;, None):
return self.revoke_token(args)
if getattr(args, &#34;status&#34;, False): if getattr(args, &#34;status&#34;, False):
return self.show_status() return self.show_status()
@@ -191,11 +199,137 @@ el.replaceWith(d);
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc) exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;) printer.info(f&#34;Expires at: {exp_dt.strftime(&#39;%Y-%m-%d %H:%M:%S UTC&#39;)}&#34;)
except Exception as e: except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</code></pre> printer.error(f&#34;Failed to check local session status: {e}&#34;)
def _get_auth_service(self):
&#34;&#34;&#34;Gets an authenticated auth service stub, reusing existing or creating one.&#34;&#34;&#34;
auth_service = getattr(self.app.services, &#34;auth&#34;, 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(&#34;remote_host&#34;)
if not remote_host:
printer.error(&#34;Remote host is not configured. Run &#39;connpy config --remote HOST:PORT&#39; first.&#34;)
sys.exit(1)
try:
# Load existing session token for authentication
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.error(&#34;No active session. Please log in first using &#39;connpy login&#39;.&#34;)
sys.exit(1)
with open(token_path, &#34;r&#34;) as f:
session_token = f.read().strip()
from ..grpc_layer.stubs import AuthClientInterceptor
interceptor = AuthClientInterceptor(lambda: session_token)
channel = grpc.intercept_channel(grpc.insecure_channel(remote_host), interceptor)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server: {e}&#34;)
sys.exit(1)
return auth_service
def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, &#34;expires_days&#34;, 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f&#34;API token &#39;{name}&#39; created successfully.&#34;)
printer.warning(&#34;⚠ Copy this token now. It will NOT be shown again:&#34;)
printer.data(&#34;Token&#34;, result[&#34;raw_token&#34;])
printer.info(f&#34;Token ID: {result[&#39;token_id&#39;]}&#34;)
if expires_days &gt; 0:
printer.info(f&#34;Expires in: {expires_days} days&#34;)
else:
printer.info(&#34;Expires: Never (permanent)&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
def list_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info(&#34;No API tokens found.&#34;)
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
&#34;token_id&#34;: t[&#34;token_id&#34;],
&#34;name&#34;: t[&#34;name&#34;],
&#34;prefix&#34;: t[&#34;token_prefix&#34;],
&#34;created&#34;: t[&#34;created_at&#34;] or &#34;N/A&#34;,
&#34;last_used&#34;: t[&#34;last_used_at&#34;] or &#34;Never&#34;,
&#34;expires&#34;: t[&#34;expires_at&#34;] or &#34;Never&#34;,
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data(&#34;API Tokens&#34;, yaml_str)
except ConnpyError as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f&#34;Token &#39;{token_id}&#39; revoked successfully.&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.cli.login_handler.LoginHandler.create_token"><code class="name flex">
<span>def <span class="ident">create_token</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_token(self, args):
auth_service = self._get_auth_service()
name = args.create_token
expires_days = getattr(args, &#34;expires_days&#34;, 0) or 0
try:
result = auth_service.create_api_token(name, expires_in_days=expires_days)
printer.success(f&#34;API token &#39;{name}&#39; created successfully.&#34;)
printer.warning(&#34;⚠ Copy this token now. It will NOT be shown again:&#34;)
printer.data(&#34;Token&#34;, result[&#34;raw_token&#34;])
printer.info(f&#34;Token ID: {result[&#39;token_id&#39;]}&#34;)
if expires_days &gt; 0:
printer.info(f&#34;Expires in: {expires_days} days&#34;)
else:
printer.info(&#34;Expires: Never (permanent)&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create token: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.dispatch"><code class="name flex"> <dt id="connpy.cli.login_handler.LoginHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span> <span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt> </code></dt>
@@ -216,6 +350,46 @@ el.replaceWith(d);
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.cli.login_handler.LoginHandler.list_tokens"><code class="name flex">
<span>def <span class="ident">list_tokens</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_tokens(self, args):
auth_service = self._get_auth_service()
try:
tokens = auth_service.list_api_tokens()
if not tokens:
printer.info(&#34;No API tokens found.&#34;)
return
import yaml
# Clean up empty strings from protobuf defaults
cleaned = []
for t in tokens:
cleaned.append({
&#34;token_id&#34;: t[&#34;token_id&#34;],
&#34;name&#34;: t[&#34;name&#34;],
&#34;prefix&#34;: t[&#34;token_prefix&#34;],
&#34;created&#34;: t[&#34;created_at&#34;] or &#34;N/A&#34;,
&#34;last_used&#34;: t[&#34;last_used_at&#34;] or &#34;Never&#34;,
&#34;expires&#34;: t[&#34;expires_at&#34;] or &#34;Never&#34;,
})
yaml_str = yaml.dump(cleaned, sort_keys=False, default_flow_style=False)
printer.data(&#34;API Tokens&#34;, yaml_str)
except ConnpyError as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to list tokens: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex"> <dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, args)</span> <span>def <span class="ident">login</span></span>(<span>self, args)</span>
</code></dt> </code></dt>
@@ -225,6 +399,14 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def login(self, args): <pre><code class="python">def login(self, args):
# Handle token management actions first
if getattr(args, &#34;create_token&#34;, None):
return self.create_token(args)
if getattr(args, &#34;list_tokens&#34;, False):
return self.list_tokens(args)
if getattr(args, &#34;revoke_token&#34;, None):
return self.revoke_token(args)
if getattr(args, &#34;status&#34;, False): if getattr(args, &#34;status&#34;, False):
return self.show_status() return self.show_status()
@@ -312,6 +494,30 @@ el.replaceWith(d);
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.cli.login_handler.LoginHandler.revoke_token"><code class="name flex">
<span>def <span class="ident">revoke_token</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def revoke_token(self, args):
auth_service = self._get_auth_service()
token_id = args.revoke_token
try:
auth_service.revoke_api_token(token_id)
printer.success(f&#34;Token &#39;{token_id}&#39; revoked successfully.&#34;)
except ConnpyError as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to revoke token: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.show_status"><code class="name flex"> <dt id="connpy.cli.login_handler.LoginHandler.show_status"><code class="name flex">
<span>def <span class="ident">show_status</span></span>(<span>self)</span> <span>def <span class="ident">show_status</span></span>(<span>self)</span>
</code></dt> </code></dt>
@@ -389,10 +595,13 @@ el.replaceWith(d);
<ul> <ul>
<li> <li>
<h4><code><a title="connpy.cli.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4> <h4><code><a title="connpy.cli.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4>
<ul class=""> <ul class="two-column">
<li><code><a title="connpy.cli.login_handler.LoginHandler.create_token" href="#connpy.cli.login_handler.LoginHandler.create_token">create_token</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.dispatch" href="#connpy.cli.login_handler.LoginHandler.dispatch">dispatch</a></code></li> <li><code><a title="connpy.cli.login_handler.LoginHandler.dispatch" href="#connpy.cli.login_handler.LoginHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.list_tokens" href="#connpy.cli.login_handler.LoginHandler.list_tokens">list_tokens</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.login" href="#connpy.cli.login_handler.LoginHandler.login">login</a></code></li> <li><code><a title="connpy.cli.login_handler.LoginHandler.login" href="#connpy.cli.login_handler.LoginHandler.login">login</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.logout" href="#connpy.cli.login_handler.LoginHandler.logout">logout</a></code></li> <li><code><a title="connpy.cli.login_handler.LoginHandler.logout" href="#connpy.cli.login_handler.LoginHandler.logout">logout</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.revoke_token" href="#connpy.cli.login_handler.LoginHandler.revoke_token">revoke_token</a></code></li>
<li><code><a title="connpy.cli.login_handler.LoginHandler.show_status" href="#connpy.cli.login_handler.LoginHandler.show_status">show_status</a></code></li> <li><code><a title="connpy.cli.login_handler.LoginHandler.show_status" href="#connpy.cli.login_handler.LoginHandler.show_status">show_status</a></code></li>
</ul> </ul>
</li> </li>
+35 -3
View File
@@ -58,7 +58,18 @@ el.replaceWith(d);
<pre><code class="python">class NodeHandler: <pre><code class="python">class NodeHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def _filter_exact_match(self, matches, query): def _filter_exact_match(self, matches, query):
if not query or len(matches) &lt;= 1: if not query or len(matches) &lt;= 1:
@@ -122,7 +133,7 @@ el.replaceWith(d);
debug=args.debug, debug=args.debug,
logger=self.app._service_logger logger=self.app._service_logger
) )
except ConnpyError as e: except (ConnpyError, ValueError) as e:
printer.error(str(e)) printer.error(str(e))
sys.exit(1) sys.exit(1)
@@ -146,6 +157,7 @@ el.replaceWith(d);
sys.exit(2) sys.exit(2)
printer.info(f&#34;Removing: {matches}&#34;) printer.info(f&#34;Removing: {matches}&#34;)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)] question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]: if confirm == None or not confirm[&#34;delete&#34;]:
@@ -306,6 +318,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre> sys.exit(1)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.node_handler.NodeHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.cli.node_handler.NodeHandler.add"><code class="name flex"> <dt id="connpy.cli.node_handler.NodeHandler.add"><code class="name flex">
@@ -400,7 +430,7 @@ el.replaceWith(d);
debug=args.debug, debug=args.debug,
logger=self.app._service_logger logger=self.app._service_logger
) )
except ConnpyError as e: except (ConnpyError, ValueError) as e:
printer.error(str(e)) printer.error(str(e))
sys.exit(1)</code></pre> sys.exit(1)</code></pre>
</details> </details>
@@ -434,6 +464,7 @@ el.replaceWith(d);
sys.exit(2) sys.exit(2)
printer.info(f&#34;Removing: {matches}&#34;) printer.info(f&#34;Removing: {matches}&#34;)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)] question = [inquirer.Confirm(&#34;delete&#34;, message=&#34;Are you sure you want to continue?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]: if confirm == None or not confirm[&#34;delete&#34;]:
@@ -630,6 +661,7 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.node_handler.NodeHandler.connect" href="#connpy.cli.node_handler.NodeHandler.connect">connect</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.connect" href="#connpy.cli.node_handler.NodeHandler.connect">connect</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.delete" href="#connpy.cli.node_handler.NodeHandler.delete">delete</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.delete" href="#connpy.cli.node_handler.NodeHandler.delete">delete</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.dispatch" href="#connpy.cli.node_handler.NodeHandler.dispatch">dispatch</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.dispatch" href="#connpy.cli.node_handler.NodeHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.forms" href="#connpy.cli.node_handler.NodeHandler.forms">forms</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.modify" href="#connpy.cli.node_handler.NodeHandler.modify">modify</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.modify" href="#connpy.cli.node_handler.NodeHandler.modify">modify</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.show" href="#connpy.cli.node_handler.NodeHandler.show">show</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.show" href="#connpy.cli.node_handler.NodeHandler.show">show</a></code></li>
<li><code><a title="connpy.cli.node_handler.NodeHandler.version" href="#connpy.cli.node_handler.NodeHandler.version">version</a></code></li> <li><code><a title="connpy.cli.node_handler.NodeHandler.version" href="#connpy.cli.node_handler.NodeHandler.version">version</a></code></li>
+16 -4
View File
@@ -167,6 +167,8 @@ el.replaceWith(d);
# Populate local plugins # Populate local plugins
for name, details in local_plugins.items(): for name, details in local_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34; state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34; color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -175,11 +177,14 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Remote)&#34; state = &#34;Shadowed (Override by Remote)&#34;
color = &#34;yellow&#34; color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Local&#34;) origin = details.get(&#34;origin&#34;, &#34;Local&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
# Populate remote plugins # Populate remote plugins
if self.app.services.mode == &#34;remote&#34;: if self.app.services.mode == &#34;remote&#34;:
for name, details in remote_plugins.items(): for name, details in remote_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34; state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34; color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -190,7 +195,8 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Local)&#34; state = &#34;Shadowed (Override by Local)&#34;
color = &#34;yellow&#34; color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Remote&#34;) origin = details.get(&#34;origin&#34;, &#34;Remote&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
if not local_plugins and not remote_plugins: if not local_plugins and not remote_plugins:
printer.console.print(&#34; No plugins found.&#34;) printer.console.print(&#34; No plugins found.&#34;)
@@ -320,6 +326,8 @@ el.replaceWith(d);
# Populate local plugins # Populate local plugins
for name, details in local_plugins.items(): for name, details in local_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34; state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34; color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -328,11 +336,14 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Remote)&#34; state = &#34;Shadowed (Override by Remote)&#34;
color = &#34;yellow&#34; color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Local&#34;) origin = details.get(&#34;origin&#34;, &#34;Local&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
# Populate remote plugins # Populate remote plugins
if self.app.services.mode == &#34;remote&#34;: if self.app.services.mode == &#34;remote&#34;:
for name, details in remote_plugins.items(): for name, details in remote_plugins.items():
if details.get(&#34;origin&#34;) == &#34;core&#34;:
continue
state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34; state = &#34;Disabled&#34; if not details.get(&#34;enabled&#34;, True) else &#34;Active&#34;
color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34; color = &#34;red&#34; if state == &#34;Disabled&#34; else &#34;green&#34;
@@ -343,7 +354,8 @@ el.replaceWith(d);
state = &#34;Shadowed (Override by Local)&#34; state = &#34;Shadowed (Override by Local)&#34;
color = &#34;yellow&#34; color = &#34;yellow&#34;
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, &#34;Remote&#34;) origin = details.get(&#34;origin&#34;, &#34;Remote&#34;).capitalize()
table.add_row(name, f&#34;[{color}]{state}[/{color}]&#34;, origin)
if not local_plugins and not remote_plugins: if not local_plugins and not remote_plugins:
printer.console.print(&#34; No plugins found.&#34;) printer.console.print(&#34; No plugins found.&#34;)
+34 -2
View File
@@ -58,7 +58,18 @@ el.replaceWith(d);
<pre><code class="python">class ProfileHandler: <pre><code class="python">class ProfileHandler:
def __init__(self, app): def __init__(self, app):
self.app = app self.app = app
self.forms = Forms(app) self._forms = None
@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms
@forms.setter
def forms(self, value):
self._forms = value
def dispatch(self, args): def dispatch(self, args):
if not self.app.case: if not self.app.case:
@@ -78,6 +89,7 @@ el.replaceWith(d);
printer.error(&#34;Can&#39;t delete default profile&#34;) printer.error(&#34;Can&#39;t delete default profile&#34;)
sys.exit(6) sys.exit(6)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)] question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]: if confirm == None or not confirm[&#34;delete&#34;]:
@@ -145,6 +157,24 @@ el.replaceWith(d);
sys.exit(1)</code></pre> sys.exit(1)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.cli.profile_handler.ProfileHandler.forms"><code class="name">prop <span class="ident">forms</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def forms(self):
if self._forms is None:
from .forms import Forms
self._forms = Forms(self.app)
return self._forms</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
<dt id="connpy.cli.profile_handler.ProfileHandler.add"><code class="name flex"> <dt id="connpy.cli.profile_handler.ProfileHandler.add"><code class="name flex">
@@ -194,6 +224,7 @@ el.replaceWith(d);
printer.error(&#34;Can&#39;t delete default profile&#34;) printer.error(&#34;Can&#39;t delete default profile&#34;)
sys.exit(6) sys.exit(6)
import inquirer
question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)] question = [inquirer.Confirm(&#34;delete&#34;, message=f&#34;Are you sure you want to delete {name}?&#34;)]
confirm = inquirer.prompt(question) confirm = inquirer.prompt(question)
if confirm == None or not confirm[&#34;delete&#34;]: if confirm == None or not confirm[&#34;delete&#34;]:
@@ -300,10 +331,11 @@ el.replaceWith(d);
<ul> <ul>
<li> <li>
<h4><code><a title="connpy.cli.profile_handler.ProfileHandler" href="#connpy.cli.profile_handler.ProfileHandler">ProfileHandler</a></code></h4> <h4><code><a title="connpy.cli.profile_handler.ProfileHandler" href="#connpy.cli.profile_handler.ProfileHandler">ProfileHandler</a></code></h4>
<ul class=""> <ul class="two-column">
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.add" href="#connpy.cli.profile_handler.ProfileHandler.add">add</a></code></li> <li><code><a title="connpy.cli.profile_handler.ProfileHandler.add" href="#connpy.cli.profile_handler.ProfileHandler.add">add</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.delete" href="#connpy.cli.profile_handler.ProfileHandler.delete">delete</a></code></li> <li><code><a title="connpy.cli.profile_handler.ProfileHandler.delete" href="#connpy.cli.profile_handler.ProfileHandler.delete">delete</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.dispatch" href="#connpy.cli.profile_handler.ProfileHandler.dispatch">dispatch</a></code></li> <li><code><a title="connpy.cli.profile_handler.ProfileHandler.dispatch" href="#connpy.cli.profile_handler.ProfileHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.forms" href="#connpy.cli.profile_handler.ProfileHandler.forms">forms</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.modify" href="#connpy.cli.profile_handler.ProfileHandler.modify">modify</a></code></li> <li><code><a title="connpy.cli.profile_handler.ProfileHandler.modify" href="#connpy.cli.profile_handler.ProfileHandler.modify">modify</a></code></li>
<li><code><a title="connpy.cli.profile_handler.ProfileHandler.show" href="#connpy.cli.profile_handler.ProfileHandler.show">show</a></code></li> <li><code><a title="connpy.cli.profile_handler.ProfileHandler.show" href="#connpy.cli.profile_handler.ProfileHandler.show">show</a></code></li>
</ul> </ul>
+68 -4
View File
@@ -427,6 +427,35 @@ el.replaceWith(d);
from rich.rule import Rule from rich.rule import Rule
from rich.panel import Panel from rich.panel import Panel
from rich.syntax import Syntax from rich.syntax import Syntax
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
# Helper to get active theme color
def get_theme_color(style_name, fallback=&#34;white&#34;):
try:
style = printer.connpy_theme.styles.get(style_name)
if style and style.color:
if style.color.is_default: return fallback
return style.color.triplet.hex if style.color.triplet else style.color.name
except: pass
return fallback
user_color = get_theme_color(&#34;user_prompt&#34;, &#34;#00afd7&#34;)
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
kb = KeyBindings()
@kb.add(&#39;enter&#39;)
def _(event):
event.current_buffer.validate_and_handle()
@kb.add(&#39;c-j&#39;)
@kb.add(&#39;escape&#39;, &#39;enter&#39;)
def _(event):
event.current_buffer.insert_text(&#39;\n&#39;)
session = PromptSession(key_bindings=kb)
dest_file = args.data[0] dest_file = args.data[0]
if os.path.exists(dest_file): if os.path.exists(dest_file):
@@ -438,12 +467,15 @@ el.replaceWith(d);
# Consistent layout opening matching global AI (engineer style) # Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;)) printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;)) printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;)) printer.console.print(Rule(style=&#34;engineer&#34;))
while True: while True:
try: try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;) user_prompt = session.prompt(
HTML(f&#39;&lt;style fg=&#34;{user_color}&#34;&gt;User (Enter to submit, Ctrl+Enter for newline):&lt;/style&gt;\n&#39;),
multiline=True
)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
printer.console.print() printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;) printer.warning(&#34;Operation cancelled by user.&#34;)
@@ -547,6 +579,35 @@ el.replaceWith(d);
from rich.rule import Rule from rich.rule import Rule
from rich.panel import Panel from rich.panel import Panel
from rich.syntax import Syntax from rich.syntax import Syntax
from prompt_toolkit import PromptSession
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.key_binding import KeyBindings
# Helper to get active theme color
def get_theme_color(style_name, fallback=&#34;white&#34;):
try:
style = printer.connpy_theme.styles.get(style_name)
if style and style.color:
if style.color.is_default: return fallback
return style.color.triplet.hex if style.color.triplet else style.color.name
except: pass
return fallback
user_color = get_theme_color(&#34;user_prompt&#34;, &#34;#00afd7&#34;)
# Configure multiline key bindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter for newlines
kb = KeyBindings()
@kb.add(&#39;enter&#39;)
def _(event):
event.current_buffer.validate_and_handle()
@kb.add(&#39;c-j&#39;)
@kb.add(&#39;escape&#39;, &#39;enter&#39;)
def _(event):
event.current_buffer.insert_text(&#39;\n&#39;)
session = PromptSession(key_bindings=kb)
dest_file = args.data[0] dest_file = args.data[0]
if os.path.exists(dest_file): if os.path.exists(dest_file):
@@ -558,12 +619,15 @@ el.replaceWith(d);
# Consistent layout opening matching global AI (engineer style) # Consistent layout opening matching global AI (engineer style)
from rich.markdown import Markdown from rich.markdown import Markdown
printer.console.print(Rule(style=&#34;engineer&#34;)) printer.console.print(Rule(style=&#34;engineer&#34;))
printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n&#34;)) printer.console.print(Markdown(&#34;**Playbook Builder AI**: Welcome! Describe the automation workflow you want to design.\nType **exit** to quit.\n*Press Enter to submit, or Ctrl+Enter (Alt+Enter) to add a new line.*\n&#34;))
printer.console.print(Rule(style=&#34;engineer&#34;)) printer.console.print(Rule(style=&#34;engineer&#34;))
while True: while True:
try: try:
user_prompt = Prompt.ask(&#34;[user_prompt]User[/user_prompt]&#34;) user_prompt = session.prompt(
HTML(f&#39;&lt;style fg=&#34;{user_color}&#34;&gt;User (Enter to submit, Ctrl+Enter for newline):&lt;/style&gt;\n&#39;),
multiline=True
)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
printer.console.print() printer.console.print()
printer.warning(&#34;Operation cancelled by user.&#34;) printer.warning(&#34;Operation cancelled by user.&#34;)
+187
View File
@@ -0,0 +1,187 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.shell_handler API documentation</title>
<meta name="description" content="">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/10up-sanitize.css/13.0.0/typography.min.css" integrity="sha512-Y1DYSb995BAfxobCkKepB1BqJJTPrOp3zPL74AWFugHHmmdcvO+C48WLrUOlhGMc0QG7AE3f7gmvvcrmX2fDoA==" crossorigin>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/default.min.css" crossorigin>
<style>:root{--highlight-color:#fe9}.flex{display:flex !important}body{line-height:1.5em}#content{padding:20px}#sidebar{padding:1.5em;overflow:hidden}#sidebar > *:last-child{margin-bottom:2cm}.http-server-breadcrumbs{font-size:130%;margin:0 0 15px 0}#footer{font-size:.75em;padding:5px 30px;border-top:1px solid #ddd;text-align:right}#footer p{margin:0 0 0 1em;display:inline-block}#footer p:last-child{margin-right:30px}h1,h2,h3,h4,h5{font-weight:300}h1{font-size:2.5em;line-height:1.1em}h2{font-size:1.75em;margin:2em 0 .50em 0}h3{font-size:1.4em;margin:1.6em 0 .7em 0}h4{margin:0;font-size:105%}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{background:var(--highlight-color);padding:.2em 0}a{color:#058;text-decoration:none;transition:color .2s ease-in-out}a:visited{color:#503}a:hover{color:#b62}.title code{font-weight:bold}h2[id^="header-"]{margin-top:2em}.ident{color:#900;font-weight:bold}pre code{font-size:.8em;line-height:1.4em;padding:1em;display:block}code{background:#f3f3f3;font-family:"DejaVu Sans Mono",monospace;padding:1px 4px;overflow-wrap:break-word}h1 code{background:transparent}pre{border-top:1px solid #ccc;border-bottom:1px solid #ccc;margin:1em 0}#http-server-module-list{display:flex;flex-flow:column}#http-server-module-list div{display:flex}#http-server-module-list dt{min-width:10%}#http-server-module-list p{margin-top:0}.toc ul,#index{list-style-type:none;margin:0;padding:0}#index code{background:transparent}#index h3{border-bottom:1px solid #ddd}#index ul{padding:0}#index h4{margin-top:.6em;font-weight:bold}@media (min-width:200ex){#index .two-column{column-count:2}}@media (min-width:300ex){#index .two-column{column-count:3}}dl{margin-bottom:2em}dl dl:last-child{margin-bottom:4em}dd{margin:0 0 1em 3em}#header-classes + dl > dd{margin-bottom:3em}dd dd{margin-left:2em}dd p{margin:10px 0}.name{background:#eee;font-size:.85em;padding:5px 10px;display:inline-block;min-width:40%}.name:hover{background:#e0e0e0}dt:target .name{background:var(--highlight-color)}.name > span:first-child{white-space:nowrap}.name.class > span:nth-child(2){margin-left:.4em}.inherited{color:#999;border-left:5px solid #eee;padding-left:1em}.inheritance em{font-style:normal;font-weight:bold}.desc h2{font-weight:400;font-size:1.25em}.desc h3{font-size:1em}.desc dt code{background:inherit}.source > summary,.git-link-div{color:#666;text-align:right;font-weight:400;font-size:.8em;text-transform:uppercase}.source summary > *{white-space:nowrap;cursor:pointer}.git-link{color:inherit;margin-left:1em}.source pre{max-height:500px;overflow:auto;margin:0}.source pre code{font-size:12px;overflow:visible;min-width:max-content}.hlist{list-style:none}.hlist li{display:inline}.hlist li:after{content:',\2002'}.hlist li:last-child:after{content:none}.hlist .hlist{display:inline;padding-left:1em}img{max-width:100%}td{padding:0 .5em}.admonition{padding:.1em 1em;margin:1em 0}.admonition-title{font-weight:bold}.admonition.note,.admonition.info,.admonition.important{background:#aef}.admonition.todo,.admonition.versionadded,.admonition.tip,.admonition.hint{background:#dfd}.admonition.warning,.admonition.versionchanged,.admonition.deprecated{background:#fd4}.admonition.error,.admonition.danger,.admonition.caution{background:lightpink}</style>
<style media="screen and (min-width: 700px)">@media screen and (min-width:700px){#sidebar{width:30%;height:100vh;overflow:auto;position:sticky;top:0}#content{width:70%;max-width:100ch;padding:3em 4em;border-left:1px solid #ddd}pre code{font-size:1em}.name{font-size:1em}main{display:flex;flex-direction:row-reverse;justify-content:flex-end}.toc ul ul,#index ul ul{padding-left:1em}.toc > ul > li{margin-top:.5em}}</style>
<style media="print">@media print{#sidebar h1{page-break-before:always}.source{display:none}}@media print{*{background:transparent !important;color:#000 !important;box-shadow:none !important;text-shadow:none !important}a[href]:after{content:" (" attr(href) ")";font-size:90%}a[href][title]:after{content:none}abbr[title]:after{content:" (" attr(title) ")"}.ir a:after,a[href^="javascript:"]:after,a[href^="#"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100% !important}@page{margin:0.5cm}p,h2,h3{orphans:3;widows:3}h1,h2,h3,h4,h5,h6{page-break-after:avoid}}</style>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js" integrity="sha512-D9gUyxqja7hBtkWpPWGt9wfbfaMGVt9gnyCvYa+jojwwPHLCzUm5i8rpk7vD7wNee9bA35eYIjobYPaQuKS1MQ==" crossorigin></script>
<script>window.addEventListener('DOMContentLoaded', () => {
hljs.configure({languages: ['bash', 'css', 'diff', 'graphql', 'ini', 'javascript', 'json', 'plaintext', 'python', 'python-repl', 'rust', 'shell', 'sql', 'typescript', 'xml', 'yaml']});
hljs.highlightAll();
/* Collapse source docstrings */
setTimeout(() => {
[...document.querySelectorAll('.hljs.language-python > .hljs-string')]
.filter(el => el.innerHTML.length > 200 && ['"""', "'''"].includes(el.innerHTML.substring(0, 3)))
.forEach(el => {
let d = document.createElement('details');
d.classList.add('hljs-string');
d.innerHTML = '<summary>"""</summary>' + el.innerHTML.substring(3);
el.replaceWith(d);
});
}, 100);
})</script>
</head>
<body>
<main>
<article id="content">
<header>
<h1 class="title">Module <code>connpy.cli.shell_handler</code></h1>
</header>
<section id="section-intro">
</section>
<section>
</section>
<section>
</section>
<section>
</section>
<section>
<h2 class="section-title" id="header-classes">Classes</h2>
<dl>
<dt id="connpy.cli.shell_handler.ShellHandler"><code class="flex name class">
<span>class <span class="ident">ShellHandler</span></span>
<span>(</span><span>app)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class ShellHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
shell_config = self.app.config.config.get(&#34;shell&#34;, {}) if hasattr(self.app.config, &#34;config&#34;) else {}
command = getattr(args, &#39;command_override&#39;, None) or shell_config.get(&#34;command&#34;) or os.environ.get(&#34;SHELL&#34;, &#34;/bin/bash&#34;)
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f&#34;Shell command executable not found: {exe}&#34;)
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
&#34;os&#34;: node_info[&#34;os&#34;],
&#34;prompt&#34;: node_info[&#34;prompt&#34;]
}
n = node(
unique=node_info[&#34;name&#34;],
host=command,
protocol=&#34;local&#34;,
config=self.app.config,
tags=tags
)
capture_file = getattr(args, &#39;capture_file&#39;, None)
if capture_file:
n.logs = capture_file
elif shell_config.get(&#34;logging&#34;):
n.logs = shell_config.get(&#34;log_path&#34;, os.path.expanduser(&#34;~/.config/conn/shell_logs/session.log&#34;))
n.interact(debug=getattr(args, &#39;debug&#39;, False))
def _build_local_identity(self, shell_config):
return {
&#34;name&#34;: &#34;local-shell&#34;,
&#34;host&#34;: socket.gethostname(),
&#34;os&#34;: shell_config.get(&#34;os&#34;, &#34;linux&#34;),
&#34;prompt&#34;: shell_config.get(&#34;prompt&#34;, r&#39;\$\s*$|#\s*$&#39;)
}</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.shell_handler.ShellHandler.dispatch"><code class="name flex">
<span>def <span class="ident">dispatch</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def dispatch(self, args):
shell_config = self.app.config.config.get(&#34;shell&#34;, {}) if hasattr(self.app.config, &#34;config&#34;) else {}
command = getattr(args, &#39;command_override&#39;, None) or shell_config.get(&#34;command&#34;) or os.environ.get(&#34;SHELL&#34;, &#34;/bin/bash&#34;)
try:
exe = shlex.split(command)[0]
except Exception:
exe = command
if not shutil.which(exe):
printer.error(f&#34;Shell command executable not found: {exe}&#34;)
sys.exit(1)
node_info = self._build_local_identity(shell_config)
tags = {
&#34;os&#34;: node_info[&#34;os&#34;],
&#34;prompt&#34;: node_info[&#34;prompt&#34;]
}
n = node(
unique=node_info[&#34;name&#34;],
host=command,
protocol=&#34;local&#34;,
config=self.app.config,
tags=tags
)
capture_file = getattr(args, &#39;capture_file&#39;, None)
if capture_file:
n.logs = capture_file
elif shell_config.get(&#34;logging&#34;):
n.logs = shell_config.get(&#34;log_path&#34;, os.path.expanduser(&#34;~/.config/conn/shell_logs/session.log&#34;))
n.interact(debug=getattr(args, &#39;debug&#39;, False))</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd>
</dl>
</section>
</article>
<nav id="sidebar">
<div class="toc">
<ul></ul>
</div>
<ul id="index">
<li><h3>Super-module</h3>
<ul>
<li><code><a title="connpy.cli" href="index.html">connpy.cli</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.cli.shell_handler.ShellHandler" href="#connpy.cli.shell_handler.ShellHandler">ShellHandler</a></code></h4>
<ul class="">
<li><code><a title="connpy.cli.shell_handler.ShellHandler.dispatch" href="#connpy.cli.shell_handler.ShellHandler.dispatch">dispatch</a></code></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
</main>
<footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.5</a>.</p>
</footer>
</body>
</html>
+4
View File
@@ -92,6 +92,7 @@ el.replaceWith(d);
sys.exit(1) sys.exit(1)
def add_provider(self, args): def add_provider(self, args):
import inquirer
provider = args.provider provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {}) sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {}) providers = sso.setdefault(&#34;providers&#34;, {})
@@ -165,6 +166,7 @@ el.replaceWith(d);
sys.exit(1) sys.exit(1)
# Confirm delete # Confirm delete
import inquirer
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)] questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions) answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]: if not answers or not answers[&#34;confirm&#34;]:
@@ -225,6 +227,7 @@ el.replaceWith(d);
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def add_provider(self, args): <pre><code class="python">def add_provider(self, args):
import inquirer
provider = args.provider provider = args.provider
sso = self.app.config.config.get(&#34;sso&#34;, {}) sso = self.app.config.config.get(&#34;sso&#34;, {})
providers = sso.setdefault(&#34;providers&#34;, {}) providers = sso.setdefault(&#34;providers&#34;, {})
@@ -308,6 +311,7 @@ el.replaceWith(d);
sys.exit(1) sys.exit(1)
# Confirm delete # Confirm delete
import inquirer
questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)] questions = [inquirer.Confirm(&#34;confirm&#34;, message=f&#34;Are you sure you want to delete SSO Provider &#39;{provider}&#39;?&#34;, default=False)]
answers = inquirer.prompt(questions) answers = inquirer.prompt(questions)
if not answers or not answers[&#34;confirm&#34;]: if not answers or not answers[&#34;confirm&#34;]:
+140 -24
View File
@@ -57,25 +57,39 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">class CopilotInterface: <pre><code class="python">class CopilotInterface:
def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None): def __init__(self, config, history=None, pt_input=None, pt_output=None, rich_file=None, session_state=None):
from ..services.ai_service import AIService
self.config = config self.config = config
self.history = history or InMemoryHistory() self.history = history or InMemoryHistory()
self.pt_input = pt_input self.pt_input = pt_input
self.pt_output = pt_output self.pt_output = pt_output
self.rich_file = rich_file
self.ai_service = AIService(config) self.ai_service = AIService(config)
self.session_state = session_state if session_state is not None else { self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2
&#39;persona&#39;: &#39;engineer&#39;,
&#39;trust_mode&#39;: False, self.session_state = session_state if session_state is not None else {}
&#39;memories&#39;: [], self.session_state.setdefault(&#39;persona&#39;, &#39;engineer&#39;)
&#39;os&#39;: None, self.session_state.setdefault(&#39;trust_mode&#39;, False)
&#39;prompt&#39;: None self.session_state.setdefault(&#39;memories&#39;, [])
} self.session_state.setdefault(&#39;os&#39;, None)
self.session_state.setdefault(&#39;prompt&#39;, None)
self.session_state.setdefault(&#39;context_mode&#39;, self.mode_range)
self.session_state.setdefault(&#39;context_cmd&#39;, 1)
self.session_state.setdefault(&#39;context_lines&#39;, 50)
self.session_state.setdefault(&#39;last_total_cmds&#39;, None)
self.session_state.setdefault(&#39;last_total_lines&#39;, None)
if rich_file: if rich_file:
self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file) self.console = Console(theme=connpy_theme, force_terminal=True, file=rich_file)
else: else:
self.console = Console(theme=connpy_theme) self.console = Console(theme=connpy_theme)
self.mode_range, self.mode_single, self.mode_lines = 0, 1, 2 def _sync_session_context(self, state: dict):
&#34;&#34;&#34;Persist current context mode, depth, total commands, and total lines into session_state.&#34;&#34;&#34;
self.session_state[&#39;context_mode&#39;] = state[&#39;context_mode&#39;]
self.session_state[&#39;context_cmd&#39;] = state[&#39;context_cmd&#39;]
self.session_state[&#39;context_lines&#39;] = state[&#39;context_lines&#39;]
self.session_state[&#39;last_total_cmds&#39;] = state[&#39;total_cmds&#39;]
self.session_state[&#39;last_total_lines&#39;] = state[&#39;total_lines&#39;]
def _get_theme_color(self, style_name: str, fallback: str = &#34;white&#34;) -&gt; str: def _get_theme_color(self, style_name: str, fallback: str = &#34;white&#34;) -&gt; str:
&#34;&#34;&#34;Extract Hex or ANSI color name from the active rich theme.&#34;&#34;&#34; &#34;&#34;&#34;Extract Hex or ANSI color name from the active rich theme.&#34;&#34;&#34;
@@ -109,16 +123,52 @@ el.replaceWith(d);
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34; last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
total_cmds = len(blocks)
total_lines = len(buffer.split(&#39;\n&#39;))
saved_mode = self.session_state.get(&#39;context_mode&#39;, self.mode_range)
saved_cmd = self.session_state.get(&#39;context_cmd&#39;, 1)
saved_lines = self.session_state.get(&#39;context_lines&#39;, min(50, total_lines))
last_total_cmds = self.session_state.get(&#39;last_total_cmds&#39;, None)
last_total_lines = self.session_state.get(&#39;last_total_lines&#39;, None)
is_range = saved_mode in (self.mode_range, 0, &#39;RANGE&#39;, &#39;range&#39;)
is_lines = saved_mode in (self.mode_lines, 2, &#39;LINES&#39;, &#39;lines&#39;)
is_single = saved_mode in (self.mode_single, 1, &#39;SINGLE&#39;, &#39;single&#39;)
if is_range or is_single:
if last_total_cmds is not None and total_cmds &gt; last_total_cmds and saved_cmd &gt; 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines &gt; last_total_lines and saved_lines &gt; 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
state = { state = {
&#39;context_cmd&#39;: 1, &#39;context_cmd&#39;: min(max(1, initial_cmd), max(1, total_cmds)),
&#39;total_cmds&#39;: len(blocks), &#39;total_cmds&#39;: total_cmds,
&#39;total_lines&#39;: len(buffer.split(&#39;\n&#39;)), &#39;total_lines&#39;: total_lines,
&#39;context_lines&#39;: min(50, len(buffer.split(&#39;\n&#39;))), &#39;context_lines&#39;: min(max(1, initial_lines), max(1, total_lines)),
&#39;context_mode&#39;: self.mode_range, &#39;context_mode&#39;: saved_mode,
&#39;cancelled&#39;: False, &#39;cancelled&#39;: False,
&#39;toolbar_msg&#39;: &#39;&#39;, &#39;toolbar_msg&#39;: &#39;&#39;,
&#39;msg_expiry&#39;: 0 &#39;msg_expiry&#39;: 0
} }
self.session_state[&#39;context_mode&#39;] = saved_mode
self.session_state[&#39;context_cmd&#39;] = max(1, initial_cmd)
self.session_state[&#39;context_lines&#39;] = max(1, initial_lines)
self.session_state[&#39;last_total_cmds&#39;] = total_cmds
self.session_state[&#39;last_total_lines&#39;] = total_lines
# 1. Visual Separation # 1. Visual Separation
self.console.print(&#34;&#34;) # Real line break self.console.print(&#34;&#34;) # Real line break
@@ -137,6 +187,7 @@ el.replaceWith(d);
state[&#39;context_lines&#39;] = min(state[&#39;context_lines&#39;] + 50, state[&#39;total_lines&#39;]) state[&#39;context_lines&#39;] = min(state[&#39;context_lines&#39;] + 50, state[&#39;total_lines&#39;])
else: else:
state[&#39;context_cmd&#39;] = min(state[&#39;context_cmd&#39;] + 1, state[&#39;total_cmds&#39;]) state[&#39;context_cmd&#39;] = min(state[&#39;context_cmd&#39;] + 1, state[&#39;total_cmds&#39;])
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;c-down&#39;) @bindings.add(&#39;c-down&#39;)
def _(event): def _(event):
@@ -144,6 +195,7 @@ el.replaceWith(d);
state[&#39;context_lines&#39;] = max(state[&#39;context_lines&#39;] - 50, min(50, state[&#39;total_lines&#39;])) state[&#39;context_lines&#39;] = max(state[&#39;context_lines&#39;] - 50, min(50, state[&#39;total_lines&#39;]))
else: else:
state[&#39;context_cmd&#39;] = max(state[&#39;context_cmd&#39;] - 1, 1) state[&#39;context_cmd&#39;] = max(state[&#39;context_cmd&#39;] - 1, 1)
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;tab&#39;) @bindings.add(&#39;tab&#39;)
def _(event): def _(event):
@@ -153,6 +205,7 @@ el.replaceWith(d);
buf.complete_next() buf.complete_next()
else: else:
state[&#39;context_mode&#39;] = (state[&#39;context_mode&#39;] + 1) % 3 state[&#39;context_mode&#39;] = (state[&#39;context_mode&#39;] + 1) % 3
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;escape&#39;, eager=True) @bindings.add(&#39;escape&#39;, eager=True)
@bindings.add(&#39;c-c&#39;) @bindings.add(&#39;c-c&#39;)
@@ -160,6 +213,16 @@ el.replaceWith(d);
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True
event.app.exit(result=&#39;&#39;) event.app.exit(result=&#39;&#39;)
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
@bindings.add(&#39;enter&#39;, filter=~has_completions)
def _(event):
event.current_buffer.validate_and_handle()
@bindings.add(&#39;c-j&#39;)
@bindings.add(&#39;escape&#39;, &#39;enter&#39;)
def _(event):
event.current_buffer.insert_text(&#39;\n&#39;)
def get_active_buffer(): def get_active_buffer():
if state[&#39;context_mode&#39;] == self.mode_lines: if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:]) return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
@@ -305,7 +368,8 @@ el.replaceWith(d);
question = await session.prompt_async( question = await session.prompt_async(
get_prompt_text, get_prompt_text,
key_bindings=bindings, key_bindings=bindings,
bottom_toolbar=get_toolbar bottom_toolbar=get_toolbar,
multiline=True
) )
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True
@@ -318,13 +382,14 @@ el.replaceWith(d);
directive = self.ai_service.process_copilot_input(question, self.session_state) directive = self.ai_service.process_copilot_input(question, self.session_state)
if directive[&#34;action&#34;] == &#34;state_update&#34;: if directive[&#34;action&#34;] == &#34;state_update&#34;:
state[&#39;toolbar_msg&#39;] = directive[&#39;message&#39;] msg = directive[&#39;message&#39;]
state[&#39;toolbar_msg&#39;] = msg
state[&#39;msg_expiry&#39;] = time.time() + 3 # 3 seconds timeout state[&#39;msg_expiry&#39;] = time.time() + 3 # 3 seconds timeout
async def delayed_refresh(): async def delayed_refresh():
await asyncio.sleep(3.1) await asyncio.sleep(3.1)
# Only invalidate if the message hasn&#39;t been replaced by a newer one # Only invalidate if the message hasn&#39;t been replaced by a newer one
if state.get(&#39;toolbar_msg&#39;) == directive[&#39;message&#39;]: if state.get(&#39;toolbar_msg&#39;) == msg:
state[&#39;toolbar_msg&#39;] = &#39;&#39; # Explicitly clear state[&#39;toolbar_msg&#39;] = &#39;&#39; # Explicitly clear
try: try:
from prompt_toolkit.application.current import get_app from prompt_toolkit.application.current import get_app
@@ -563,16 +628,52 @@ el.replaceWith(d);
last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34; last_line = buffer.split(&#39;\n&#39;)[-1].strip() if buffer.strip() else &#34;(prompt)&#34;
blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line) blocks = self.ai_service.build_context_blocks(raw_bytes, cmd_byte_positions, node_info, last_line=last_line)
total_cmds = len(blocks)
total_lines = len(buffer.split(&#39;\n&#39;))
saved_mode = self.session_state.get(&#39;context_mode&#39;, self.mode_range)
saved_cmd = self.session_state.get(&#39;context_cmd&#39;, 1)
saved_lines = self.session_state.get(&#39;context_lines&#39;, min(50, total_lines))
last_total_cmds = self.session_state.get(&#39;last_total_cmds&#39;, None)
last_total_lines = self.session_state.get(&#39;last_total_lines&#39;, None)
is_range = saved_mode in (self.mode_range, 0, &#39;RANGE&#39;, &#39;range&#39;)
is_lines = saved_mode in (self.mode_lines, 2, &#39;LINES&#39;, &#39;lines&#39;)
is_single = saved_mode in (self.mode_single, 1, &#39;SINGLE&#39;, &#39;single&#39;)
if is_range or is_single:
if last_total_cmds is not None and total_cmds &gt; last_total_cmds and saved_cmd &gt; 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines &gt; last_total_lines and saved_lines &gt; 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
state = { state = {
&#39;context_cmd&#39;: 1, &#39;context_cmd&#39;: min(max(1, initial_cmd), max(1, total_cmds)),
&#39;total_cmds&#39;: len(blocks), &#39;total_cmds&#39;: total_cmds,
&#39;total_lines&#39;: len(buffer.split(&#39;\n&#39;)), &#39;total_lines&#39;: total_lines,
&#39;context_lines&#39;: min(50, len(buffer.split(&#39;\n&#39;))), &#39;context_lines&#39;: min(max(1, initial_lines), max(1, total_lines)),
&#39;context_mode&#39;: self.mode_range, &#39;context_mode&#39;: saved_mode,
&#39;cancelled&#39;: False, &#39;cancelled&#39;: False,
&#39;toolbar_msg&#39;: &#39;&#39;, &#39;toolbar_msg&#39;: &#39;&#39;,
&#39;msg_expiry&#39;: 0 &#39;msg_expiry&#39;: 0
} }
self.session_state[&#39;context_mode&#39;] = saved_mode
self.session_state[&#39;context_cmd&#39;] = max(1, initial_cmd)
self.session_state[&#39;context_lines&#39;] = max(1, initial_lines)
self.session_state[&#39;last_total_cmds&#39;] = total_cmds
self.session_state[&#39;last_total_lines&#39;] = total_lines
# 1. Visual Separation # 1. Visual Separation
self.console.print(&#34;&#34;) # Real line break self.console.print(&#34;&#34;) # Real line break
@@ -591,6 +692,7 @@ el.replaceWith(d);
state[&#39;context_lines&#39;] = min(state[&#39;context_lines&#39;] + 50, state[&#39;total_lines&#39;]) state[&#39;context_lines&#39;] = min(state[&#39;context_lines&#39;] + 50, state[&#39;total_lines&#39;])
else: else:
state[&#39;context_cmd&#39;] = min(state[&#39;context_cmd&#39;] + 1, state[&#39;total_cmds&#39;]) state[&#39;context_cmd&#39;] = min(state[&#39;context_cmd&#39;] + 1, state[&#39;total_cmds&#39;])
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;c-down&#39;) @bindings.add(&#39;c-down&#39;)
def _(event): def _(event):
@@ -598,6 +700,7 @@ el.replaceWith(d);
state[&#39;context_lines&#39;] = max(state[&#39;context_lines&#39;] - 50, min(50, state[&#39;total_lines&#39;])) state[&#39;context_lines&#39;] = max(state[&#39;context_lines&#39;] - 50, min(50, state[&#39;total_lines&#39;]))
else: else:
state[&#39;context_cmd&#39;] = max(state[&#39;context_cmd&#39;] - 1, 1) state[&#39;context_cmd&#39;] = max(state[&#39;context_cmd&#39;] - 1, 1)
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;tab&#39;) @bindings.add(&#39;tab&#39;)
def _(event): def _(event):
@@ -607,6 +710,7 @@ el.replaceWith(d);
buf.complete_next() buf.complete_next()
else: else:
state[&#39;context_mode&#39;] = (state[&#39;context_mode&#39;] + 1) % 3 state[&#39;context_mode&#39;] = (state[&#39;context_mode&#39;] + 1) % 3
self._sync_session_context(state)
event.app.invalidate() event.app.invalidate()
@bindings.add(&#39;escape&#39;, eager=True) @bindings.add(&#39;escape&#39;, eager=True)
@bindings.add(&#39;c-c&#39;) @bindings.add(&#39;c-c&#39;)
@@ -614,6 +718,16 @@ el.replaceWith(d);
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True
event.app.exit(result=&#39;&#39;) event.app.exit(result=&#39;&#39;)
# Multiline keybindings: Enter to submit, Ctrl+Enter (c-j) or Alt+Enter to add a newline
@bindings.add(&#39;enter&#39;, filter=~has_completions)
def _(event):
event.current_buffer.validate_and_handle()
@bindings.add(&#39;c-j&#39;)
@bindings.add(&#39;escape&#39;, &#39;enter&#39;)
def _(event):
event.current_buffer.insert_text(&#39;\n&#39;)
def get_active_buffer(): def get_active_buffer():
if state[&#39;context_mode&#39;] == self.mode_lines: if state[&#39;context_mode&#39;] == self.mode_lines:
return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:]) return &#39;\n&#39;.join(buffer.split(&#39;\n&#39;)[-state[&#39;context_lines&#39;]:])
@@ -759,7 +873,8 @@ el.replaceWith(d);
question = await session.prompt_async( question = await session.prompt_async(
get_prompt_text, get_prompt_text,
key_bindings=bindings, key_bindings=bindings,
bottom_toolbar=get_toolbar bottom_toolbar=get_toolbar,
multiline=True
) )
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
state[&#39;cancelled&#39;] = True state[&#39;cancelled&#39;] = True
@@ -772,13 +887,14 @@ el.replaceWith(d);
directive = self.ai_service.process_copilot_input(question, self.session_state) directive = self.ai_service.process_copilot_input(question, self.session_state)
if directive[&#34;action&#34;] == &#34;state_update&#34;: if directive[&#34;action&#34;] == &#34;state_update&#34;:
state[&#39;toolbar_msg&#39;] = directive[&#39;message&#39;] msg = directive[&#39;message&#39;]
state[&#39;toolbar_msg&#39;] = msg
state[&#39;msg_expiry&#39;] = time.time() + 3 # 3 seconds timeout state[&#39;msg_expiry&#39;] = time.time() + 3 # 3 seconds timeout
async def delayed_refresh(): async def delayed_refresh():
await asyncio.sleep(3.1) await asyncio.sleep(3.1)
# Only invalidate if the message hasn&#39;t been replaced by a newer one # Only invalidate if the message hasn&#39;t been replaced by a newer one
if state.get(&#39;toolbar_msg&#39;) == directive[&#39;message&#39;]: if state.get(&#39;toolbar_msg&#39;) == msg:
state[&#39;toolbar_msg&#39;] = &#39;&#39; # Explicitly clear state[&#39;toolbar_msg&#39;] = &#39;&#39; # Explicitly clear
try: try:
from prompt_toolkit.application.current import get_app from prompt_toolkit.application.current import get_app
+48 -48
View File
@@ -61,61 +61,61 @@ el.replaceWith(d);
def host_validation(self, answers, current, regex = &#34;^.+$&#34;): def host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True return True
def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;): def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;) _raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
return True return True
def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;): def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;) _raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True return True
def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;): def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535: if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535 or leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535 or leave empty&#34;)
return True return True
def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;): def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile or leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535: elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
return True return True
def pass_validation(self, answers, current, regex = &#34;(^@.+$)&#34;): def pass_validation(self, answers, current, regex = &#34;(^@.+$)&#34;):
profiles = current.split(&#34;,&#34;) profiles = current.split(&#34;,&#34;)
for i in profiles: for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles: if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(i)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(i))
return True return True
def tags_validation(self, answers, current): def tags_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;: elif current != &#34;&#34;:
isdict = False isdict = False
try: try:
@@ -123,7 +123,7 @@ el.replaceWith(d);
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current)) _raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True return True
def profile_tags_validation(self, answers, current): def profile_tags_validation(self, answers, current):
@@ -134,36 +134,36 @@ el.replaceWith(d);
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current)) _raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True return True
def jumphost_validation(self, answers, current): def jumphost_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;: elif current != &#34;&#34;:
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current)) _raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True return True
def profile_jumphost_validation(self, answers, current): def profile_jumphost_validation(self, answers, current):
if current != &#34;&#34;: if current != &#34;&#34;:
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current)) _raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True return True
def default_validation(self, answers, current): def default_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True return True
def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;): def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True return True
def bulk_folder_validation(self, answers, current): def bulk_folder_validation(self, answers, current):
@@ -176,19 +176,19 @@ el.replaceWith(d);
matches = list(filter(lambda k: k == candidate, self.app.folders)) matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != &#34;&#34; and len(matches) == 0: if current != &#34;&#34; and len(matches) == 0:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Location {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Location {} don&#39;t exist&#34;.format(current))
return True return True
def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;): def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
hosts = current.split(&#34;,&#34;) hosts = current.split(&#34;,&#34;)
nodes = answers[&#34;ids&#34;].split(&#34;,&#34;) nodes = answers[&#34;ids&#34;].split(&#34;,&#34;)
if len(hosts) &gt; 1 and len(hosts) != len(nodes): if len(hosts) &gt; 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Hosts list should be the same length of nodes list&#34;) _raise_val_err(&#34;Hosts list should be the same length of nodes list&#34;)
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -212,7 +212,7 @@ el.replaceWith(d);
matches = list(filter(lambda k: k == candidate, self.app.folders)) matches = list(filter(lambda k: k == candidate, self.app.folders))
if current != &#34;&#34; and len(matches) == 0: if current != &#34;&#34; and len(matches) == 0:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Location {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Location {} don&#39;t exist&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -227,14 +227,14 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;): <pre><code class="python">def bulk_host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
hosts = current.split(&#34;,&#34;) hosts = current.split(&#34;,&#34;)
nodes = answers[&#34;ids&#34;].split(&#34;,&#34;) nodes = answers[&#34;ids&#34;].split(&#34;,&#34;)
if len(hosts) &gt; 1 and len(hosts) != len(nodes): if len(hosts) &gt; 1 and len(hosts) != len(nodes):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Hosts list should be the same length of nodes list&#34;) _raise_val_err(&#34;Hosts list should be the same length of nodes list&#34;)
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -249,10 +249,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;): <pre><code class="python">def bulk_node_validation(self, answers, current, regex = &#34;^[0-9a-zA-Z_.,$#-]+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -268,7 +268,7 @@ el.replaceWith(d);
<pre><code class="python">def default_validation(self, answers, current): <pre><code class="python">def default_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -283,10 +283,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def host_validation(self, answers, current, regex = &#34;^.+$&#34;): <pre><code class="python">def host_validation(self, answers, current, regex = &#34;^.+$&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Host cannot be empty&#34;) _raise_val_err(&#34;Host cannot be empty&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -302,10 +302,10 @@ el.replaceWith(d);
<pre><code class="python">def jumphost_validation(self, answers, current): <pre><code class="python">def jumphost_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;: elif current != &#34;&#34;:
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current)) _raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -322,7 +322,7 @@ el.replaceWith(d);
profiles = current.split(&#34;,&#34;) profiles = current.split(&#34;,&#34;)
for i in profiles: for i in profiles:
if not re.match(regex, i) or i[1:] not in self.app.profiles: if not re.match(regex, i) or i[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(i)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(i))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -337,16 +337,16 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;): <pre><code class="python">def port_validation(self, answers, current, regex = &#34;(^[0-9]*$|^@.+$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile or leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile or leave empty&#34;)
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535: elif current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -362,7 +362,7 @@ el.replaceWith(d);
<pre><code class="python">def profile_jumphost_validation(self, answers, current): <pre><code class="python">def profile_jumphost_validation(self, answers, current):
if current != &#34;&#34;: if current != &#34;&#34;:
if current not in self.app.nodes_list: if current not in self.app.nodes_list:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Node {} don&#39;t exist.&#34;.format(current)) _raise_val_err(&#34;Node {} don&#39;t exist.&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -377,13 +377,13 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;): <pre><code class="python">def profile_port_validation(self, answers, current, regex = &#34;(^[0-9]*$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535, @profile o leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535, @profile o leave empty&#34;)
try: try:
port = int(current) port = int(current)
except ValueError: except ValueError:
port = 0 port = 0
if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535: if current != &#34;&#34; and not 1 &lt;= int(port) &lt;= 65535:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick a port between 1-65535 or leave empty&#34;) _raise_val_err(&#34;Pick a port between 1-65535 or leave empty&#34;)
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -398,7 +398,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;): <pre><code class="python">def profile_protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;) _raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm or leave empty&#34;)
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -419,7 +419,7 @@ el.replaceWith(d);
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current)) _raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -434,10 +434,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;): <pre><code class="python">def protocol_validation(self, answers, current, regex = &#34;(^ssh$|^telnet$|^kubectl$|^docker$|^ssm$|^$|^@.+$)&#34;):
if not re.match(regex, current): if not re.match(regex, current):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;) _raise_val_err(&#34;Pick between ssh, telnet, kubectl, docker, ssm, leave empty or @profile&#34;)
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -453,7 +453,7 @@ el.replaceWith(d);
<pre><code class="python">def tags_validation(self, answers, current): <pre><code class="python">def tags_validation(self, answers, current):
if current.startswith(&#34;@&#34;): if current.startswith(&#34;@&#34;):
if current[1:] not in self.app.profiles: if current[1:] not in self.app.profiles:
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Profile {} don&#39;t exist&#34;.format(current)) _raise_val_err(&#34;Profile {} don&#39;t exist&#34;.format(current))
elif current != &#34;&#34;: elif current != &#34;&#34;:
isdict = False isdict = False
try: try:
@@ -461,7 +461,7 @@ el.replaceWith(d);
except Exception: except Exception:
pass pass
if not isinstance (isdict, dict): if not isinstance (isdict, dict):
raise inquirer.errors.ValidationError(&#34;&#34;, reason=&#34;Tags should be a python dictionary.&#34;.format(current)) _raise_val_err(&#34;Tags should be a python dictionary.&#34;.format(current))
return True</code></pre> return True</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
+296 -2
View File
@@ -153,6 +153,21 @@ el.replaceWith(d);
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString, response_serializer=connpy__pb2.SSOProvidersResponse.SerializeToString,
), ),
&#39;create_api_token&#39;: grpc.unary_unary_rpc_method_handler(
servicer.create_api_token,
request_deserializer=connpy__pb2.CreateApiTokenRequest.FromString,
response_serializer=connpy__pb2.CreateApiTokenResponse.SerializeToString,
),
&#39;list_api_tokens&#39;: grpc.unary_unary_rpc_method_handler(
servicer.list_api_tokens,
request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
response_serializer=connpy__pb2.ListApiTokensResponse.SerializeToString,
),
&#39;revoke_api_token&#39;: grpc.unary_unary_rpc_method_handler(
servicer.revoke_api_token,
request_deserializer=connpy__pb2.RevokeApiTokenRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
&#39;connpy.AuthService&#39;, rpc_method_handlers) &#39;connpy.AuthService&#39;, rpc_method_handlers)
@@ -1779,6 +1794,87 @@ def predict_execution_results(request,
wait_for_ready, wait_for_ready,
timeout, timeout,
metadata, metadata,
_registered_method=True)
@staticmethod
def create_api_token(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,
&#39;/connpy.AuthService/create_api_token&#39;,
connpy__pb2.CreateApiTokenRequest.SerializeToString,
connpy__pb2.CreateApiTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def list_api_tokens(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,
&#39;/connpy.AuthService/list_api_tokens&#39;,
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.ListApiTokensResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def revoke_api_token(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,
&#39;/connpy.AuthService/revoke_api_token&#39;,
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre> _registered_method=True)</code></pre>
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div> <div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -1821,6 +1917,43 @@ def change_password(request,
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token"><code class="name flex">
<span>def <span class="ident">create_api_token</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def create_api_token(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,
&#39;/connpy.AuthService/create_api_token&#39;,
connpy__pb2.CreateApiTokenRequest.SerializeToString,
connpy__pb2.CreateApiTokenResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span> <span>def <span class="ident">get_sso_providers</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt> </code></dt>
@@ -1858,6 +1991,43 @@ def get_sso_providers(request,
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens"><code class="name flex">
<span>def <span class="ident">list_api_tokens</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def list_api_tokens(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,
&#39;/connpy.AuthService/list_api_tokens&#39;,
google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
connpy__pb2.ListApiTokensResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span> <span>def <span class="ident">login</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt> </code></dt>
@@ -1932,6 +2102,43 @@ def login_sso(request,
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token"><code class="name flex">
<span>def <span class="ident">revoke_api_token</span></span>(<span>request,<br>target,<br>options=(),<br>channel_credentials=None,<br>call_credentials=None,<br>insecure=False,<br>compression=None,<br>wait_for_ready=None,<br>timeout=None,<br>metadata=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def revoke_api_token(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,
&#39;/connpy.AuthService/revoke_api_token&#39;,
connpy__pb2.RevokeApiTokenRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
@@ -1964,6 +2171,24 @@ def login_sso(request,
raise NotImplementedError(&#39;Method not implemented!&#39;) raise NotImplementedError(&#39;Method not implemented!&#39;)
def get_sso_providers(self, request, context): def get_sso_providers(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def create_api_token(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def list_api_tokens(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)
def revoke_api_token(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34; &#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;) context.set_details(&#39;Method not implemented!&#39;)
@@ -1992,6 +2217,22 @@ def login_sso(request,
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div> <div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token"><code class="name flex">
<span>def <span class="ident">create_api_token</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_api_token(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers"><code class="name flex">
<span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span> <span>def <span class="ident">get_sso_providers</span></span>(<span>self, request, context)</span>
</code></dt> </code></dt>
@@ -2008,6 +2249,22 @@ def login_sso(request,
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div> <div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens"><code class="name flex">
<span>def <span class="ident">list_api_tokens</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_api_tokens(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, request, context)</span> <span>def <span class="ident">login</span></span>(<span>self, request, context)</span>
</code></dt> </code></dt>
@@ -2040,6 +2297,22 @@ def login_sso(request,
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div> <div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token"><code class="name flex">
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, request, context)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def revoke_api_token(self, request, context):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details(&#39;Method not implemented!&#39;)
raise NotImplementedError(&#39;Method not implemented!&#39;)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
@@ -2079,6 +2352,21 @@ def login_sso(request,
&#39;/connpy.AuthService/get_sso_providers&#39;, &#39;/connpy.AuthService/get_sso_providers&#39;,
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.SSOProvidersResponse.FromString, response_deserializer=connpy__pb2.SSOProvidersResponse.FromString,
_registered_method=True)
self.create_api_token = channel.unary_unary(
&#39;/connpy.AuthService/create_api_token&#39;,
request_serializer=connpy__pb2.CreateApiTokenRequest.SerializeToString,
response_deserializer=connpy__pb2.CreateApiTokenResponse.FromString,
_registered_method=True)
self.list_api_tokens = channel.unary_unary(
&#39;/connpy.AuthService/list_api_tokens&#39;,
request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
response_deserializer=connpy__pb2.ListApiTokensResponse.FromString,
_registered_method=True)
self.revoke_api_token = channel.unary_unary(
&#39;/connpy.AuthService/revoke_api_token&#39;,
request_serializer=connpy__pb2.RevokeApiTokenRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)</code></pre> _registered_method=True)</code></pre>
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p> <div class="desc"><p>Missing associated documentation comment in .proto file.</p>
@@ -6510,20 +6798,26 @@ def stop_api(request,
</li> </li>
<li> <li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4> <h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService">AuthService</a></code></h4>
<ul class=""> <ul class="two-column">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.create_api_token">create_api_token</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.list_api_tokens">list_api_tokens</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login_sso">login_sso</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.revoke_api_token">revoke_api_token</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4> <h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></code></h4>
<ul class=""> <ul class="two-column">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token">create_api_token</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens">list_api_tokens</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token">revoke_api_token</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
+129 -8
View File
@@ -514,6 +514,8 @@ def service(self):
return self._unauthenticated_handler(handler_call_details, &#34;Authorization token is missing&#34;) return self._unauthenticated_handler(handler_call_details, &#34;Authorization token is missing&#34;)
username = self.registry.user_service.verify_jwt(token) username = self.registry.user_service.verify_jwt(token)
if not username and token.startswith(&#34;cnp_pat_&#34;):
username = self.registry.user_service.verify_api_token(token)
if not username: if not username:
return self._unauthenticated_handler(handler_call_details, &#34;Invalid or expired token&#34;) return self._unauthenticated_handler(handler_call_details, &#34;Invalid or expired token&#34;)
@@ -628,6 +630,8 @@ def service(self):
return self._unauthenticated_handler(handler_call_details, &#34;Authorization token is missing&#34;) return self._unauthenticated_handler(handler_call_details, &#34;Authorization token is missing&#34;)
username = self.registry.user_service.verify_jwt(token) username = self.registry.user_service.verify_jwt(token)
if not username and token.startswith(&#34;cnp_pat_&#34;):
username = self.registry.user_service.verify_api_token(token)
if not username: if not username:
return self._unauthenticated_handler(handler_call_details, &#34;Invalid or expired token&#34;) return self._unauthenticated_handler(handler_call_details, &#34;Invalid or expired token&#34;)
@@ -834,6 +838,58 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
except ValueError as e: except ValueError as e:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e)) context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
return Empty()
@handle_errors
def create_api_token(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Authentication required&#34;)
try:
expires_in_days = request.expires_in_days if request.expires_in_days &gt; 0 else None
result = self.registry.user_service.create_api_token(
username, request.name, expires_in_days=expires_in_days
)
except ValueError as e:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
return connpy_pb2.CreateApiTokenResponse(
token_id=result[&#34;token_id&#34;],
raw_token=result[&#34;raw_token&#34;],
name=result[&#34;name&#34;],
)
@handle_errors
def list_api_tokens(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Authentication required&#34;)
tokens = self.registry.user_service.list_api_tokens(username)
token_infos = [
connpy_pb2.ApiTokenInfo(
token_id=t[&#34;token_id&#34;],
name=t.get(&#34;name&#34;) or &#34;&#34;,
token_prefix=t.get(&#34;token_prefix&#34;) or &#34;&#34;,
created_at=t.get(&#34;created_at&#34;) or &#34;&#34;,
last_used_at=t.get(&#34;last_used_at&#34;) or &#34;&#34;,
expires_at=t.get(&#34;expires_at&#34;) or &#34;&#34;,
)
for t in tokens
]
return connpy_pb2.ListApiTokensResponse(tokens=token_infos)
@handle_errors
def revoke_api_token(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, &#34;Authentication required&#34;)
removed = self.registry.user_service.revoke_api_token(username, request.token_id)
if not removed:
context.abort(grpc.StatusCode.NOT_FOUND, f&#34;Token &#39;{request.token_id}&#39; not found&#34;)
return Empty()</code></pre> return Empty()</code></pre>
</details> </details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div> <div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
@@ -846,9 +902,12 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
<li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>: <li><code><b><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer">AuthServiceServicer</a></b></code>:
<ul class="hlist"> <ul class="hlist">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.create_api_token">create_api_token</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.get_sso_providers">get_sso_providers</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.list_api_tokens">list_api_tokens</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login_sso">login_sso</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token" href="connpy_pb2_grpc.html#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.revoke_api_token">revoke_api_token</a></code></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@@ -1496,10 +1555,59 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
raw_bytes = str(raw_bytes).encode() raw_bytes = str(raw_bytes).encode()
from connpy.utils import log_cleaner from connpy.utils import log_cleaner
last_line = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;)).split(&#39;\n&#39;)[-1].strip() cleaned_buffer = log_cleaner(raw_bytes.decode(errors=&#39;replace&#39;))
last_line = cleaned_buffer.split(&#39;\n&#39;)[-1].strip() if cleaned_buffer.strip() else &#34;(prompt)&#34;
blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line) blocks = service.build_context_blocks(raw_bytes, n.cmd_byte_positions, node_info, last_line=last_line)
node_info[&#34;context_blocks&#34;] = blocks node_info[&#34;context_blocks&#34;] = blocks
total_cmds = len(blocks)
total_lines = len(cleaned_buffer.split(&#39;\n&#39;))
if not hasattr(remote_stream, &#39;copilot_state&#39;) or remote_stream.copilot_state is None:
remote_stream.copilot_state = {}
session_state = remote_stream.copilot_state
if isinstance(node_info, dict):
for k, v in node_info.items():
if k in (&#39;context_mode&#39;, &#39;context_cmd&#39;, &#39;context_lines&#39;, &#39;persona&#39;, &#39;trust&#39;, &#39;os&#39;, &#39;prompt&#39;):
session_state[k] = v
saved_mode = session_state.get(&#39;context_mode&#39;, 0)
saved_cmd = session_state.get(&#39;context_cmd&#39;, 1)
saved_lines = session_state.get(&#39;context_lines&#39;, 50)
last_total_cmds = session_state.get(&#39;last_total_cmds&#39;, None)
last_total_lines = session_state.get(&#39;last_total_lines&#39;, None)
is_range = saved_mode in (0, &#39;RANGE&#39;, &#39;range&#39;)
is_lines = saved_mode in (2, &#39;LINES&#39;, &#39;lines&#39;)
is_single = saved_mode in (1, &#39;SINGLE&#39;, &#39;single&#39;)
if is_range or is_single:
if last_total_cmds is not None and total_cmds &gt; last_total_cmds and saved_cmd &gt; 1:
new_cmds = total_cmds - last_total_cmds
initial_cmd = saved_cmd + new_cmds
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
elif is_lines:
if last_total_lines is not None and total_lines &gt; last_total_lines and saved_lines &gt; 50:
new_lines = total_lines - last_total_lines
initial_lines = saved_lines + new_lines
else:
initial_lines = saved_lines
initial_cmd = saved_cmd
else:
initial_cmd = saved_cmd
initial_lines = saved_lines
session_state[&#39;context_cmd&#39;] = max(1, initial_cmd)
session_state[&#39;context_lines&#39;] = max(1, initial_lines)
session_state[&#39;last_total_cmds&#39;] = total_cmds
session_state[&#39;last_total_lines&#39;] = total_lines
node_info.update(session_state)
node_info[&#39;context_cmd&#39;] = min(session_state[&#39;context_cmd&#39;], max(1, total_cmds))
node_info[&#39;context_lines&#39;] = min(session_state[&#39;context_lines&#39;], max(1, total_lines))
node_info_json = json.dumps(node_info) node_info_json = json.dumps(node_info)
# Convert buffer to string if it&#39;s bytes for the preview # Convert buffer to string if it&#39;s bytes for the preview
@@ -1544,6 +1652,17 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
if req_session_id and req_session_id != copilot_session_id: if req_session_id and req_session_id != copilot_session_id:
continue # Ignore stale request from a previous session continue # Ignore stale request from a previous session
merged_node_info_str = req_data.get(&#34;node_info_json&#34;, &#34;&#34;)
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
node_info.update(merged_node_info)
# Sync context state from frontend into session_state for persistence
for k in (&#39;context_mode&#39;, &#39;context_cmd&#39;, &#39;context_lines&#39;):
if k in merged_node_info:
session_state[k] = merged_node_info[k]
except: pass
if &#34;question&#34; not in req_data or not req_data[&#34;question&#34;] or req_data[&#34;question&#34;] == &#34;CANCEL&#34; or req_data.get(&#34;action&#34;) in (&#34;cancel&#34;, &#34;web_cancel&#34;): if &#34;question&#34; not in req_data or not req_data[&#34;question&#34;] or req_data[&#34;question&#34;] == &#34;CANCEL&#34; or req_data.get(&#34;action&#34;) in (&#34;cancel&#34;, &#34;web_cancel&#34;):
if req_data.get(&#34;action&#34;) == &#34;web_cancel&#34;: if req_data.get(&#34;action&#34;) == &#34;web_cancel&#34;:
os.write(child_fd, b&#39;\x05&#39;) os.write(child_fd, b&#39;\x05&#39;)
@@ -1552,13 +1671,6 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
return return
question = req_data[&#34;question&#34;] question = req_data[&#34;question&#34;]
merged_node_info_str = req_data.get(&#34;node_info_json&#34;, &#34;&#34;)
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
node_info.update(merged_node_info)
except: pass
context_buffer = req_data.get(&#34;context_buffer&#34;, &#34;&#34;) context_buffer = req_data.get(&#34;context_buffer&#34;, &#34;&#34;)
if context_buffer.startswith(&#39;{&#34;context_start_pos&#34;&#39;): if context_buffer.startswith(&#39;{&#34;context_start_pos&#34;&#39;):
try: try:
@@ -1620,6 +1732,15 @@ interceptor chooses to service this RPC, or None otherwise.</p></div>
if not action_data: return if not action_data: return
action = action_data.get(&#34;action&#34;, &#34;cancel&#34;) action = action_data.get(&#34;action&#34;, &#34;cancel&#34;)
merged_node_info_str = action_data.get(&#34;node_info_json&#34;, &#34;&#34;)
if merged_node_info_str:
try:
merged_node_info = json.loads(merged_node_info_str)
for k in (&#39;context_mode&#39;, &#39;context_cmd&#39;, &#39;context_lines&#39;):
if k in merged_node_info:
session_state[k] = merged_node_info[k]
except: pass
if action == &#34;continue&#34;: if action == &#34;continue&#34;:
continue # Loop back for next question continue # Loop back for next question
+94 -1
View File
@@ -864,7 +864,37 @@ Call-Future's exception value will be an RpcError.</p></div>
@handle_errors @handle_errors
def change_password(self, old_password, new_password): def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password) req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)</code></pre> self.stub.change_password(req)
@handle_errors
def create_api_token(self, name, expires_in_days=0):
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
resp = self.stub.create_api_token(req)
return {
&#34;token_id&#34;: resp.token_id,
&#34;raw_token&#34;: resp.raw_token,
&#34;name&#34;: resp.name,
}
@handle_errors
def list_api_tokens(self):
resp = self.stub.list_api_tokens(Empty())
return [
{
&#34;token_id&#34;: t.token_id,
&#34;name&#34;: t.name,
&#34;token_prefix&#34;: t.token_prefix,
&#34;created_at&#34;: t.created_at,
&#34;last_used_at&#34;: t.last_used_at,
&#34;expires_at&#34;: t.expires_at,
}
for t in resp.tokens
]
@handle_errors
def revoke_api_token(self, token_id):
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
self.stub.revoke_api_token(req)</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -884,6 +914,51 @@ def change_password(self, old_password, new_password):
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.AuthStub.create_api_token"><code class="name flex">
<span>def <span class="ident">create_api_token</span></span>(<span>self, name, expires_in_days=0)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def create_api_token(self, name, expires_in_days=0):
req = connpy_pb2.CreateApiTokenRequest(name=name, expires_in_days=expires_in_days)
resp = self.stub.create_api_token(req)
return {
&#34;token_id&#34;: resp.token_id,
&#34;raw_token&#34;: resp.raw_token,
&#34;name&#34;: resp.name,
}</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthStub.list_api_tokens"><code class="name flex">
<span>def <span class="ident">list_api_tokens</span></span>(<span>self)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def list_api_tokens(self):
resp = self.stub.list_api_tokens(Empty())
return [
{
&#34;token_id&#34;: t.token_id,
&#34;name&#34;: t.name,
&#34;token_prefix&#34;: t.token_prefix,
&#34;created_at&#34;: t.created_at,
&#34;last_used_at&#34;: t.last_used_at,
&#34;expires_at&#34;: t.expires_at,
}
for t in resp.tokens
]</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthStub.login"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.AuthStub.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, username, password)</span> <span>def <span class="ident">login</span></span>(<span>self, username, password)</span>
</code></dt> </code></dt>
@@ -904,6 +979,21 @@ def login(self, username, password):
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.AuthStub.revoke_api_token"><code class="name flex">
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, token_id)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def revoke_api_token(self, token_id):
req = connpy_pb2.RevokeApiTokenRequest(token_id=token_id)
self.stub.revoke_api_token(req)</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.ConfigStub"><code class="flex name class"> <dt id="connpy.grpc_layer.stubs.ConfigStub"><code class="flex name class">
@@ -2785,7 +2875,10 @@ def stop_api(self):
<h4><code><a title="connpy.grpc_layer.stubs.AuthStub" href="#connpy.grpc_layer.stubs.AuthStub">AuthStub</a></code></h4> <h4><code><a title="connpy.grpc_layer.stubs.AuthStub" href="#connpy.grpc_layer.stubs.AuthStub">AuthStub</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.change_password" href="#connpy.grpc_layer.stubs.AuthStub.change_password">change_password</a></code></li> <li><code><a title="connpy.grpc_layer.stubs.AuthStub.change_password" href="#connpy.grpc_layer.stubs.AuthStub.change_password">change_password</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.create_api_token" href="#connpy.grpc_layer.stubs.AuthStub.create_api_token">create_api_token</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.list_api_tokens" href="#connpy.grpc_layer.stubs.AuthStub.list_api_tokens">list_api_tokens</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.login" href="#connpy.grpc_layer.stubs.AuthStub.login">login</a></code></li> <li><code><a title="connpy.grpc_layer.stubs.AuthStub.login" href="#connpy.grpc_layer.stubs.AuthStub.login">login</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthStub.revoke_api_token" href="#connpy.grpc_layer.stubs.AuthStub.revoke_api_token">revoke_api_token</a></code></li>
</ul> </ul>
</li> </li>
<li> <li>
+435 -2855
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -115,7 +115,7 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(core_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;core&#34;}
# 2. Scan shared plugins (medium priority) # 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config: if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
@@ -125,10 +125,10 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(shared_dir, f) path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;shared&#34;}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False} all_plugin_info[name] = {&#34;enabled&#34;: False, &#34;origin&#34;: &#34;shared&#34;}
# 3. Scan user plugins (highest priority) # 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;) user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
@@ -137,10 +137,10 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(user_dir, f) path = os.path.join(user_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;user&#34;}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False} all_plugin_info[name] = {&#34;enabled&#34;: False, &#34;origin&#34;: &#34;user&#34;}
return all_plugin_info return all_plugin_info
@@ -322,13 +322,13 @@ el.replaceWith(d);
is_mock = True is_mock = True
def __init__(self, config): def __init__(self, config):
from ..core import node, nodes from ..core import node, nodes
from ..ai import ai from ..connapp import DeferredAIProxy
from ..services.provider import ServiceProvider from ..services.provider import ServiceProvider
self.config = config self.config = config
self.node = node self.node = node
self.nodes = nodes self.nodes = nodes
self.ai = ai self.ai = DeferredAIProxy()
self.services = ServiceProvider(config, mode=&#34;local&#34;) self.services = ServiceProvider(config, mode=&#34;local&#34;)
@@ -645,13 +645,13 @@ el.replaceWith(d);
is_mock = True is_mock = True
def __init__(self, config): def __init__(self, config):
from ..core import node, nodes from ..core import node, nodes
from ..ai import ai from ..connapp import DeferredAIProxy
from ..services.provider import ServiceProvider from ..services.provider import ServiceProvider
self.config = config self.config = config
self.node = node self.node = node
self.nodes = nodes self.nodes = nodes
self.ai = ai self.ai = DeferredAIProxy()
self.services = ServiceProvider(config, mode=&#34;local&#34;) self.services = ServiceProvider(config, mode=&#34;local&#34;)
@@ -763,7 +763,7 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(core_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;core&#34;}
# 2. Scan shared plugins (medium priority) # 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config: if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
@@ -773,10 +773,10 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(shared_dir, f) path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;shared&#34;}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False} all_plugin_info[name] = {&#34;enabled&#34;: False, &#34;origin&#34;: &#34;shared&#34;}
# 3. Scan user plugins (highest priority) # 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;) user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
@@ -785,10 +785,10 @@ el.replaceWith(d);
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(user_dir, f) path = os.path.join(user_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)} all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path), &#34;origin&#34;: &#34;user&#34;}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False} all_plugin_info[name] = {&#34;enabled&#34;: False, &#34;origin&#34;: &#34;user&#34;}
return all_plugin_info</code></pre> return all_plugin_info</code></pre>
</details> </details>
+177 -16
View File
@@ -79,6 +79,12 @@ el.replaceWith(d);
self.mode = mode self.mode = mode
self.config = config self.config = config
self.remote_host = remote_host self.remote_host = remote_host
self._system = None
self._execution = None
self._import_export = None
self._sync = None
self._users = None
self._ai = None
if mode == &#34;local&#34;: if mode == &#34;local&#34;:
self._init_local() self._init_local()
@@ -92,35 +98,20 @@ el.replaceWith(d);
from .profile_service import ProfileService from .profile_service import ProfileService
from .config_service import ConfigService from .config_service import ConfigService
from .plugin_service import PluginService from .plugin_service import PluginService
from .ai_service import AIService
from .system_service import SystemService
from .execution_service import ExecutionService
from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService
from .user_service import UserService
self.nodes = NodeService(self.config) self.nodes = NodeService(self.config)
self.profiles = ProfileService(self.config) self.profiles = ProfileService(self.config)
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.plugins = PluginService(self.config) self.plugins = PluginService(self.config)
self.ai = AIService(self.config)
self.system = SystemService(self.config)
self.execution = ExecutionService(self.config)
self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = UserService(self.config.defaultdir)
def _init_remote(self): def _init_remote(self):
# Allow ConfigService to work locally so the user can revert the mode # Allow ConfigService to work locally so the user can revert the mode
from .config_service import ConfigService from .config_service import ConfigService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService
self.config_svc = ConfigService(self.config) self.config_svc = ConfigService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(self.config)
self.users = None
if not self.remote_host: if not self.remote_host:
raise InvalidConfigurationError(&#34;Remote host must be specified in remote mode&#34;) raise InvalidConfigurationError(&#34;Remote host must be specified in remote mode&#34;)
@@ -134,6 +125,9 @@ el.replaceWith(d);
) )
def get_token(): def get_token():
env_token = os.environ.get(&#34;CONNPY_TOKEN&#34;)
if env_token:
return env_token
token_path = os.path.join(self.config.defaultdir, &#34;.token&#34;) token_path = os.path.join(self.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path): if os.path.exists(token_path):
try: try:
@@ -159,9 +153,168 @@ el.replaceWith(d);
self.system = SystemStub(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.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)</code></pre> self.auth = AuthStub(channel, remote_host=self.remote_host)
@property
def system(self):
if self._system is None and self.mode == &#34;local&#34;:
from .system_service import SystemService
self._system = SystemService(self.config)
return self._system
@system.setter
def system(self, value):
self._system = value
@property
def execution(self):
if self._execution is None and self.mode == &#34;local&#34;:
from .execution_service import ExecutionService
self._execution = ExecutionService(self.config)
return self._execution
@execution.setter
def execution(self, value):
self._execution = value
@property
def import_export(self):
if self._import_export is None and self.mode == &#34;local&#34;:
from .import_export_service import ImportExportService
self._import_export = ImportExportService(self.config)
return self._import_export
@import_export.setter
def import_export(self, value):
self._import_export = value
@property
def sync(self):
if self._sync is None:
from .sync_service import SyncService
self._sync = SyncService(self.config)
return self._sync
@sync.setter
def sync(self, value):
self._sync = value
@property
def users(self):
if self._users is None and self.mode == &#34;local&#34;:
from .user_service import UserService
self._users = UserService(self.config.defaultdir)
return self._users
@users.setter
def users(self, value):
self._users = value
@property
def ai(self):
if self._ai is None and self.mode == &#34;local&#34;:
from .ai_service import AIService
self._ai = AIService(self.config)
return self._ai
@ai.setter
def ai(self, value):
self._ai = value</code></pre>
</details> </details>
<div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div> <div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div>
<h3>Instance variables</h3>
<dl>
<dt id="connpy.services.provider.ServiceProvider.ai"><code class="name">prop <span class="ident">ai</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def ai(self):
if self._ai is None and self.mode == &#34;local&#34;:
from .ai_service import AIService
self._ai = AIService(self.config)
return self._ai</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.services.provider.ServiceProvider.execution"><code class="name">prop <span class="ident">execution</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def execution(self):
if self._execution is None and self.mode == &#34;local&#34;:
from .execution_service import ExecutionService
self._execution = ExecutionService(self.config)
return self._execution</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.services.provider.ServiceProvider.import_export"><code class="name">prop <span class="ident">import_export</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def import_export(self):
if self._import_export is None and self.mode == &#34;local&#34;:
from .import_export_service import ImportExportService
self._import_export = ImportExportService(self.config)
return self._import_export</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.services.provider.ServiceProvider.sync"><code class="name">prop <span class="ident">sync</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def sync(self):
if self._sync is None:
from .sync_service import SyncService
self._sync = SyncService(self.config)
return self._sync</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.services.provider.ServiceProvider.system"><code class="name">prop <span class="ident">system</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def system(self):
if self._system is None and self.mode == &#34;local&#34;:
from .system_service import SystemService
self._system = SystemService(self.config)
return self._system</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.services.provider.ServiceProvider.users"><code class="name">prop <span class="ident">users</span></code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@property
def users(self):
if self._users is None and self.mode == &#34;local&#34;:
from .user_service import UserService
self._users = UserService(self.config.defaultdir)
return self._users</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</dd> </dd>
</dl> </dl>
</section> </section>
@@ -183,6 +336,14 @@ el.replaceWith(d);
</li> </li>
<li> <li>
<h4><code><a title="connpy.services.provider.ServiceProvider" href="#connpy.services.provider.ServiceProvider">ServiceProvider</a></code></h4> <h4><code><a title="connpy.services.provider.ServiceProvider" href="#connpy.services.provider.ServiceProvider">ServiceProvider</a></code></h4>
<ul class="two-column">
<li><code><a title="connpy.services.provider.ServiceProvider.ai" href="#connpy.services.provider.ServiceProvider.ai">ai</a></code></li>
<li><code><a title="connpy.services.provider.ServiceProvider.execution" href="#connpy.services.provider.ServiceProvider.execution">execution</a></code></li>
<li><code><a title="connpy.services.provider.ServiceProvider.import_export" href="#connpy.services.provider.ServiceProvider.import_export">import_export</a></code></li>
<li><code><a title="connpy.services.provider.ServiceProvider.sync" href="#connpy.services.provider.ServiceProvider.sync">sync</a></code></li>
<li><code><a title="connpy.services.provider.ServiceProvider.system" href="#connpy.services.provider.ServiceProvider.system">system</a></code></li>
<li><code><a title="connpy.services.provider.ServiceProvider.users" href="#connpy.services.provider.ServiceProvider.users">users</a></code></li>
</ul>
</li> </li>
</ul> </ul>
</li> </li>
+14
View File
@@ -82,6 +82,7 @@ el.replaceWith(d);
def login(self): def login(self):
&#34;&#34;&#34;Authenticate with Google Drive.&#34;&#34;&#34; &#34;&#34;&#34;Authenticate with Google Drive.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
creds = None creds = None
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
@@ -119,6 +120,7 @@ el.replaceWith(d);
def get_credentials(self): def get_credentials(self):
&#34;&#34;&#34;Get valid credentials, refreshing if necessary.&#34;&#34;&#34; &#34;&#34;&#34;Get valid credentials, refreshing if necessary.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
else: else:
@@ -136,6 +138,7 @@ el.replaceWith(d);
def check_login_status(self): def check_login_status(self):
&#34;&#34;&#34;Check if logged in to Google Drive.&#34;&#34;&#34; &#34;&#34;&#34;Check if logged in to Google Drive.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file) creds = Credentials.from_authorized_user_file(self.token_file)
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
@@ -148,6 +151,7 @@ el.replaceWith(d);
def list_backups(self): def list_backups(self):
&#34;&#34;&#34;List files in Google Drive appDataFolder.&#34;&#34;&#34; &#34;&#34;&#34;List files in Google Drive appDataFolder.&#34;&#34;&#34;
_, _, build, _, _, _, _, HttpError = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: if not creds:
printer.error(&#34;Not logged in to Google Drive.&#34;) printer.error(&#34;Not logged in to Google Drive.&#34;)
@@ -206,6 +210,7 @@ el.replaceWith(d);
def upload_file(self, file_path, timestamp): def upload_file(self, file_path, timestamp):
&#34;&#34;&#34;Internal method to upload to Drive.&#34;&#34;&#34; &#34;&#34;&#34;Internal method to upload to Drive.&#34;&#34;&#34;
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
@@ -231,6 +236,7 @@ el.replaceWith(d);
def delete_backup(self, file_id): def delete_backup(self, file_id):
&#34;&#34;&#34;Delete a backup from Drive.&#34;&#34;&#34; &#34;&#34;&#34;Delete a backup from Drive.&#34;&#34;&#34;
_, _, build, _, _, _, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
@@ -264,6 +270,7 @@ el.replaceWith(d);
def download_file(self, file_id, dest): def download_file(self, file_id, dest):
&#34;&#34;&#34;Internal method to download from Drive.&#34;&#34;&#34; &#34;&#34;&#34;Internal method to download from Drive.&#34;&#34;&#34;
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
@@ -508,6 +515,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def check_login_status(self): <pre><code class="python">def check_login_status(self):
&#34;&#34;&#34;Check if logged in to Google Drive.&#34;&#34;&#34; &#34;&#34;&#34;Check if logged in to Google Drive.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file) creds = Credentials.from_authorized_user_file(self.token_file)
if creds and creds.expired and creds.refresh_token: if creds and creds.expired and creds.refresh_token:
@@ -570,6 +578,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def delete_backup(self, file_id): <pre><code class="python">def delete_backup(self, file_id):
&#34;&#34;&#34;Delete a backup from Drive.&#34;&#34;&#34; &#34;&#34;&#34;Delete a backup from Drive.&#34;&#34;&#34;
_, _, build, _, _, _, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
@@ -592,6 +601,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def download_file(self, file_id, dest): <pre><code class="python">def download_file(self, file_id, dest):
&#34;&#34;&#34;Internal method to download from Drive.&#34;&#34;&#34; &#34;&#34;&#34;Internal method to download from Drive.&#34;&#34;&#34;
_, _, build, _, _, _, MediaIoBaseDownload, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
try: try:
@@ -619,6 +629,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def get_credentials(self): <pre><code class="python">def get_credentials(self):
&#34;&#34;&#34;Get valid credentials, refreshing if necessary.&#34;&#34;&#34; &#34;&#34;&#34;Get valid credentials, refreshing if necessary.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, _, _, _, _ = _get_google_libs()
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
else: else:
@@ -646,6 +657,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def list_backups(self): <pre><code class="python">def list_backups(self):
&#34;&#34;&#34;List files in Google Drive appDataFolder.&#34;&#34;&#34; &#34;&#34;&#34;List files in Google Drive appDataFolder.&#34;&#34;&#34;
_, _, build, _, _, _, _, HttpError = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: if not creds:
printer.error(&#34;Not logged in to Google Drive.&#34;) printer.error(&#34;Not logged in to Google Drive.&#34;)
@@ -684,6 +696,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def login(self): <pre><code class="python">def login(self):
&#34;&#34;&#34;Authenticate with Google Drive.&#34;&#34;&#34; &#34;&#34;&#34;Authenticate with Google Drive.&#34;&#34;&#34;
Credentials, Request, _, RefreshError, InstalledAppFlow, _, _, _ = _get_google_libs()
creds = None creds = None
if os.path.exists(self.token_file): if os.path.exists(self.token_file):
creds = Credentials.from_authorized_user_file(self.token_file, self.scopes) creds = Credentials.from_authorized_user_file(self.token_file, self.scopes)
@@ -890,6 +903,7 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def upload_file(self, file_path, timestamp): <pre><code class="python">def upload_file(self, file_path, timestamp):
&#34;&#34;&#34;Internal method to upload to Drive.&#34;&#34;&#34; &#34;&#34;&#34;Internal method to upload to Drive.&#34;&#34;&#34;
_, _, build, _, _, MediaFileUpload, _, _ = _get_google_libs()
creds = self.get_credentials() creds = self.get_credentials()
if not creds: return False if not creds: return False
+302 -1
View File
@@ -64,6 +64,9 @@ el.replaceWith(d);
# Ensure users directory exists # Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True) os.makedirs(self.users_dir, exist_ok=True)
# Reverse index cache: token_hash -&gt; (username, token_id)
self._token_index: dict[str, tuple[str, str]] = {}
def _load_registry(self) -&gt; dict: def _load_registry(self) -&gt; dict:
&#34;&#34;&#34;Loads registry from file. If it doesn&#39;t exist, initializes it with a new JWT secret.&#34;&#34;&#34; &#34;&#34;&#34;Loads registry from file. If it doesn&#39;t exist, initializes it with a new JWT secret.&#34;&#34;&#34;
if not os.path.exists(self.registry_file): if not os.path.exists(self.registry_file):
@@ -107,6 +110,16 @@ el.replaceWith(d);
pass pass
raise e raise e
def _build_token_index(self, registry: dict) -&gt; dict[str, tuple[str, str]]:
&#34;&#34;&#34;Builds a reverse index of token_hash -&gt; (username, token_id) for O(1) PAT lookup.&#34;&#34;&#34;
index = {}
for username, user_data in registry.get(&#34;users&#34;, {}).items():
for token_id, token_meta in user_data.get(&#34;api_tokens&#34;, {}).items():
token_hash = token_meta.get(&#34;token_hash&#34;)
if token_hash:
index[token_hash] = (username, token_id)
return index
def create_user(self, username, password, config_path=None) -&gt; dict: def create_user(self, username, password, config_path=None) -&gt; dict:
&#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials. &#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials.
@@ -282,7 +295,129 @@ el.replaceWith(d);
payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;]) payload = jwt.decode(token, secret, algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;) return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError): except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre> return None
# --- Personal Access Token (PAT) Management ---
def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -&gt; dict:
&#34;&#34;&#34;Creates a Personal Access Token for the user.
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
&#34;&#34;&#34;
if not name or not isinstance(name, str):
raise ValueError(&#34;Token name cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
if &#34;api_tokens&#34; not in user_data:
user_data[&#34;api_tokens&#34;] = {}
# Generate cryptographically secure token with recognizable prefix
raw_secret = secrets.token_hex(32)
raw_token = f&#34;cnp_pat_{raw_secret}&#34;
token_hash = hashlib.sha256(raw_token.encode(&#34;utf-8&#34;)).hexdigest()
token_id = f&#34;tok_{secrets.token_hex(4)}&#34;
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = None
if expires_in_days and expires_in_days &gt; 0:
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
user_data[&#34;api_tokens&#34;][token_id] = {
&#34;name&#34;: name,
&#34;token_hash&#34;: token_hash,
&#34;token_prefix&#34;: raw_token[:16],
&#34;created_at&#34;: now.isoformat(),
&#34;last_used_at&#34;: None,
&#34;expires_at&#34;: expires_at,
}
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return {
&#34;token_id&#34;: token_id,
&#34;raw_token&#34;: raw_token,
&#34;name&#34;: name,
}
def list_api_tokens(self, username: str) -&gt; list[dict]:
&#34;&#34;&#34;Lists all active API tokens for a user (without sensitive data).&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
tokens = registry[&#34;users&#34;][username].get(&#34;api_tokens&#34;, {})
return [
{
&#34;token_id&#34;: tid,
&#34;name&#34;: meta.get(&#34;name&#34;),
&#34;token_prefix&#34;: meta.get(&#34;token_prefix&#34;),
&#34;created_at&#34;: meta.get(&#34;created_at&#34;),
&#34;last_used_at&#34;: meta.get(&#34;last_used_at&#34;),
&#34;expires_at&#34;: meta.get(&#34;expires_at&#34;),
}
for tid, meta in tokens.items()
]
def revoke_api_token(self, username: str, token_id: str) -&gt; bool:
&#34;&#34;&#34;Revokes (deletes) a specific API token. Returns True if found and removed.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
tokens = registry[&#34;users&#34;][username].get(&#34;api_tokens&#34;, {})
if token_id not in tokens:
return False
del tokens[token_id]
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return True
def verify_api_token(self, raw_token: str) -&gt; str | None:
&#34;&#34;&#34;Validates a PAT by hashing it and looking up the reverse index.
Returns username if valid and not expired, None otherwise.
&#34;&#34;&#34;
token_hash = hashlib.sha256(raw_token.encode(&#34;utf-8&#34;)).hexdigest()
# Rebuild index if empty (cold start or after process restart)
if not self._token_index:
registry = self._load_registry()
self._token_index = self._build_token_index(registry)
match = self._token_index.get(token_hash)
if not match:
return None
username, token_id = match
# Validate token still exists and check expiration
registry = self._load_registry()
user_data = registry.get(&#34;users&#34;, {}).get(username, {})
token_meta = user_data.get(&#34;api_tokens&#34;, {}).get(token_id)
if not token_meta:
# Token was revoked between index build and now
self._token_index = self._build_token_index(registry)
return None
# Check expiration
expires_at = token_meta.get(&#34;expires_at&#34;)
if expires_at:
exp_dt = datetime.datetime.fromisoformat(expires_at)
if datetime.datetime.now(datetime.timezone.utc) &gt; exp_dt:
return None
# Update last_used_at
token_meta[&#34;last_used_at&#34;] = datetime.datetime.now(datetime.timezone.utc).isoformat()
self._save_registry(registry)
return username</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
<h3>Methods</h3> <h3>Methods</h3>
@@ -356,6 +491,62 @@ el.replaceWith(d);
</details> </details>
<div class="desc"><p>Verifies old password and updates registry with new hashed password.</p></div> <div class="desc"><p>Verifies old password and updates registry with new hashed password.</p></div>
</dd> </dd>
<dt id="connpy.services.user_service.UserService.create_api_token"><code class="name flex">
<span>def <span class="ident">create_api_token</span></span>(<span>self, username: str, name: str, expires_in_days: int | None = None) > dict</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_api_token(self, username: str, name: str, expires_in_days: int | None = None) -&gt; dict:
&#34;&#34;&#34;Creates a Personal Access Token for the user.
Returns the raw token ONCE. Only the SHA-256 hash is persisted.
&#34;&#34;&#34;
if not name or not isinstance(name, str):
raise ValueError(&#34;Token name cannot be empty&#34;)
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
user_data = registry[&#34;users&#34;][username]
if &#34;api_tokens&#34; not in user_data:
user_data[&#34;api_tokens&#34;] = {}
# Generate cryptographically secure token with recognizable prefix
raw_secret = secrets.token_hex(32)
raw_token = f&#34;cnp_pat_{raw_secret}&#34;
token_hash = hashlib.sha256(raw_token.encode(&#34;utf-8&#34;)).hexdigest()
token_id = f&#34;tok_{secrets.token_hex(4)}&#34;
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = None
if expires_in_days and expires_in_days &gt; 0:
expires_at = (now + datetime.timedelta(days=expires_in_days)).isoformat()
user_data[&#34;api_tokens&#34;][token_id] = {
&#34;name&#34;: name,
&#34;token_hash&#34;: token_hash,
&#34;token_prefix&#34;: raw_token[:16],
&#34;created_at&#34;: now.isoformat(),
&#34;last_used_at&#34;: None,
&#34;expires_at&#34;: expires_at,
}
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return {
&#34;token_id&#34;: token_id,
&#34;raw_token&#34;: raw_token,
&#34;name&#34;: name,
}</code></pre>
</details>
<div class="desc"><p>Creates a Personal Access Token for the user.</p>
<p>Returns the raw token ONCE. Only the SHA-256 hash is persisted.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.create_user"><code class="name flex"> <dt id="connpy.services.user_service.UserService.create_user"><code class="name flex">
<span>def <span class="ident">create_user</span></span>(<span>self, username, password, config_path=None) > dict</span> <span>def <span class="ident">create_user</span></span>(<span>self, username, password, config_path=None) > dict</span>
</code></dt> </code></dt>
@@ -514,6 +705,35 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
</details> </details>
<div class="desc"><p>Retrieves raw metadata for a specific user.</p></div> <div class="desc"><p>Retrieves raw metadata for a specific user.</p></div>
</dd> </dd>
<dt id="connpy.services.user_service.UserService.list_api_tokens"><code class="name flex">
<span>def <span class="ident">list_api_tokens</span></span>(<span>self, username: str) > list[dict]</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_api_tokens(self, username: str) -&gt; list[dict]:
&#34;&#34;&#34;Lists all active API tokens for a user (without sensitive data).&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
tokens = registry[&#34;users&#34;][username].get(&#34;api_tokens&#34;, {})
return [
{
&#34;token_id&#34;: tid,
&#34;name&#34;: meta.get(&#34;name&#34;),
&#34;token_prefix&#34;: meta.get(&#34;token_prefix&#34;),
&#34;created_at&#34;: meta.get(&#34;created_at&#34;),
&#34;last_used_at&#34;: meta.get(&#34;last_used_at&#34;),
&#34;expires_at&#34;: meta.get(&#34;expires_at&#34;),
}
for tid, meta in tokens.items()
]</code></pre>
</details>
<div class="desc"><p>Lists all active API tokens for a user (without sensitive data).</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.list_users"><code class="name flex"> <dt id="connpy.services.user_service.UserService.list_users"><code class="name flex">
<span>def <span class="ident">list_users</span></span>(<span>self) > list[dict]</span> <span>def <span class="ident">list_users</span></span>(<span>self) > list[dict]</span>
</code></dt> </code></dt>
@@ -536,6 +756,83 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
</details> </details>
<div class="desc"><p>Lists all registered users with metadata.</p></div> <div class="desc"><p>Lists all registered users with metadata.</p></div>
</dd> </dd>
<dt id="connpy.services.user_service.UserService.revoke_api_token"><code class="name flex">
<span>def <span class="ident">revoke_api_token</span></span>(<span>self, username: str, token_id: str) > bool</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def revoke_api_token(self, username: str, token_id: str) -&gt; bool:
&#34;&#34;&#34;Revokes (deletes) a specific API token. Returns True if found and removed.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; not found&#34;)
tokens = registry[&#34;users&#34;][username].get(&#34;api_tokens&#34;, {})
if token_id not in tokens:
return False
del tokens[token_id]
self._save_registry(registry)
self._token_index = self._build_token_index(registry)
return True</code></pre>
</details>
<div class="desc"><p>Revokes (deletes) a specific API token. Returns True if found and removed.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.verify_api_token"><code class="name flex">
<span>def <span class="ident">verify_api_token</span></span>(<span>self, raw_token: str) > str | None</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def verify_api_token(self, raw_token: str) -&gt; str | None:
&#34;&#34;&#34;Validates a PAT by hashing it and looking up the reverse index.
Returns username if valid and not expired, None otherwise.
&#34;&#34;&#34;
token_hash = hashlib.sha256(raw_token.encode(&#34;utf-8&#34;)).hexdigest()
# Rebuild index if empty (cold start or after process restart)
if not self._token_index:
registry = self._load_registry()
self._token_index = self._build_token_index(registry)
match = self._token_index.get(token_hash)
if not match:
return None
username, token_id = match
# Validate token still exists and check expiration
registry = self._load_registry()
user_data = registry.get(&#34;users&#34;, {}).get(username, {})
token_meta = user_data.get(&#34;api_tokens&#34;, {}).get(token_id)
if not token_meta:
# Token was revoked between index build and now
self._token_index = self._build_token_index(registry)
return None
# Check expiration
expires_at = token_meta.get(&#34;expires_at&#34;)
if expires_at:
exp_dt = datetime.datetime.fromisoformat(expires_at)
if datetime.datetime.now(datetime.timezone.utc) &gt; exp_dt:
return None
# Update last_used_at
token_meta[&#34;last_used_at&#34;] = datetime.datetime.now(datetime.timezone.utc).isoformat()
self._save_registry(registry)
return username</code></pre>
</details>
<div class="desc"><p>Validates a PAT by hashing it and looking up the reverse index.</p>
<p>Returns username if valid and not expired, None otherwise.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.verify_jwt"><code class="name flex"> <dt id="connpy.services.user_service.UserService.verify_jwt"><code class="name flex">
<span>def <span class="ident">verify_jwt</span></span>(<span>self, token) > str | None</span> <span>def <span class="ident">verify_jwt</span></span>(<span>self, token) > str | None</span>
</code></dt> </code></dt>
@@ -579,11 +876,15 @@ Mode B: config_path set -&gt; Reuses existing directory after validating its str
<li><code><a title="connpy.services.user_service.UserService.admin_change_password" href="#connpy.services.user_service.UserService.admin_change_password">admin_change_password</a></code></li> <li><code><a title="connpy.services.user_service.UserService.admin_change_password" href="#connpy.services.user_service.UserService.admin_change_password">admin_change_password</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.authenticate" href="#connpy.services.user_service.UserService.authenticate">authenticate</a></code></li> <li><code><a title="connpy.services.user_service.UserService.authenticate" href="#connpy.services.user_service.UserService.authenticate">authenticate</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.change_password" href="#connpy.services.user_service.UserService.change_password">change_password</a></code></li> <li><code><a title="connpy.services.user_service.UserService.change_password" href="#connpy.services.user_service.UserService.change_password">change_password</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.create_api_token" href="#connpy.services.user_service.UserService.create_api_token">create_api_token</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.create_user" href="#connpy.services.user_service.UserService.create_user">create_user</a></code></li> <li><code><a title="connpy.services.user_service.UserService.create_user" href="#connpy.services.user_service.UserService.create_user">create_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.delete_user" href="#connpy.services.user_service.UserService.delete_user">delete_user</a></code></li> <li><code><a title="connpy.services.user_service.UserService.delete_user" href="#connpy.services.user_service.UserService.delete_user">delete_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.generate_jwt" href="#connpy.services.user_service.UserService.generate_jwt">generate_jwt</a></code></li> <li><code><a title="connpy.services.user_service.UserService.generate_jwt" href="#connpy.services.user_service.UserService.generate_jwt">generate_jwt</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.get_user" href="#connpy.services.user_service.UserService.get_user">get_user</a></code></li> <li><code><a title="connpy.services.user_service.UserService.get_user" href="#connpy.services.user_service.UserService.get_user">get_user</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.list_api_tokens" href="#connpy.services.user_service.UserService.list_api_tokens">list_api_tokens</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.list_users" href="#connpy.services.user_service.UserService.list_users">list_users</a></code></li> <li><code><a title="connpy.services.user_service.UserService.list_users" href="#connpy.services.user_service.UserService.list_users">list_users</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.revoke_api_token" href="#connpy.services.user_service.UserService.revoke_api_token">revoke_api_token</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.verify_api_token" href="#connpy.services.user_service.UserService.verify_api_token">verify_api_token</a></code></li>
<li><code><a title="connpy.services.user_service.UserService.verify_jwt" href="#connpy.services.user_service.UserService.verify_jwt">verify_jwt</a></code></li> <li><code><a title="connpy.services.user_service.UserService.verify_jwt" href="#connpy.services.user_service.UserService.verify_jwt">verify_jwt</a></code></li>
</ul> </ul>
</li> </li>
+4
View File
@@ -376,6 +376,8 @@ Handles terminal raw mode, async I/O, and SIGWINCH signals.</p></div>
}) })
if getattr(req, &#34;copilot_action&#34;, &#34;&#34;): if getattr(req, &#34;copilot_action&#34;, &#34;&#34;):
copilot_msg[&#34;action&#34;] = req.copilot_action copilot_msg[&#34;action&#34;] = req.copilot_action
if getattr(req, &#34;copilot_node_info_json&#34;, &#34;&#34;):
copilot_msg[&#34;node_info_json&#34;] = req.copilot_node_info_json
if copilot_msg: if copilot_msg:
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg) self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)
@@ -454,6 +456,8 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.</p></di
}) })
if getattr(req, &#34;copilot_action&#34;, &#34;&#34;): if getattr(req, &#34;copilot_action&#34;, &#34;&#34;):
copilot_msg[&#34;action&#34;] = req.copilot_action copilot_msg[&#34;action&#34;] = req.copilot_action
if getattr(req, &#34;copilot_node_info_json&#34;, &#34;&#34;):
copilot_msg[&#34;node_info_json&#34;] = req.copilot_node_info_json
if copilot_msg: if copilot_msg:
self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg) self._loop.call_soon_threadsafe(self.copilot_queue.put_nowait, copilot_msg)