Compare commits

...

10 Commits

Author SHA1 Message Date
fluzzi32 e52d300cf1 new version and docs 2026-05-28 18:22:00 -03:00
fluzzi32 cf866d782a new license 2026-05-28 18:20:24 -03:00
fluzzi32 49dfa805e4 fix api multiple debug 2026-05-28 18:01:56 -03:00
fluzzi32 1b9751bd23 multiuser plugins + fixes 2026-05-28 15:23:39 -03:00
fluzzi32 f5e09a55ab feat(cli): agregar comando connpy login --status 2026-05-28 12:48:38 -03:00
fluzzi32 f6ce48ed8a fix(shared-ai): implementar aislamiento de credenciales locales y globales 2026-05-28 11:18:03 -03:00
fluzzi32 7b053998f9 Merge branch 'main' into multiuser
# Conflicts:
#	connpy/grpc_layer/server.py
2026-05-28 10:47:21 -03:00
fluzzi32 58c81a19cb refactor: optimize bulk ops, prioritize exact node matches & fix remote AI deadlock
- Priority Matching: Prioritize exact node matches in connect, delete, show, and modify actions to bypass disambiguation prompts and prevent accidental bulk mutations on partial matches.
- Bulk Operations: Optimize NodeService delete and update operations by deferring configuration writes, syncs, and cache updates to the final element of a batch.
- Remote AI: Prevent client-side CLI deadlocks when the gRPC server encounters AI configuration ValueErrors by returning a clean error state and final stream marker.
- Testing: Add unit test to verify exact-match priority behavior and update existing CLI tests to match new NodeService signatures.
2026-05-28 10:35:15 -03:00
fluzzi32 0adaaad971 feat(multiuser): implementar sistema multiusuario gRPC y configuración compartida de IA/MCP
- Servidor gRPC: Agregar interceptores de autenticación y UserRegistry para aislar sesiones por usuario.
- Contexto de Hilos: Corregir propagación de ContextVar _current_user a hilos secundarios en ExecutionServicer.
- Configuración Compartida: Implementar herencia y deep merge de settings de IA ('ai') y servidores MCP en configfile.
- Hot-Reload: Recarga automática en caliente de la configuración compartida global ante cambios en disco.
- CLI: Agregar comandos e interfaces de usuario para autenticación (login) y administración de usuarios.
- Pruebas: Desarrollar tests unitarios completos (test_shared_ai.py) y resolver regresiones en la suite existente.
2026-05-28 09:27:54 -03:00
fluzzi32 aa542cb6eb ready for production 2026-05-27 14:44:01 -03:00
84 changed files with 6996 additions and 567 deletions
+7
View File
@@ -20,3 +20,10 @@ scratch
testall testall
testremote testremote
automation-template.yaml automation-template.yaml
# Sensitive local files and credentials
auth.json
key.db
config.db
*.db
testnew/
+1
View File
@@ -146,6 +146,7 @@ package.json
# Development docs # Development docs
connpy_roadmap.md connpy_roadmap.md
testfew/
testnew/ testnew/
testall/ testall/
testremote/ testremote/
+123 -8
View File
@@ -1,16 +1,131 @@
Custom Software License # PolyForm Noncommercial License 1.0.0
Copyright (c) 2022 Federico Luzzi <https://polyformproject.org/licenses/noncommercial/1.0.0>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, and modify the Software, subject to the following conditions: ## Acceptance
Commercial Use: The use of the Software for commercial purposes, including but not limited to selling, sublicensing, or generating revenue in any form, is expressly prohibited for individuals and entities other than the copyright holder. In order to get any license under these terms, you must agree
to them as both strict obligations and conditions to all
your licenses.
Personal and Non-commercial Use: Individuals and entities are permitted to use, copy, and modify the Software for personal and non-commercial purposes. ## Copyright License
Distribution: Redistribution of the original or modified Software is allowed, provided the Software is not sold or sublicensed and this license notice is included in all copies or substantial portions of the Software. The licensor grants you a copyright license for the
software to do everything you might do with the software
that would otherwise infringe the licensor's copyright
in it for any permitted purpose. However, you may
only distribute the software according to [Distribution
License](#distribution-license) and make changes or new works
based on the software according to [Changes and New Works
License](#changes-and-new-works-license).
Support and Sale: The copyright holder reserves the exclusive right to sell or offer support services for the Software to any company or commercial entity. ## Distribution License
Disclaimer: THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. The licensor grants you an additional copyright license
to distribute copies of the software. Your license
to distribute covers distributing the software with
changes and new works permitted by [Changes and New Works
License](#changes-and-new-works-license).
## Notices
You must ensure that anyone who gets a copy of any part of
the software from you also gets a copy of these terms or the
URL for them above, as well as copies of any plain-text lines
beginning with `Required Notice:` that the licensor provided
with the software. For example:
> Required Notice: Copyright (c) 2022-2026 Federico Luzzi (<https://github.com/fluzzi/connpy>)
## Changes and New Works License
The licensor grants you an additional copyright license to
make changes and new works based on the software for any
permitted purpose.
## Patent License
The licensor grants you a patent license for the software that
covers patent claims the licensor can license, or becomes able
to license, that you would infringe by using the software.
## Noncommercial Purposes
Any noncommercial purpose is a permitted purpose.
## Personal Uses
Personal use for research, experiment, and testing for
the benefit of public knowledge, personal study, private
entertainment, hobby projects, amateur pursuits, or religious
observance, without any anticipated commercial application,
is use for a permitted purpose.
## Noncommercial Organizations
Use by any charitable organization, educational institution,
public research organization, public safety or health
organization, environmental protection organization,
or government institution is use for a permitted purpose
regardless of the source of funding or obligations resulting
from the funding.
## Fair Use
You may have "fair use" rights for the software under the
law. These terms do not limit them.
## No Other Rights
These terms do not allow you to sublicense or transfer any of
your licenses to anyone else, or prevent the licensor from
granting licenses to anyone else. These terms do not imply
any other licenses.
## Patent Defense
If you make any written claim that the software infringes or
contributes to infringement of any patent, your patent license
for the software granted under these terms ends immediately. If
your company makes such a claim, your patent license ends
immediately for work on behalf of your company.
## Violations
The first time you are notified in writing that you have
violated any of these terms, or done anything with the software
not covered by your licenses, your licenses can nonetheless
continue if you come into full compliance with these terms,
and take practical steps to correct past violations, within
32 days of receiving notice. Otherwise, all your licenses
end immediately.
## No Liability
***As far as the law allows, the software comes as is, without
any warranty or condition, and the licensor will not be liable
to you for any damages arising out of these terms or the use
or nature of the software, under any kind of legal claim.***
## Definitions
The **licensor** is the individual or entity offering these
terms, and the **software** is the software the licensor makes
available under these terms.
**You** refers to the individual or entity agreeing to these
terms.
**Your company** is any legal entity, sole proprietorship,
or other kind of organization that you work for, plus all
organizations that have control over, are under the control of,
or are under common control with that organization. **Control**
means ownership of substantially all the assets of an entity,
or the power to direct its management and policies by vote,
contract, or otherwise. Control can be direct or indirect.
**Your licenses** are all the licenses granted to you for the
software under these terms.
**Use** means anything you do with the software requiring one
of your licenses.
+1 -1
View File
@@ -1 +1 @@
__version__ = "6.0.0b12" __version__ = "6.0.0b13"
+7 -2
View File
@@ -116,8 +116,11 @@ class ai:
self.interrupted = False self.interrupted = False
# 1. Cargar configuración genérica # 1. Cargar configuración genérica con herencia/merge global
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "get_effective_setting"):
aiconfig = self.config.get_effective_setting("ai", {})
else:
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
# Modelos (Prioridad: Argumento -> Config -> Default) # Modelos (Prioridad: Argumento -> Config -> Default)
self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite" self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
@@ -1008,9 +1011,11 @@ class ai:
@MethodHook @MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower() is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless: if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.") raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
+44 -8
View File
@@ -48,14 +48,50 @@ def stop_api():
return port return port
def debug_api(port=8048, config=None): def debug_api(port=8048, config=None):
from .grpc_layer.server import serve # Check if already running via PID file verification
conf = config or configfile() for pid_file in [PID_FILE1, PID_FILE2]:
server = serve(conf, port=port, debug=True) if os.path.exists(pid_file):
printer.info(f"gRPC Server running in debug mode on port {port}...") try:
_wait_for_termination() with open(pid_file, "r") as f:
server.stop(0) pid = int(f.readline().strip())
from .ai import cleanup os.kill(pid, 0)
cleanup() # If we get here, process exists
printer.info(f"API is already running (PID {pid})")
return
except (ValueError, OSError, ProcessLookupError):
# Stale PID file, ignore here
pass
# Create PID file for the debug process
written_pid_file = None
my_pid = os.getpid()
try:
with open(PID_FILE1, "w") as f:
f.write(str(my_pid) + "\n" + str(port))
written_pid_file = PID_FILE1
except OSError:
try:
with open(PID_FILE2, "w") as f:
f.write(str(my_pid) + "\n" + str(port))
written_pid_file = PID_FILE2
except OSError:
pass
try:
from .grpc_layer.server import serve
conf = config or configfile()
server = serve(conf, port=port, debug=True)
printer.info(f"gRPC Server running in debug mode on port {port}...")
_wait_for_termination()
server.stop(0)
from .ai import cleanup
cleanup()
finally:
if written_pid_file and os.path.exists(written_pid_file):
try:
os.remove(written_pid_file)
except OSError:
pass
def start_server(port=8048, config=None): def start_server(port=8048, config=None):
try: try:
+143
View File
@@ -0,0 +1,143 @@
import os
import sys
import getpass
from .. import printer
from ..services.exceptions import ConnpyError
class LoginHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
action = getattr(args, "action", None)
if action == "login":
return self.login(args)
elif action == "logout":
return self.logout(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def login(self, args):
if getattr(args, "status", False):
return self.show_status()
if self.app.services.mode != "remote":
printer.warning("Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to 'remote'.")
username = getattr(args, "username", None)
if not username:
try:
username = input("Username: ").strip()
if not username:
printer.error("Username cannot be empty.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
password = getpass.getpass("Password: ")
if not password:
printer.error("Password cannot be empty.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let's instantiate it dynamically if it's not present.
auth_service = getattr(self.app.services, "auth", None)
if not auth_service:
import grpc
from ..grpc_layer.stubs import AuthStub
remote_host = self.app.services.remote_host or self.app.config.config.get("remote_host")
if not remote_host:
printer.error("Remote host is not configured. Run 'connpy config --remote HOST:PORT' first.")
sys.exit(1)
try:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f"Failed to connect to remote server for login: {e}")
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res["token"]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, ".token")
with open(token_path, "w") as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f"Logged in successfully as '{username}'. Session expires in 8 hours.")
except ConnpyError as e:
printer.error(f"Login failed: {e}")
sys.exit(1)
except Exception as e:
printer.error(f"Login failed with unexpected error: {e}")
sys.exit(1)
def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, ".token")
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success("Logged out successfully. Local session cleared.")
except Exception as e:
printer.error(f"Failed to clear session: {e}")
sys.exit(1)
else:
printer.info("No active session found (already logged out).")
def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, ".token")
if not os.path.exists(token_path):
printer.warning("No active session found. You can log in using 'connpy login'.")
return
try:
with open(token_path, "r") as f:
token = f.read().strip()
parts = token.split(".")
if len(parts) != 3:
printer.error("Invalid local session token format.")
return
payload_b64 = parts[1]
payload_b64 += "=" * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode("utf-8"))
username = payload.get("sub")
exp = payload.get("exp")
if not exp:
printer.success(f"Active session as '{username}' (Indefinite expiration).")
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now > exp:
printer.error("Session has expired. Please log in again using 'connpy login'.")
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f"Logged in as '{username}'")
printer.info(f"Time remaining: {hours}h {minutes}m")
exp_dt = datetime.datetime.fromtimestamp(exp, datetime.timezone.utc)
printer.info(f"Expires at: {exp_dt.strftime('%Y-%m-%d %H:%M:%S UTC')}")
except Exception as e:
printer.error(f"Failed to check local session status: {e}")
+31 -5
View File
@@ -14,6 +14,23 @@ class NodeHandler:
self.app = app self.app = app
self.forms = Forms(app) self.forms = Forms(app)
def _filter_exact_match(self, matches, query):
if not query or len(matches) <= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args): def dispatch(self, args):
if not self.app.case and args.data != None: if not self.app.case and args.data != None:
args.data = args.data.lower() args.data = args.data.lower()
@@ -39,6 +56,7 @@ class NodeHandler:
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -73,6 +91,7 @@ class NodeHandler:
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -87,8 +106,9 @@ class NodeHandler:
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f"{matches[0]} deleted successfully") printer.success(f"{matches[0]} deleted successfully")
@@ -144,6 +164,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -171,6 +192,7 @@ class NodeHandler:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -209,7 +231,7 @@ class NodeHandler:
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f"{args.data} edited successfully") printer.success(f"{args.data} edited successfully")
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item["type"] = "connection" updated_item["type"] = "connection"
@@ -222,8 +244,12 @@ class NodeHandler:
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info("Nothing to do here") printer.info("Nothing to do here")
+35 -2
View File
@@ -20,6 +20,17 @@ class RunHandler:
def node_run(self, args): def node_run(self, args):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f"No nodes found matching filter: {nodes_filter}")
sys.exit(2)
commands = [" ".join(args.data[1:])] commands = [" ".join(args.data[1:])]
try: try:
@@ -36,7 +47,7 @@ class RunHandler:
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -53,7 +64,7 @@ class RunHandler:
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
@@ -103,6 +114,28 @@ class RunHandler:
folder = output_cfg if output_cfg not in [None, "stdout"] else None folder = output_cfg if output_cfg not in [None, "stdout"] else None
prompt = options.get("prompt") prompt = options.get("prompt")
# Resolve and filter nodes through context-aware list_nodes
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
if not resolved_nodes:
printer.error(f"[{name}] No nodes found matching filter: {nodelist}")
sys.exit(11)
nodelist = resolved_nodes
try: try:
header_printed = False header_printed = False
if action == "run": if action == "run":
+190
View File
@@ -0,0 +1,190 @@
import sys
import os
import getpass
import yaml
from .. import printer
from ..services.exceptions import ConnpyError
class UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == "remote":
printer.error("User management commands are only available in local/server-side mode.")
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, "add", None):
args.action = "add"
args.username = args.add[0]
elif getattr(args, "delete", None):
args.action = "del"
args.username = args.delete[0]
elif getattr(args, "list", False):
args.action = "list"
elif getattr(args, "show", None):
args.action = "show"
args.username = args.show[0]
elif getattr(args, "regen_password", None):
args.action = "regen_password"
args.username = args.regen_password[0]
action = getattr(args, "action", None)
if action == "add":
return self.add_user(args)
elif action == "del":
return self.delete_user(args)
elif action == "list":
return self.list_users(args)
elif action == "show":
return self.show_user(args)
elif action == "regen_password":
return self.regen_password(args)
else:
printer.error(f"Unknown action: {action}")
sys.exit(1)
def add_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --add <username>")
sys.exit(1)
custom_path = getattr(args, "path", None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass("Enter password for new user: ")
if not password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm password: ")
if password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f"User '{username}' created successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to create user: {e}")
sys.exit(1)
def delete_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --del <username>")
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f"User '{username}' deleted successfully.")
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to delete user: {e}")
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning("No users registered.")
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get("config_path"):
formatted_u["config_path"] = os.path.join(self.app.services.users.users_dir, formatted_u["username"])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data("Registered Users", yaml_str)
except Exception as e:
printer.error(f"Failed to list users: {e}")
sys.exit(1)
def show_user(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --show <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != "password_hash"}
if not safe_user.get("config_path"):
safe_user["config_path"] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f"User: {username}", yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
def regen_password(self, args):
username = getattr(args, "username", None)
if not username:
printer.error("Username is required. Usage: connpy user --regen-password <username>")
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f"User '{username}' not found.")
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to retrieve user details: {e}")
sys.exit(1)
try:
new_password = getpass.getpass("Enter new password: ")
if not new_password:
printer.error("Password cannot be empty.")
sys.exit(1)
confirm = getpass.getpass("Confirm new password: ")
if new_password != confirm:
printer.error("Passwords do not match.")
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning("\nOperation cancelled.")
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f"Password for user '{username}' regenerated successfully.")
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f"Failed to regenerate password: {e}")
sys.exit(1)
+31
View File
@@ -105,6 +105,21 @@ def _get_plugins(which, defaultdir):
return final_all_plugins return final_all_plugins
def _get_users(configdir):
import yaml
registry_file = os.path.join(configdir, "users", "registry.yaml")
if not os.path.exists(registry_file):
return []
try:
with open(registry_file, "r") as f:
data = yaml.safe_load(f) or {}
if isinstance(data, dict) and "users" in data:
return list(data["users"].keys())
except Exception:
pass
return []
def _build_tree(nodes, folders, profiles, plugins, configdir): def _build_tree(nodes, folders, profiles, plugins, configdir):
"""Build the declarative CLI navigation tree. """Build the declarative CLI navigation tree.
@@ -203,6 +218,19 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
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}
_users = lambda w=None: _get_users(configdir)
user_dict = {
"--add": {"*": {"--path": {"__extra__": lambda w: get_cwd(w, "--path", True), "*": None}}},
"--del": {"__extra__": _users},
"--rm": {"__extra__": _users},
"--show": {"__extra__": _users},
"--regen-password": {"__extra__": _users},
"--list": None,
"--ls": None,
"--help": None, "-h": None
}
mv_state = {"__extra__": _nodes, "--help": None, "-h": None} mv_state = {"__extra__": _nodes, "--help": None, "-h": None}
cp_state = {"__extra__": _nodes, "--help": None, "-h": None} cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
ls_state = { ls_state = {
@@ -297,6 +325,9 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
"--list": None, "--help": None, "--list": None, "--help": None,
"-h": None, "-h": None,
}, },
"user": user_dict,
"login": {"--help": None, "-h": None, "*": None},
"logout": {"--help": None, "-h": None},
"config": config_dict, "config": config_dict,
"sync": { "sync": {
"--login": None, "--logout": None, "--login": None, "--logout": None,
+40 -2
View File
@@ -43,7 +43,8 @@ class configfile:
passwords. passwords.
''' '''
def __init__(self, conf = None, key = None): def __init__(self, conf = None, key = None, shared_config = None):
self._shared_config = shared_config
''' '''
### Optional Parameters: ### Optional Parameters:
@@ -149,6 +150,42 @@ class configfile:
self._generate_nodes_cache() self._generate_nodes_cache()
def get_effective_setting(self, key, default=None):
"""Get config setting with shared fallback for inheritable keys."""
val = self.config.get(key)
if key == "ai":
if val is not None:
if self._shared_config:
import copy
# Deep merge: shared as base, user overrides
base = copy.deepcopy(self._shared_config.config.get(key, {}))
if isinstance(base, dict) and isinstance(val, dict):
# Credential isolation:
# If user defines engineer credentials, discard shared ones
if "engineer_api_key" in val or "engineer_auth" in val:
base.pop("engineer_api_key", None)
base.pop("engineer_auth", None)
# If user defines architect credentials, discard shared ones
if "architect_api_key" in val or "architect_auth" in val:
base.pop("architect_api_key", None)
base.pop("architect_auth", None)
# Recursive update for inner dictionaries (like mcp_servers or model details)
def deep_merge(d1, d2):
for k, v in d2.items():
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
deep_merge(d1[k], v)
else:
d1[k] = copy.deepcopy(v)
deep_merge(base, val)
return base
return val
elif self._shared_config:
return self._shared_config.config.get(key, default)
return val if val is not None else default
def _validate_config(self, data): def _validate_config(self, data):
"""Verify config data has the required structure.""" """Verify config data has the required structure."""
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -489,7 +526,8 @@ class configfile:
else: else:
printer.error("Filter must be a string or a list of strings") printer.error("Filter must be a string or a list of strings")
sys.exit(1) sys.exit(1)
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)] flags = re.IGNORECASE if not self.config.get("case", False) else 0
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
return nodes return nodes
@MethodHook @MethodHook
+43 -10
View File
@@ -79,15 +79,16 @@ class connapp:
self.debug_api = debug_api self.debug_api = debug_api
self.ai = ai self.ai = ai
# Register context filtering hooks # Register context filtering hooks (only on Client CLI, bypass on gRPC Server)
self.services.context.config._getallnodes.register_post_hook(self.services.context.filter_node_list) is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api"
self.services.context.config._getallfolders.register_post_hook(self.services.context.filter_node_list) if not is_api_server:
self.services.context.config._getallnodesfull.register_post_hook(self.services.context.filter_node_dict) self.services.context.config._getallnodes.register_post_hook(self.services.context.filter_node_list)
self.services.context.config._getallfolders.register_post_hook(self.services.context.filter_node_list)
if hasattr(self.services.nodes, "list_nodes") and hasattr(self.services.nodes.list_nodes, "register_post_hook"): self.services.context.config._getallnodesfull.register_post_hook(self.services.context.filter_node_dict)
self.services.nodes.list_nodes.register_post_hook(self.services.context.filter_node_list) if hasattr(self.services.nodes, "list_nodes") and hasattr(self.services.nodes.list_nodes, "register_post_hook"):
if hasattr(self.services.nodes, "list_folders") and hasattr(self.services.nodes.list_folders, "register_post_hook"): self.services.nodes.list_nodes.register_post_hook(self.services.context.filter_node_list)
self.services.nodes.list_folders.register_post_hook(self.services.context.filter_node_list) if hasattr(self.services.nodes, "list_folders") and hasattr(self.services.nodes.list_folders, "register_post_hook"):
self.services.nodes.list_folders.register_post_hook(self.services.context.filter_node_list)
# Apply theme from config if exists before remote connection attempts # Apply theme from config if exists before remote connection attempts
user_theme = self.config.config.get("theme", {}) user_theme = self.config.config.get("theme", {})
@@ -109,7 +110,10 @@ class connapp:
except ConnpyError as e: except ConnpyError as e:
# If in remote mode, connectivity issues should be reported # If in remote mode, connectivity issues should be reported
if mode == "remote": if mode == "remote":
printer.warning(f"Failed to fetch data from remote server: {e}") is_auth_cmd = len(sys.argv) > 1 and sys.argv[1] in ["login", "logout", "user"]
is_unauth = "unauthenticated" in str(e).lower() or "token" in str(e).lower()
if not (is_auth_cmd and is_unauth):
printer.warning(f"Failed to fetch data from remote server: {e}")
self.nodes_list = [] self.nodes_list = []
self.folders = [] self.folders = []
self.profiles = [] self.profiles = []
@@ -135,6 +139,8 @@ class connapp:
from .cli.context_handler import ContextHandler from .cli.context_handler import ContextHandler
from .cli.import_export_handler import ImportExportHandler from .cli.import_export_handler import ImportExportHandler
from .cli.sync_handler import SyncHandler from .cli.sync_handler import SyncHandler
from .cli.user_handler import UserHandler
from .cli.login_handler import LoginHandler
# Instantiate Handlers # Instantiate Handlers
self._node = NodeHandler(self) self._node = NodeHandler(self)
@@ -147,6 +153,8 @@ class connapp:
self._context = ContextHandler(self) self._context = ContextHandler(self)
self._import_export = ImportExportHandler(self) self._import_export = ImportExportHandler(self)
self._sync = SyncHandler(self) self._sync = SyncHandler(self)
self._user = UserHandler(self)
self._login = LoginHandler(self)
# Register auto-sync hook to trigger after config saves # Register auto-sync hook to trigger after config saves
from .configfile import configfile from .configfile import configfile
@@ -353,6 +361,31 @@ class connapp:
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"])
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
userparser = subparsers.add_parser("user", help="Manage server users", description="Manage server users", formatter_class=RichHelpFormatter)
userparser.error = self._custom_error
usercrud = userparser.add_mutually_exclusive_group(required=True)
usercrud.add_argument("--add", nargs=1, dest="add", help="Add new user", metavar="USERNAME")
usercrud.add_argument("--del", "--rm", nargs=1, dest="delete", help="Delete user", metavar="USERNAME")
usercrud.add_argument("--list", "--ls", dest="list", action="store_true", help="List all users")
usercrud.add_argument("--show", nargs=1, dest="show", help="Show user details", metavar="USERNAME")
usercrud.add_argument("--regen-password", nargs=1, dest="regen_password", help="Regenerate user password", metavar="USERNAME")
userparser.add_argument("--path", dest="path", nargs=1, help="Custom configuration path for user configuration (in Mode B)")
userparser.set_defaults(func=self._user.dispatch)
#LOGINPARSER
loginparser = subparsers.add_parser("login", help="Login to remote connpy server", description="Login to remote connpy server", formatter_class=RichHelpFormatter)
loginparser.error = self._custom_error
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.set_defaults(func=self._login.dispatch, action="login")
#LOGOUTPARSER
logoutparser = subparsers.add_parser("logout", help="Logout from remote connpy server", description="Logout from remote connpy server", formatter_class=RichHelpFormatter)
logoutparser.error = self._custom_error
logoutparser.set_defaults(func=self._login.dispatch, action="logout")
#SYNCPARSER #SYNCPARSER
syncparser = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter) syncparser = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter)
-12
View File
@@ -1016,18 +1016,6 @@ class node:
cmd += f" {self.options}" cmd += f" {self.options}"
return cmd return cmd
@MethodHook
def _generate_ssm_cmd(self):
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
cmd = f"aws ssm start-session --target {self.host}"
if region:
cmd += f" --region {region}"
if profile:
cmd += f" --profile {profile}"
if self.options:
cmd += f" {self.options}"
return cmd
@MethodHook @MethodHook
def _get_cmd(self): def _get_cmd(self):
File diff suppressed because one or more lines are too long
+115
View File
@@ -2535,3 +2535,118 @@ class SystemService(object):
timeout, timeout,
metadata, metadata,
_registered_method=True) _registered_method=True)
class AuthServiceStub(object):
"""Missing associated documentation comment in .proto file."""
def __init__(self, channel):
"""Constructor.
Args:
channel: A grpc.Channel.
"""
self.login = channel.unary_unary(
'/connpy.AuthService/login',
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
'/connpy.AuthService/change_password',
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)
class AuthServiceServicer(object):
"""Missing associated documentation comment in .proto file."""
def login(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def change_password(self, request, context):
"""Missing associated documentation comment in .proto file."""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_AuthServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
'login': grpc.unary_unary_rpc_method_handler(
servicer.login,
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
'change_password': grpc.unary_unary_rpc_method_handler(
servicer.change_password,
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
'connpy.AuthService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers('connpy.AuthService', rpc_method_handlers)
# This class is part of an EXPERIMENTAL API.
class AuthService(object):
"""Missing associated documentation comment in .proto file."""
@staticmethod
def login(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/login',
connpy__pb2.LoginRequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def change_password(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/connpy.AuthService/change_password',
connpy__pb2.ChangePasswordRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
+356 -50
View File
@@ -4,6 +4,8 @@ from google.protobuf.empty_pb2 import Empty
import os import os
import ctypes import ctypes
import threading import threading
import contextvars
import datetime
# Suppress harmless but noisy gRPC fork() warnings from pexpect child processes # Suppress harmless but noisy gRPC fork() warnings from pexpect child processes
os.environ["GRPC_VERBOSITY"] = "NONE" os.environ["GRPC_VERBOSITY"] = "NONE"
@@ -14,15 +16,7 @@ from .utils import to_value, from_value, to_struct, from_struct
from ..services.exceptions import ConnpyError from ..services.exceptions import ConnpyError
from .. import printer from .. import printer
# Import local services _current_user = contextvars.ContextVar("current_user", default=None)
from ..services.node_service import NodeService
from ..services.profile_service import ProfileService
from ..services.config_service import ConfigService
from ..services.plugin_service import PluginService
from ..services.ai_service import AIService
from ..services.system_service import SystemService
from ..services.execution_service import ExecutionService
from ..services.import_export_service import ImportExportService
def handle_errors(func): def handle_errors(func):
import inspect import inspect
@@ -31,10 +25,16 @@ def handle_errors(func):
try: try:
for item in func(*args, **kwargs): for item in func(*args, **kwargs):
yield item yield item
except grpc.RpcError:
raise
except ConnpyError as e: except ConnpyError as e:
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.INTERNAL, str(e)) context.abort(grpc.StatusCode.INTERNAL, str(e))
except Exception as e: except Exception as e:
if type(e) is Exception and not e.args:
raise e
if e.__class__.__name__ in ("_AbortError", "RpcError"):
raise e
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.UNKNOWN, str(e)) context.abort(grpc.StatusCode.UNKNOWN, str(e))
finally: finally:
@@ -44,10 +44,16 @@ def handle_errors(func):
def wrapper(*args, **kwargs): def wrapper(*args, **kwargs):
try: try:
return func(*args, **kwargs) return func(*args, **kwargs)
except grpc.RpcError:
raise
except ConnpyError as e: except ConnpyError as e:
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.INTERNAL, str(e)) context.abort(grpc.StatusCode.INTERNAL, str(e))
except Exception as e: except Exception as e:
if type(e) is Exception and not e.args:
raise e
if e.__class__.__name__ in ("_AbortError", "RpcError"):
raise e
context = kwargs.get("context") or args[-1] context = kwargs.get("context") or args[-1]
context.abort(grpc.StatusCode.UNKNOWN, str(e)) context.abort(grpc.StatusCode.UNKNOWN, str(e))
finally: finally:
@@ -55,25 +61,46 @@ def handle_errors(func):
return wrapper return wrapper
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer): class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
def __init__(self, config, debug=False): def __init__(self, provider, registry=None, debug=False):
self.service = NodeService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
self.server_debug = debug self.server_debug = debug
if debug: if debug:
from rich.console import Console from rich.console import Console
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
self.server_console = Console(theme=connpy_theme, file=get_original_stdout()) self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def interact_node(self, request_iterator, context): def interact_node(self, request_iterator, context):
import sys import sys
import os import os
import asyncio import asyncio
from connpy.core import node from connpy.core import node
from ..services.profile_service import ProfileService
from connpy.tunnels import RemoteStream from connpy.tunnels import RemoteStream
import queue import queue
import threading import threading
# Resolve provider once at the start of the RPC stream
provider = self._get_provider()
nodes_service = provider.nodes
profile_service = provider.profiles
ai_service = provider.ai
user_config = provider.config
# Fetch first setup packet # Fetch first setup packet
try: try:
first_req = next(request_iterator) first_req = next(request_iterator)
@@ -100,9 +127,9 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
if base_node_id: if base_node_id:
# Look up the base node in config and use its full data # Look up the base node in config and use its full data
nodes = self.service.config._getallnodes(base_node_id) nodes = user_config._getallnodes(base_node_id)
if nodes: if nodes:
device = self.service.config.getitem(nodes[0]) device = user_config.getitem(nodes[0])
# Override device properties with any passed in params # Override device properties with any passed in params
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
@@ -116,11 +143,11 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
device["tags"] = device_tags device["tags"] = device_tags
node_name = params.get("name", base_node_id) node_name = params.get("name", base_node_id)
n = node(node_name, **device, config=self.service.config) n = node(node_name, **device, config=user_config)
else: else:
# base_node not found, fall back to dynamic # base_node not found, fall back to dynamic
node_name = params.get("name", fallback_id) node_name = params.get("name", fallback_id)
n = node(node_name, host=params.get("host", ""), config=self.service.config) n = node(node_name, host=params.get("host", ""), config=user_config)
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
setattr(n, attr, params[attr]) setattr(n, attr, params[attr])
@@ -128,19 +155,22 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
n.tags = params["tags"] n.tags = params["tags"]
else: else:
node_name = params.get("name", fallback_id) node_name = params.get("name", fallback_id)
n = node(node_name, host=params.get("host", ""), config=self.service.config) n = node(node_name, host=params.get("host", ""), config=user_config)
for attr in valid_attrs: for attr in valid_attrs:
if attr in params: if attr in params:
setattr(n, attr, params[attr]) setattr(n, attr, params[attr])
if "tags" in params: if "tags" in params:
n.tags = params["tags"] n.tags = params["tags"]
else: else:
node_data = self.service.config.getitem(unique_id, extract=False) try:
node_data = user_config.getitem(unique_id, extract=False)
except (KeyError, TypeError):
node_data = None
if not node_data: if not node_data:
context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found") context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found")
profile_service = ProfileService(self.service.config)
resolved_data = profile_service.resolve_node_data(node_data) resolved_data = profile_service.resolve_node_data(node_data)
n = node(unique_id, **resolved_data, config=self.service.config) n = node(unique_id, **resolved_data, config=user_config)
if sftp: if sftp:
n.protocol = "sftp" n.protocol = "sftp"
@@ -207,9 +237,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
import json import json
import asyncio import asyncio
import os import os
from ..services.ai_service import AIService
service = AIService(self.service.config) service = ai_service
if node_info is None: if node_info is None:
node_info = {} node_info = {}
@@ -479,10 +508,27 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
) )
class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer): class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ProfileService(config) if not hasattr(provider, "mode"):
self.node_service = NodeService(config) from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().profiles
@property
def node_service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def list_profiles(self, request, context): def list_profiles(self, request, context):
@@ -516,8 +562,23 @@ class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
return Empty() return Empty()
class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer): class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ConfigService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().config_svc
@handle_errors @handle_errors
def get_settings(self, request, context): def get_settings(self, request, context):
@@ -546,8 +607,23 @@ class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
return connpy_pb2.StructResponse(data=to_struct(self.service.apply_theme_from_file(request.value))) return connpy_pb2.StructResponse(data=to_struct(self.service.apply_theme_from_file(request.value)))
class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer): class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = PluginService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().plugins
@handle_errors @handle_errors
def list_plugins(self, request, context): def list_plugins(self, request, context):
@@ -589,8 +665,23 @@ class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_gr
yield remote_plugin_pb2.OutputChunk(text=chunk) yield remote_plugin_pb2.OutputChunk(text=chunk)
class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer): class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ExecutionService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().execution
@handle_errors @handle_errors
def run_commands(self, request, context): def run_commands(self, request, context):
@@ -599,6 +690,11 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes) nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
# threading.Thread does NOT inherit ContextVars, so self.service inside
# _worker() would fall back to the admin provider.
execution_service = self.service
q = queue.Queue() q = queue.Queue()
def _on_complete(unique, output, status): def _on_complete(unique, output, status):
@@ -606,7 +702,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker(): def _worker():
try: try:
self.service.run_commands( nodes_filter=nodes_filter, execution_service.run_commands( nodes_filter=nodes_filter,
commands=list(request.commands), commands=list(request.commands),
folder=request.folder if request.folder else None, folder=request.folder if request.folder else None,
prompt=request.prompt if request.prompt else None, prompt=request.prompt if request.prompt else None,
@@ -645,6 +741,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes) nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
execution_service = self.service
q = queue.Queue() q = queue.Queue()
def _on_complete(unique, node_output, node_status, node_result): def _on_complete(unique, node_output, node_status, node_result):
@@ -652,7 +751,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
def _worker(): def _worker():
try: try:
self.service.test_commands( execution_service.test_commands(
nodes_filter=nodes_filter, nodes_filter=nodes_filter,
commands=list(request.commands), commands=list(request.commands),
expected=list(request.expected), expected=list(request.expected),
@@ -698,9 +797,27 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
return connpy_pb2.StructResponse(data=to_struct(res)) return connpy_pb2.StructResponse(data=to_struct(res))
class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer): class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = ImportExportService(config) if not hasattr(provider, "mode"):
self.node_service = NodeService(config) from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().import_export
@property
def node_service(self):
return self._get_provider().nodes
@handle_errors @handle_errors
def export_to_file(self, request, context): def export_to_file(self, request, context):
@@ -815,14 +932,35 @@ class StatusBridge:
return default return default
class AIServicer(connpy_pb2_grpc.AIServiceServicer): class AIServicer(connpy_pb2_grpc.AIServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None, debug=False):
self.service = AIService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
self.server_debug = debug
if debug:
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().ai
@handle_errors @handle_errors
def ask(self, request_iterator, context): def ask(self, request_iterator, context):
import queue import queue
import threading import threading
ai_service = self.service
chunk_queue = queue.Queue() chunk_queue = queue.Queue()
request_queue = queue.Queue() request_queue = queue.Queue()
bridge = None bridge = None
@@ -840,7 +978,7 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
nonlocal history, bridge, agent_instance nonlocal history, bridge, agent_instance
try: try:
# Run the AI interaction (this blocks this specific thread) # Run the AI interaction (this blocks this specific thread)
res = self.service.ask( res = ai_service.ask(
input_text, input_text,
chat_history=history if history else None, chat_history=history if history else None,
session_id=session_id, session_id=session_id,
@@ -859,6 +997,16 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
# Send final chunk marker # Send final chunk marker
chunk_queue.put(("final_mark", res)) chunk_queue.put(("final_mark", res))
except ValueError as e:
# Configuration or LLM provider connection errors are expected, only print in debug mode
if debug or getattr(self, "server_debug", False):
from rich.console import Console
from ..printer import connpy_theme, get_original_stdout
c = getattr(self, "server_console", None) or Console(theme=connpy_theme, file=get_original_stdout())
c.print(f"[debug][DEBUG][/debug] AI Task Error: {e}")
chunk_queue.put(("status", f"Error: {str(e)}"))
# Crucial: always send final_mark to avoid client deadlock
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "chat_history": history, "error": True}))
except Exception as e: except Exception as e:
import traceback import traceback
print(f"AI Task Error: {e}") print(f"AI Task Error: {e}")
@@ -996,8 +1144,23 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
return connpy_pb2.StructResponse(data=to_struct(self.service.load_session_data(request.value))) return connpy_pb2.StructResponse(data=to_struct(self.service.load_session_data(request.value)))
class SystemServicer(connpy_pb2_grpc.SystemServiceServicer): class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
def __init__(self, config): def __init__(self, provider, registry=None):
self.service = SystemService(config) if not hasattr(provider, "mode"):
from connpy.services.provider import ServiceProvider
provider = ServiceProvider(provider, mode="local")
self._fallback_provider = provider
self._registry = registry
def _get_provider(self):
if self._registry:
username = _current_user.get()
if username:
return self._registry.get_provider(username)
return self._fallback_provider
@property
def service(self):
return self._get_provider().system
@handle_errors @handle_errors
def start_api(self, request, context): def start_api(self, request, context):
@@ -1023,6 +1186,138 @@ class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
def get_api_status(self, request, context): def get_api_status(self, request, context):
return connpy_pb2.BoolResponse(value=self.service.get_api_status()) return connpy_pb2.BoolResponse(value=self.service.get_api_status())
class AuthServicer(connpy_pb2_grpc.AuthServiceServicer):
def __init__(self, registry):
self.registry = registry
@handle_errors
def login(self, request, context):
username = request.username
password = request.password
if not self.registry.user_service.authenticate(username, password):
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Invalid username or password")
token = self.registry.user_service.generate_jwt(username)
expires_at = int((datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)).timestamp())
return connpy_pb2.LoginResponse(
token=token,
username=username,
expires_at=expires_at
)
@handle_errors
def change_password(self, request, context):
username = _current_user.get()
if not username:
context.abort(grpc.StatusCode.UNAUTHENTICATED, "Authentication required")
try:
self.registry.user_service.change_password(username, request.old_password, request.new_password)
self.registry.evict(username)
except ValueError as e:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(e))
return Empty()
class AuthInterceptor(grpc.ServerInterceptor):
OPEN_METHODS = ["/connpy.AuthService/login"]
def __init__(self, registry):
self.registry = registry
def intercept_service(self, continuation, handler_call_details):
method = handler_call_details.method
if method in self.OPEN_METHODS:
return continuation(handler_call_details)
if not self.registry.has_users():
return continuation(handler_call_details)
token = self._extract_token(handler_call_details.invocation_metadata)
if not token:
return self._unauthenticated_handler(handler_call_details, "Authorization token is missing")
username = self.registry.user_service.verify_jwt(token)
if not username:
return self._unauthenticated_handler(handler_call_details, "Invalid or expired token")
handler = continuation(handler_call_details)
if handler is None:
return None
return self._wrap_handler(handler, username)
def _wrap_handler(self, handler, username):
if handler.unary_unary:
original_behavior = handler.unary_unary
def wrapper(request, context):
token = _current_user.set(username)
try:
return original_behavior(request, context)
finally:
_current_user.reset(token)
return grpc.unary_unary_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.unary_stream:
original_behavior = handler.unary_stream
def wrapper(request, context):
token = _current_user.set(username)
try:
for response in original_behavior(request, context):
yield response
finally:
_current_user.reset(token)
return grpc.unary_stream_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.stream_unary:
original_behavior = handler.stream_unary
def wrapper(request_iterator, context):
token = _current_user.set(username)
try:
return original_behavior(request_iterator, context)
finally:
_current_user.reset(token)
return grpc.stream_unary_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
elif handler.stream_stream:
original_behavior = handler.stream_stream
def wrapper(request_iterator, context):
token = _current_user.set(username)
try:
for response in original_behavior(request_iterator, context):
yield response
finally:
_current_user.reset(token)
return grpc.stream_stream_rpc_method_handler(
wrapper,
request_deserializer=handler.request_deserializer,
response_serializer=handler.response_serializer,
)
return handler
def _extract_token(self, metadata):
for key, value in metadata:
if key.lower() == "authorization":
if value.startswith("Bearer "):
return value[7:]
return None
def _unauthenticated_handler(self, handler_call_details, message):
def abort_call(request_or_iterator, context):
context.abort(grpc.StatusCode.UNAUTHENTICATED, message)
return grpc.unary_unary_rpc_method_handler(abort_call)
class LoggingInterceptor(grpc.ServerInterceptor): class LoggingInterceptor(grpc.ServerInterceptor):
def __init__(self): def __init__(self):
from rich.console import Console from rich.console import Console
@@ -1047,19 +1342,30 @@ class LoggingInterceptor(grpc.ServerInterceptor):
return result return result
def serve(config, port=8048, debug=False): def serve(config, port=8048, debug=False):
interceptors = [LoggingInterceptor()] if debug else [] from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
fallback_provider = ServiceProvider(config, mode="local")
registry = UserRegistry(config.defaultdir)
interceptors = []
if debug:
interceptors.append(LoggingInterceptor())
interceptors.append(AuthInterceptor(registry))
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors) server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config, debug=debug), server) connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(fallback_provider, registry=registry, debug=debug), server)
connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(config), server) connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server) connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(fallback_provider, registry=registry), server)
plugin_servicer = PluginServicer(config) plugin_servicer = PluginServicer(fallback_provider, registry=registry)
connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server) connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server)
remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server) remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server)
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(config), server) connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(config), server) connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(config), server) connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(fallback_provider, registry=registry, debug=debug), server)
connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(config), server) connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(fallback_provider, registry=registry), server)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(registry), server)
server.add_insecure_port(f'[::]:{port}') server.add_insecure_port(f'[::]:{port}')
server.start() server.start()
+81 -7
View File
@@ -462,16 +462,18 @@ class NodeStub:
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def move_node(self, src_id, dst_id, copy=False): def move_node(self, src_id, dst_id, copy=False):
@@ -895,9 +897,6 @@ class AIStub:
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get("response"):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -980,3 +979,78 @@ class SystemStub:
@handle_errors @handle_errors
def get_api_status(self): def get_api_status(self):
return self.stub.get_api_status(Empty()).value return self.stub.get_api_status(Empty()).value
class _ClientCallDetails(object):
def __init__(self, method, timeout, metadata, credentials, wait_for_ready, compression=None):
self.method = method
self.timeout = timeout
self.metadata = metadata
self.credentials = credentials
self.wait_for_ready = wait_for_ready
self.compression = compression
class AuthClientInterceptor(grpc.UnaryUnaryClientInterceptor,
grpc.UnaryStreamClientInterceptor,
grpc.StreamUnaryClientInterceptor,
grpc.StreamStreamClientInterceptor):
def __init__(self, token_provider):
self.token_provider = token_provider
def _add_metadata(self, client_call_details):
token = self.token_provider()
if not token:
return client_call_details
metadata = []
if client_call_details.metadata:
metadata = list(client_call_details.metadata)
# Check if already present to avoid duplicates
if not any(k.lower() == "authorization" for k, v in metadata):
metadata.append(("authorization", f"Bearer {token}"))
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=client_call_details.wait_for_ready,
compression=client_call_details.compression,
)
def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
class AuthStub:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.AuthServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
"token": resp.token,
"username": resp.username,
"expires_at": resp.expires_at
}
@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)
+107
View File
@@ -0,0 +1,107 @@
import os
import threading
from connpy.configfile import configfile
from connpy.services.provider import ServiceProvider
from connpy.services.user_service import UserService
class UserRegistry:
"""Holds per-user ServiceProviders in memory, thread-safe with hot-reloading."""
def __init__(self, server_config_dir):
self.server_config_dir = os.path.abspath(server_config_dir)
self.user_service = UserService(self.server_config_dir)
self._providers = {} # username → ServiceProvider
self._mtimes = {} # username → last loaded mtime (float)
self._lock = threading.Lock()
# Load shared/global config
self._shared_conf_file = os.path.join(self.server_config_dir, "config.yaml")
if os.path.exists(self._shared_conf_file):
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = os.path.getmtime(self._shared_conf_file)
else:
self._shared_config = None
self._shared_mtime = 0.0
def _refresh_shared(self):
"""Hot-reload shared config if the file changed on disk."""
if not os.path.exists(self._shared_conf_file):
return
current_mtime = os.path.getmtime(self._shared_conf_file)
if current_mtime > self._shared_mtime:
try:
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = current_mtime
# Clear all user providers so they pick up the new shared config
self._providers.clear()
self._mtimes.clear()
except Exception as e:
from connpy import printer
printer.warning(f"Failed to reload shared config: {e}")
def get_provider(self, username) -> ServiceProvider:
"""Get, lazy-load, or hot-reload a user's full ServiceProvider."""
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user's config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get("config_path")
if config_path:
conf_file = os.path.join(config_path, "config.yaml")
else:
conf_file = os.path.join(self.server_config_dir, "users", username, "config.yaml")
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) < current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode="local")
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, "close"):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f"Failed to hot-reload config for user '{username}' (file may be corrupt/incomplete): {e}")
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]
def has_users(self) -> bool:
"""Check if any users are registered (enables auth enforcement)."""
return bool(self.user_service.list_users())
def evict(self, username):
"""Remove and cleanly shut down cached provider (after delete or password change)."""
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, "close"):
try:
provider.close()
except Exception:
pass
+4 -1
View File
@@ -48,7 +48,10 @@ class MCPClientManager:
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "get_effective_setting"):
mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {})
else:
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {}
except Exception: except Exception:
return [] return []
+21
View File
@@ -296,3 +296,24 @@ message MCPRequest {
string auto_load_on_os = 4; string auto_load_on_os = 4;
bool remove = 5; bool remove = 5;
} }
service AuthService {
rpc login (LoginRequest) returns (LoginResponse) {}
rpc change_password (ChangePasswordRequest) returns (google.protobuf.Empty) {}
}
message LoginRequest {
string username = 1;
string password = 2;
}
message LoginResponse {
string token = 1;
string username = 2;
int64 expires_at = 3;
}
message ChangePasswordRequest {
string old_password = 1;
string new_password = 2;
}
+4 -1
View File
@@ -307,7 +307,10 @@ class AIService(BaseService):
def list_mcp_servers(self) -> dict: def list_mcp_servers(self) -> dict:
"""Get the configured MCP servers.""" """Get the configured MCP servers."""
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "get_effective_setting"):
ai_settings = self.config.get_effective_setting("ai", {})
else:
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
return ai_settings.get("mcp_servers", {}) return ai_settings.get("mcp_servers", {})
def load_session_data(self, session_id): def load_session_data(self, session_id):
+6 -4
View File
@@ -148,7 +148,7 @@ class NodeService(BaseService):
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
"""Explicitly update an existing node.""" """Explicitly update an existing node."""
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -162,9 +162,10 @@ class NodeService(BaseService):
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
"""Logic for deleting a node or folder.""" """Logic for deleting a node or folder."""
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -177,7 +178,8 @@ class NodeService(BaseService):
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.") raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
"""Interact with a node directly.""" """Interact with a node directly."""
+124 -41
View File
@@ -7,16 +7,47 @@ from .exceptions import InvalidConfigurationError, NodeNotFoundError
class PluginService(BaseService): class PluginService(BaseService):
"""Business logic for enabling, disabling, and listing plugins.""" """Business logic for enabling, disabling, and listing plugins."""
def _get_plugin_path(self, name, include_disabled=True):
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "user", True
if include_disabled:
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "user", False
# 2. Shared/Global directory
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "shared", True
if include_disabled:
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
if os.path.exists(bkp_file):
return bkp_file, "shared", False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
p_file = os.path.join(core_dir, f"{name}.py")
if os.path.exists(p_file):
return p_file, "core", True
return None, None, False
def list_plugins(self): def list_plugins(self):
"""List all core and user-defined plugins with their status and hash.""" """List all core and user-defined plugins with their status and hash."""
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -26,12 +57,35 @@ class PluginService(BaseService):
except Exception: except Exception:
return "" return ""
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(".py"): if f.endswith(".py"):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, "_shared_config") and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"):
name = f[:-7]
all_plugin_info[name] = {"enabled": False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, "plugins")
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(".py"):
name = f[:-3]
path = os.path.join(user_dir, f)
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)} all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
elif f.endswith(".py.bkp"): elif f.endswith(".py.bkp"):
name = f[:-7] name = f[:-7]
@@ -39,6 +93,7 @@ class PluginService(BaseService):
return all_plugin_info return all_plugin_info
def add_plugin(self, name, source_file, update=False): def add_plugin(self, name, source_file, update=False):
"""Add or update a plugin from a local file.""" """Add or update a plugin from a local file."""
import os import os
@@ -119,6 +174,10 @@ class PluginService(BaseService):
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}") raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
if not deleted: if not deleted:
# If not deleted from user directory, check if it's in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in ["shared", "core"]:
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
raise InvalidConfigurationError(f"Plugin '{name}' not found.") raise InvalidConfigurationError(f"Plugin '{name}' not found.")
def enable_plugin(self, name): def enable_plugin(self, name):
@@ -127,17 +186,38 @@ class PluginService(BaseService):
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp" disabled_file = f"{plugin_file}.bkp"
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
if os.path.exists(plugin_file): if os.path.exists(plugin_file):
return False # Already enabled return False # Already enabled
if not os.path.exists(disabled_file): # If it doesn't exist locally, check if it's already an active shared/core plugin
raise InvalidConfigurationError(f"Plugin '{name}' not found.") path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
return False # Already active/enabled through inheritance
try: raise InvalidConfigurationError(f"Plugin '{name}' not found.")
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
def disable_plugin(self, name): def disable_plugin(self, name):
"""Deactivate a plugin by renaming it to a backup file.""" """Deactivate a plugin by renaming it to a backup file."""
@@ -145,33 +225,41 @@ class PluginService(BaseService):
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
disabled_file = f"{plugin_file}.bkp" disabled_file = f"{plugin_file}.bkp"
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
if os.path.exists(disabled_file): if os.path.exists(disabled_file):
return False # Already disabled return False # Already disabled
if not os.path.exists(plugin_file): # Check if it exists in shared or core
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.") path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in ["shared", "core"]:
try: # Shadow disable it by creating an empty .py.bkp in user plugins dir
os.rename(plugin_file, disabled_file) plugin_dir = os.path.dirname(plugin_file)
return True os.makedirs(plugin_dir, exist_ok=True)
except OSError as e: try:
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}") with open(disabled_file, "w") as f:
f.write("")
return True
except OSError as e:
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
def get_plugin_source(self, name): def get_plugin_source(self, name):
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py") path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py" if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found") raise InvalidConfigurationError(f"Plugin '{name}' not found")
with open(target, "r") as f: with open(path, "r") as f:
return f.read() return f.read()
def invoke_plugin(self, name, args_dict): def invoke_plugin(self, name, args_dict):
@@ -211,17 +299,12 @@ class PluginService(BaseService):
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f"Plugin '{name}' not found") raise InvalidConfigurationError(f"Plugin '{name}' not found")
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, "Parser") else None parser = module.Parser().parser if hasattr(module, "Parser") else None
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]): if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
+27 -1
View File
@@ -33,6 +33,7 @@ class ServiceProvider:
from .import_export_service import ImportExportService from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService 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)
@@ -44,6 +45,7 @@ class ServiceProvider:
self.import_export = ImportExportService(self.config) self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(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
@@ -53,15 +55,38 @@ class ServiceProvider:
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.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")
import grpc import grpc
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub import os
from ..grpc_layer.stubs import (
NodeStub, ProfileStub, PluginStub, AIStub,
ExecutionStub, ImportExportStub, SystemStub,
ConfigStub, AuthClientInterceptor, AuthStub
)
def get_token():
token_path = os.path.join(self.config.defaultdir, ".token")
if os.path.exists(token_path):
try:
with open(token_path, "r") as f:
return f.read().strip()
except Exception:
pass
return None
channel = grpc.insecure_channel(self.remote_host) channel = grpc.insecure_channel(self.remote_host)
interceptor = AuthClientInterceptor(get_token)
channel = grpc.intercept_channel(channel, interceptor)
# Surgical fix: Keep ConfigService local for mode/theme management,
# but delegate encryption to the server stub.
config_remote = ConfigStub(channel, remote_host=self.remote_host)
self.config_svc.encrypt_password = config_remote.encrypt_password
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config) self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes) self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
self.plugins = PluginStub(channel, remote_host=self.remote_host) self.plugins = PluginStub(channel, remote_host=self.remote_host)
@@ -69,3 +94,4 @@ class ServiceProvider:
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)
+237
View File
@@ -0,0 +1,237 @@
import os
import re
import shutil
import secrets
import datetime
import bcrypt
import jwt
import yaml
from pathlib import Path
from connpy.configfile import configfile
class UserService:
def __init__(self, config_dir):
self.config_dir = os.path.abspath(config_dir)
self.users_dir = os.path.join(self.config_dir, "users")
self.registry_file = os.path.join(self.users_dir, "registry.yaml")
# Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True)
def _load_registry(self) -> dict:
"""Loads registry from file. If it doesn't exist, initializes it with a new JWT secret."""
if not os.path.exists(self.registry_file):
registry = {
"jwt_secret": secrets.token_hex(32),
"users": {}
}
self._save_registry(registry)
return registry
try:
with open(self.registry_file, "r") as f:
registry = yaml.safe_load(f) or {}
except Exception:
registry = {}
if not isinstance(registry, dict):
registry = {}
if "jwt_secret" not in registry:
registry["jwt_secret"] = secrets.token_hex(32)
if "users" not in registry or not isinstance(registry["users"], dict):
registry["users"] = {}
return registry
def _save_registry(self, data: dict):
"""Safely saves registry structure to registry.yaml."""
tmp_file = self.registry_file + ".tmp"
try:
with open(tmp_file, "w") as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp_file, self.registry_file)
os.chmod(self.registry_file, 0o600)
except Exception as e:
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except OSError:
pass
raise e
def create_user(self, username, password, config_path=None) -> dict:
"""Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -> Generates config.yaml and .osk key.
Mode B: config_path set -> Reuses existing directory after validating its structure.
"""
if not username or not isinstance(username, str):
raise ValueError("Username cannot be empty")
if not re.match(r"^[a-zA-Z0-9_-]+$", username):
raise ValueError("Username must contain only alphanumeric characters, dashes, or underscores")
if not password or not isinstance(password, str):
raise ValueError("Password cannot be empty")
registry = self._load_registry()
if username in registry["users"]:
raise ValueError(f"User '{username}' already exists")
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, "plugins"), exist_ok=True)
os.makedirs(os.path.join(user_dir, "ai_sessions"), exist_ok=True)
# Create default config.yaml & .osk key via configfile
conf_file = os.path.join(user_dir, "config.yaml")
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, "plugins"), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, "ai_sessions"), exist_ok=True)
# Create default config.yaml & .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, "config.yaml")
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
user_entry = {
"password_hash": password_hash,
"config_path": stored_config_path,
"created": datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry["users"][username] = user_entry
self._save_registry(registry)
return {
"username": username,
"config_path": stored_config_path,
"created": user_entry["created"]
}
def delete_user(self, username):
"""Removes user from the registry and cleans up config directory if server-managed."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
config_path = user_data.get("config_path")
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry["users"][username]
self._save_registry(registry)
def list_users(self) -> list[dict]:
"""Lists all registered users with metadata."""
registry = self._load_registry()
return [
{
"username": name,
"config_path": data.get("config_path"),
"created": data.get("created")
}
for name, data in registry.get("users", {}).items()
]
def get_user(self, username) -> dict:
"""Retrieves raw metadata for a specific user."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
data = registry["users"][username]
return {
"username": username,
"config_path": data.get("config_path"),
"created": data.get("created"),
"password_hash": data.get("password_hash")
}
def change_password(self, username, old_password, new_password):
"""Verifies old password and updates registry with new hashed password."""
if not new_password or not isinstance(new_password, str):
raise ValueError("New password cannot be empty")
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
if not bcrypt.checkpw(old_password.encode("utf-8"), user_data["password_hash"].encode("utf-8")):
raise ValueError("Invalid credentials")
# Update hash
user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
self._save_registry(registry)
def admin_change_password(self, username, new_password):
"""Administrative password override (does not require old password)."""
if not new_password or not isinstance(new_password, str):
raise ValueError("New password cannot be empty")
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
user_data = registry["users"][username]
user_data["password_hash"] = bcrypt.hashpw(new_password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
self._save_registry(registry)
def authenticate(self, username, password) -> bool:
"""Verifies if the credentials are valid using bcrypt."""
registry = self._load_registry()
if username not in registry["users"]:
return False
user_data = registry["users"][username]
return bcrypt.checkpw(password.encode("utf-8"), user_data["password_hash"].encode("utf-8"))
def generate_jwt(self, username) -> str:
"""Generates a secure JSON Web Token for the user expiring in 8 hours."""
registry = self._load_registry()
if username not in registry["users"]:
raise ValueError(f"User '{username}' not found")
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
payload = {
"sub": username,
"exp": expiration
}
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
return token
def verify_jwt(self, token) -> str | None:
"""Decodes JWT and returns username if token is valid and unexpired."""
registry = self._load_registry()
try:
payload = jwt.decode(token, registry["jwt_secret"], algorithms=["HS256"])
return payload.get("sub")
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None
+239
View File
@@ -0,0 +1,239 @@
import os
import pytest
import grpc
import argparse
from unittest.mock import MagicMock, patch
from connpy.connapp import connapp
from connpy.services.provider import ServiceProvider
from connpy.cli.user_handler import UserHandler
from connpy.cli.login_handler import LoginHandler
from connpy.grpc_layer.stubs import AuthClientInterceptor, AuthStub
@pytest.fixture
def mock_config():
config = MagicMock()
config.config = {"service_mode": "local", "remote_host": "localhost:8048"}
config.defaultdir = "/mock/default/dir"
return config
@pytest.fixture
def app_instance(mock_config):
with patch("connpy.services.provider.ServiceProvider") as mock_provider_cls:
mock_provider = MagicMock()
mock_provider.context = MagicMock()
mock_provider.nodes = MagicMock()
mock_provider.profiles = MagicMock()
mock_provider.config_svc = MagicMock()
mock_provider.plugins = MagicMock()
mock_provider.sync = MagicMock()
mock_provider.mode = "local"
mock_provider.remote_host = "localhost:8048"
mock_provider_cls.return_value = mock_provider
app = connapp(mock_config)
# Mock UserService on app services
app.services.users = MagicMock()
return app
class TestCLIMultiUserParsing:
def test_parser_contains_user_login_logout(self, app_instance):
parser, _ = app_instance.get_parser()
# Verify subcommands exist by finding the _SubParsersAction
subparsers_action = None
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
subparsers_action = action
break
assert subparsers_action is not None
subcommands = subparsers_action.choices.keys()
assert "user" in subcommands
assert "login" in subcommands
assert "logout" in subcommands
def test_user_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
# Parse add user
args = parser.parse_args(["user", "--add", "newguy"])
assert args.add == ["newguy"]
assert args.func == app_instance._user.dispatch
# Parse delete user
args = parser.parse_args(["user", "--del", "oldguy"])
assert args.delete == ["oldguy"]
# Parse list users
args = parser.parse_args(["user", "--list"])
assert args.list is True
# Parse show user
args = parser.parse_args(["user", "--show", "someguy"])
assert args.show == ["someguy"]
# Parse regen-password
args = parser.parse_args(["user", "--regen-password", "someguy"])
assert args.regen_password == ["someguy"]
# Parse path
args = parser.parse_args(["user", "--add", "newguy", "--path", "/some/path"])
assert args.add == ["newguy"]
assert args.path == ["/some/path"]
def test_login_logout_parser_arguments(self, app_instance):
parser, _ = app_instance.get_parser()
args = parser.parse_args(["login", "someuser"])
assert args.username == "someuser"
assert args.status is False
assert args.func == app_instance._login.dispatch
args = parser.parse_args(["login", "--status"])
assert args.status is True
args = parser.parse_args(["login", "-s"])
assert args.status is True
args = parser.parse_args(["logout"])
assert args.func == app_instance._login.dispatch
class TestUserHandlerDispatch:
def test_user_handler_fails_in_remote_mode(self, app_instance):
app_instance.services.mode = "remote"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["testuser"]
with pytest.raises(SystemExit) as excinfo:
handler.dispatch(args)
assert excinfo.value.code == 1
def test_user_handler_routes_add_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = ["newuser"]
args.delete = None
args.list = False
args.show = None
args.regen_password = None
with patch.object(handler, "add_user") as mock_add:
handler.dispatch(args)
assert args.action == "add"
assert args.username == "newuser"
mock_add.assert_called_once_with(args)
def test_user_handler_routes_list_correctly(self, app_instance):
app_instance.services.mode = "local"
handler = UserHandler(app_instance)
args = MagicMock()
args.add = None
args.delete = None
args.list = True
args.show = None
args.regen_password = None
with patch.object(handler, "list_users") as mock_list:
handler.dispatch(args)
assert args.action == "list"
mock_list.assert_called_once_with(args)
class TestAuthClientInterceptor:
def test_auth_client_interceptor_adds_bearer_token(self):
# Mock token provider
token_provider = MagicMock(return_value="my-super-secret-token")
interceptor = AuthClientInterceptor(token_provider)
# Mock ClientCallDetails using namedtuple
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata was injected
metadata_dict = dict(intercepted_details.metadata)
assert "authorization" in metadata_dict
assert metadata_dict["authorization"] == "Bearer my-super-secret-token"
def test_auth_client_interceptor_no_token(self):
token_provider = MagicMock(return_value=None)
interceptor = AuthClientInterceptor(token_provider)
from collections import namedtuple
ClientCallDetails = namedtuple('ClientCallDetails', ['method', 'timeout', 'metadata', 'credentials', 'wait_for_ready', 'compression'])
mock_details = ClientCallDetails(
method="/connpy.NodeService/list_nodes",
timeout=10,
metadata=[],
credentials=None,
wait_for_ready=True,
compression=None
)
intercepted_details = interceptor._add_metadata(mock_details)
# Verify metadata remains empty
assert len(intercepted_details.metadata) == 0
class TestLoginHandlerStatus:
def test_status_no_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=False):
with patch("connpy.printer.warning") as mock_warning:
handler.show_status()
mock_warning.assert_called_once_with("No active session found. You can log in using 'connpy login'.")
def test_status_invalid_token(self, app_instance):
handler = LoginHandler(app_instance)
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data="invalid-token")):
with patch("connpy.printer.error") as mock_error:
handler.show_status()
mock_error.assert_called_once_with("Invalid local session token format.")
def test_status_valid_token(self, app_instance):
handler = LoginHandler(app_instance)
# Mock token payload: {"sub": "testuser", "exp": 1780007003}
# Part 1 (header): eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
# Part 2 (payload): eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30
# Part 3 (sig): signature
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0dXNlciIsImV4cCI6MTc4MDAwNzAwM30.signature"
with patch("os.path.exists", return_value=True):
with patch("builtins.open", mock_open(read_data=token)):
with patch("connpy.printer.success") as mock_success:
with patch("connpy.printer.info") as mock_info:
# Patch time so exp is in the future
with patch("datetime.datetime") as mock_dt:
mock_dt.now.return_value.timestamp.return_value = 1780000000
# Mock fromtimestamp for expiration display
mock_dt.fromtimestamp.return_value.strftime.return_value = "2026-05-28 19:23:23 UTC"
handler.show_status()
mock_success.assert_called_once_with("Logged in as 'testuser'")
def mock_open(*args, **kwargs):
from unittest.mock import mock_open as unittest_mock_open
return unittest_mock_open(*args, **kwargs)
+58
View File
@@ -141,4 +141,62 @@ class TestTreeCompletions:
assert "stop" in loop_back_comp assert "stop" in loop_back_comp
class TestUserCompletions:
def test_user_command_options(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test options at the "user" level
user_completions = resolve_completion(["user", ""], tree)
assert "--add" in user_completions
assert "--del" in user_completions
assert "--rm" in user_completions
assert "--show" in user_completions
assert "--regen-password" in user_completions
assert "--list" in user_completions
assert "--ls" in user_completions
def test_user_action_completed_users(self, tmp_path):
from connpy.completion import _build_tree, resolve_completion
import yaml
# Create users directory and mock registry
users_dir = tmp_path / "users"
users_dir.mkdir()
registry_file = users_dir / "registry.yaml"
registry_data = {
"users": {
"fluzzi": {"password_hash": "hash1"},
"john": {"password_hash": "hash2"}
}
}
with open(registry_file, "w") as f:
yaml.dump(registry_data, f)
tree = _build_tree([], [], [], {}, str(tmp_path))
# Resolve after --del, --rm, --show, --regen-password
for action in ["--del", "--rm", "--show", "--regen-password"]:
completions = resolve_completion(["user", action, ""], tree)
assert "fluzzi" in completions
assert "john" in completions
# --add username completed options
add_completions = resolve_completion(["user", "--add", "newguy", ""], tree)
assert "--path" in add_completions
def test_login_logout_completions(self):
from connpy.completion import _build_tree, resolve_completion
tree = _build_tree([], [], [], {}, "/tmp")
# Test login option resolution
login_completions = resolve_completion(["login", ""], tree)
assert "--help" in login_completions
# Test logout option resolution
logout_completions = resolve_completion(["logout", ""], tree)
assert "--help" in logout_completions
+13 -3
View File
@@ -40,7 +40,7 @@ def test_node_del(mock_prompt, mock_delete_node, mock_list_nodes, app):
mock_list_nodes.return_value = ["router1"] mock_list_nodes.return_value = ["router1"]
mock_prompt.return_value = {"delete": True} mock_prompt.return_value = {"delete": True}
app.start(["node", "-r", "router1"]) app.start(["node", "-r", "router1"])
mock_delete_node.assert_called_once_with("router1", is_folder=False) mock_delete_node.assert_called_once_with("router1", is_folder=False, save=True)
@patch("connpy.services.node_service.NodeService.list_nodes") @patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.get_node_details") @patch("connpy.services.node_service.NodeService.get_node_details")
@@ -165,9 +165,9 @@ def test_ai(mock_status, mock_ask, app):
@patch("connpy.services.execution_service.ExecutionService.run_commands") @patch("connpy.services.execution_service.ExecutionService.run_commands")
def test_run(mock_run_commands, app): def test_run(mock_run_commands, app):
app.start(["run", "node1", "command1", "command2"]) app.start(["run", "router1", "command1", "command2"])
mock_run_commands.assert_called_once() mock_run_commands.assert_called_once()
assert mock_run_commands.call_args[1]["nodes_filter"] == "node1" assert mock_run_commands.call_args[1]["nodes_filter"] == ["router1"]
assert mock_run_commands.call_args[1]["commands"] == ["command1 command2"] assert mock_run_commands.call_args[1]["commands"] == ["command1 command2"]
@patch("os.path.exists") @patch("os.path.exists")
@@ -314,3 +314,13 @@ def test_config_auth_file_path(mock_get_settings, mock_update_setting, mock_open
assert args[1]["engineer_auth"] == {"vertex_project": "file-project"} assert args[1]["engineer_auth"] == {"vertex_project": "file-project"}
@patch("connpy.services.node_service.NodeService.list_nodes")
@patch("connpy.services.node_service.NodeService.connect_node")
def test_node_connect_exact_match_priority(mock_connect_node, mock_list_nodes, app):
"""Test that exact matches are prioritized over partial/regex matches when connecting."""
mock_list_nodes.return_value = ["pe1@ctx", "qro1pe1@ctx"]
app.start(["node", "pe1@ctx"])
mock_connect_node.assert_called_once_with("pe1@ctx", sftp=False, debug=False, logger=app._service_logger)
+131
View File
@@ -0,0 +1,131 @@
import os
import pytest
import grpc
from concurrent import futures
from google.protobuf.empty_pb2 import Empty
from connpy.grpc_layer import server, connpy_pb2, connpy_pb2_grpc, stubs
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
from connpy.configfile import configfile
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing gRPC auth."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
# Initialize basic config file inside it
from connpy.configfile import configfile
conf_file = os.path.join(str(config_dir), "config.yaml")
configfile(conf=conf_file)
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry."""
return UserRegistry(str(test_config_dir))
@pytest.fixture
def auth_grpc_server(test_config_dir, registry):
"""Starts an authenticated local gRPC server for integration testing."""
srv = grpc.server(
futures.ThreadPoolExecutor(max_workers=5),
interceptors=[server.AuthInterceptor(registry)]
)
fallback_provider = ServiceProvider(configfile(conf=os.path.join(str(test_config_dir), "config.yaml")), mode="local")
# Register services
connpy_pb2_grpc.add_NodeServiceServicer_to_server(server.NodeServicer(fallback_provider, registry=registry), srv)
connpy_pb2_grpc.add_AuthServiceServicer_to_server(server.AuthServicer(registry), srv)
port = srv.add_insecure_port('127.0.0.1:0')
srv.start()
yield f"127.0.0.1:{port}"
srv.stop(0)
@pytest.fixture
def channel(auth_grpc_server):
with grpc.insecure_channel(auth_grpc_server) as channel:
yield channel
class TestGRPCAuthentication:
def test_backward_compatibility_no_users(self, channel, registry):
"""Verifies that if no users are registered, gRPC calls proceed without authentication."""
assert registry.has_users() is False
# Calling NodeService list_nodes should succeed without any authorization metadata
stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
res = stub.list_nodes(req)
assert res is not None
def test_login_and_authenticated_calls(self, channel, registry):
"""Tests user creation, login to retrieve JWT, and using JWT to access protected endpoints."""
username = "alice"
password = "alicepassword"
# 1. Register a user in the registry
registry.user_service.create_user(username, password)
assert registry.has_users() is True
# 2. Try unauthenticated call - must fail with UNAUTHENTICATED
node_stub = connpy_pb2_grpc.NodeServiceStub(channel)
req = connpy_pb2.FilterRequest()
with pytest.raises(grpc.RpcError) as exc:
node_stub.list_nodes(req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Authorization token is missing" in exc.value.details()
# 3. Call login endpoint (open method) - must succeed
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username=username, password=password)
login_res = auth_stub.login(login_req)
assert login_res.username == username
assert isinstance(login_res.token, str)
assert login_res.expires_at > 0
# 4. Make authenticated call using Bearer token - must succeed
metadata = [("authorization", f"Bearer {login_res.token}")]
res = node_stub.list_nodes(req, metadata=metadata)
assert res is not None
def test_login_invalid_credentials(self, channel, registry):
"""Verifies login fails and returns UNAUTHENTICATED for incorrect credentials."""
registry.user_service.create_user("bob", "bobpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
login_req = connpy_pb2.LoginRequest(username="bob", password="wrongpassword")
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(login_req)
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
assert "Invalid username or password" in exc.value.details()
def test_change_password(self, channel, registry):
"""Tests changing password via gRPC and verifying old password no longer works."""
username = "charlie"
registry.user_service.create_user(username, "oldpass")
auth_stub = connpy_pb2_grpc.AuthServiceStub(channel)
# 1. Login with old password to get token
login_res = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
token = login_res.token
# 2. Change password via gRPC using the token
metadata = [("authorization", f"Bearer {token}")]
change_req = connpy_pb2.ChangePasswordRequest(old_password="oldpass", new_password="newpass")
auth_stub.change_password(change_req, metadata=metadata)
# 3. Logging in with old password must fail
with pytest.raises(grpc.RpcError) as exc:
auth_stub.login(connpy_pb2.LoginRequest(username=username, password="oldpass"))
assert exc.value.code() == grpc.StatusCode.UNAUTHENTICATED
# 4. Logging in with new password must succeed
login_res_new = auth_stub.login(connpy_pb2.LoginRequest(username=username, password="newpass"))
assert login_res_new.token is not None
+67
View File
@@ -0,0 +1,67 @@
import os
import pytest
from connpy.grpc_layer.server import NodeServicer, _current_user
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
def test_dynamic_routing_isolation(test_config_dir, registry):
"""Verifies that NodeServicer routes list_nodes to the correct user configuration based on _current_user ContextVar."""
# Setup fallback provider
from connpy.configfile import configfile
conf_file = os.path.join(registry.user_service.config_dir, "config.yaml")
config = configfile(conf=conf_file)
fallback_provider = ServiceProvider(config, mode="local")
# Create servicer with fallback and registry
servicer = NodeServicer(fallback_provider, registry=registry)
# Register two users
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
# Add nodes to each user's provider
p1.nodes.add_node("node-for-user-1", {"host": "1.1.1.1"})
p2.nodes.add_node("node-for-user-2", {"host": "2.2.2.2"})
# Verify fallback is empty
fallback_res = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
from connpy.grpc_layer.utils import from_value
assert "node-for-user-1" not in from_value(fallback_res.data)
assert "node-for-user-2" not in from_value(fallback_res.data)
# Set context to User 1
t1 = _current_user.set(u1)
try:
res1 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes1 = from_value(res1.data)
assert "node-for-user-1" in nodes1
assert "node-for-user-2" not in nodes1
finally:
_current_user.reset(t1)
# Set context to User 2
t2 = _current_user.set(u2)
try:
res2 = servicer.list_nodes(type('Request', (), {'filter_str': None, 'format_str': None})(), None)
nodes2 = from_value(res2.data)
assert "node-for-user-2" in nodes2
assert "node-for-user-1" not in nodes2
finally:
_current_user.reset(t2)
+198
View File
@@ -0,0 +1,198 @@
import os
import shutil
import pytest
from connpy.configfile import configfile
from connpy.services.plugin_service import PluginService
from connpy.services.exceptions import InvalidConfigurationError
@pytest.fixture
def temp_plugins_env(tmp_path):
"""Creates a temporary isolated environment for core, shared, and user plugins."""
base_dir = tmp_path / "plugins_test_env"
base_dir.mkdir()
# Paths for shared config and user config folders
shared_dir = base_dir / "shared"
user_dir = base_dir / "user"
shared_dir.mkdir()
user_dir.mkdir()
# Create plugins subdirectories
(shared_dir / "plugins").mkdir()
(user_dir / "plugins").mkdir()
# Mock core_plugins path by creating a sibling folder
core_dir = base_dir / "core_plugins"
core_dir.mkdir()
# Config file paths
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write empty config templates
import yaml
empty_conf = {"config": {}, "connections": {}, "profiles": {}}
with open(shared_path, "w") as f:
yaml.safe_dump(empty_conf, f)
with open(user_path, "w") as f:
yaml.safe_dump(empty_conf, f)
return {
"shared_dir": shared_dir,
"user_dir": user_dir,
"core_dir": core_dir,
"shared_path": shared_path,
"user_path": user_path
}
def test_plugin_resolution_priority_merge(temp_plugins_env, monkeypatch):
"""Test that list_plugins correctly merges core, shared, and user plugins with overrides."""
env = temp_plugins_env
# 1. Create a core plugin: 'coreplug'
core_file = env["core_dir"] / "coreplug.py"
with open(core_file, "w") as f:
f.write("# core plugin content")
# 2. Create a shared plugin: 'sharedplug'
shared_file = env["shared_dir"] / "plugins" / "sharedplug.py"
with open(shared_file, "w") as f:
f.write("# shared plugin content")
# 3. Create a user plugin: 'userplug'
user_file = env["user_dir"] / "plugins" / "userplug.py"
with open(user_file, "w") as f:
f.write("# user plugin content")
# 4. Create an override plugin: 'overrideplug' in all three directories
with open(env["core_dir"] / "overrideplug.py", "w") as f:
f.write("# core override version")
with open(env["shared_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# shared override version")
with open(env["user_dir"] / "plugins" / "overrideplug.py", "w") as f:
f.write("# user override version")
# Initialize configs
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
# Initialize service
plugin_svc = PluginService(user_cfg)
# Monkeypatch the core plugins folder path inside list_plugins
# in order to use our mock core folder instead of the real one.
# Note: real path is computed via __file__, so we'll mock the internal core path
monkeypatch.setattr(
"os.path.realpath",
lambda path: os.path.join(str(env["core_dir"]), "dummy")
)
plugins_list = plugin_svc.list_plugins()
# Verify all plugins are registered
assert "coreplug" in plugins_list
assert "sharedplug" in plugins_list
assert "userplug" in plugins_list
assert "overrideplug" in plugins_list
# Verify status is Active (enabled=True)
assert plugins_list["coreplug"]["enabled"] is True
assert plugins_list["sharedplug"]["enabled"] is True
assert plugins_list["userplug"]["enabled"] is True
assert plugins_list["overrideplug"]["enabled"] is True
# Verify hashes differ matching user overrides
import hashlib
user_override_hash = hashlib.md5(b"# user override version").hexdigest()
assert plugins_list["overrideplug"]["hash"] == user_override_hash
def test_get_plugin_source_override(temp_plugins_env, monkeypatch):
"""Test that get_plugin_source resolves the highest priority plugin version."""
env = temp_plugins_env
# Create override in shared and user
with open(env["shared_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("shared content")
with open(env["user_dir"] / "plugins" / "myplug.py", "w") as f:
f.write("user override")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Fetch source
source = plugin_svc.get_plugin_source("myplug")
assert source == "user override"
def test_delete_plugin_restrictions(temp_plugins_env):
"""Test that deleting shared plugins is rejected, but deleting user overrides works."""
env = temp_plugins_env
# Create shared plugin
with open(env["shared_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("global content")
# Create user plugin override
with open(env["user_dir"] / "plugins" / "globalplug.py", "w") as f:
f.write("user content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# 1. Delete plugin (should delete the user override first)
plugin_svc.delete_plugin("globalplug")
# Verify user override is gone, but shared plugin remains
assert not os.path.exists(env["user_dir"] / "plugins" / "globalplug.py")
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
# 2. Try to delete again (now only exists in shared/global folder)
with pytest.raises(InvalidConfigurationError) as exc:
plugin_svc.delete_plugin("globalplug")
assert "Global and core plugins are read-only" in str(exc.value)
# Verify shared plugin is still present
assert os.path.exists(env["shared_dir"] / "plugins" / "globalplug.py")
def test_shadow_disable_and_enable_mechanisms(temp_plugins_env):
"""Test that disabling a shared plugin creates a shadow backup file and enabling it removes it."""
env = temp_plugins_env
# Create a shared plugin
with open(env["shared_dir"] / "plugins" / "sharedplug.py", "w") as f:
f.write("shared content")
shared_cfg = configfile(conf=env["shared_path"])
user_cfg = configfile(conf=env["user_path"], shared_config=shared_cfg)
plugin_svc = PluginService(user_cfg)
# Ensure it's active initially
list_initial = plugin_svc.list_plugins()
assert list_initial["sharedplug"]["enabled"] is True
# 1. Disable the shared plugin (should shadow-disable it in user dir)
res = plugin_svc.disable_plugin("sharedplug")
assert res is True
# Verify shadow bkp file exists in user plugins and has 0 bytes
shadow_bkp = env["user_dir"] / "plugins" / "sharedplug.py.bkp"
assert os.path.exists(shadow_bkp)
assert os.path.getsize(shadow_bkp) == 0
# Verify list_plugins lists it as disabled
list_disabled = plugin_svc.list_plugins()
assert list_disabled["sharedplug"]["enabled"] is False
# 2. Re-enable the shadow-disabled plugin (should delete the user shadow file)
res_enable = plugin_svc.enable_plugin("sharedplug")
assert res_enable is True
# Verify shadow file is deleted
assert not os.path.exists(shadow_bkp)
# Verify list_plugins lists it as active again
list_active = plugin_svc.list_plugins()
assert list_active["sharedplug"]["enabled"] is True
+217
View File
@@ -0,0 +1,217 @@
import os
import time
import pytest
import yaml
from connpy.configfile import configfile
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def temp_config_dir(tmp_path):
"""Creates a temporary config directory for testing."""
config_dir = tmp_path / "conn_shared_test"
config_dir.mkdir()
return config_dir
def test_shared_ai_deep_merge(temp_config_dir):
"""Test get_effective_setting deep merge logic for 'ai' settings."""
shared_dir = os.path.join(temp_config_dir, "shared")
user_dir = os.path.join(temp_config_dir, "user")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Write shared configuration
shared_data = {
"config": {
"theme": "dark",
"case": False,
"ai": {
"engineer_model": "shared-eng-model",
"architect_model": "shared-arch-model",
"engineer_api_key": "shared-key",
"mcp_servers": {
"global-server": {
"url": "http://global-server/sse",
"enabled": True
},
"override-server": {
"url": "http://override-shared/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# Write user configuration with overrides
user_data = {
"config": {
"case": True,
"ai": {
"engineer_model": "user-custom-eng-model",
"mcp_servers": {
"override-server": {
"enabled": False
},
"user-server": {
"url": "http://user-server/sse",
"enabled": True
}
}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
# Initialize configfile instances
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
# Verify non-inheritable settings (theme, case)
assert user_config.get_effective_setting("case") is True
assert user_config.get_effective_setting("theme") is None # Should NOT inherit "theme"
# Verify AI setting deep merge
effective_ai = user_config.get_effective_setting("ai")
# Model override
assert effective_ai.get("engineer_model") == "user-custom-eng-model"
# Model inheritance
assert effective_ai.get("architect_model") == "shared-arch-model"
# API key inheritance
assert effective_ai.get("engineer_api_key") == "shared-key"
# MCP Servers merge
mcp = effective_ai.get("mcp_servers", {})
# Inherited server
assert "global-server" in mcp
assert mcp["global-server"]["url"] == "http://global-server/sse"
assert mcp["global-server"]["enabled"] is True
# Merged & overridden server
assert "override-server" in mcp
assert mcp["override-server"]["url"] == "http://override-shared/sse" # inherited
assert mcp["override-server"]["enabled"] is False # overridden
# User-only server
assert "user-server" in mcp
assert mcp["user-server"]["url"] == "http://user-server/sse"
def test_registry_injection_and_hot_reload(temp_config_dir):
"""Test that UserRegistry correctly injects shared config and hot-reloads it when it changes on disk."""
registry = UserRegistry(str(temp_config_dir))
# Define paths
shared_path = os.path.join(temp_config_dir, "config.yaml")
# 1. Create a global config file
global_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_model": "global-model"
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Re-init registry to pick up the newly created shared config file
registry = UserRegistry(str(temp_config_dir))
# Register user
username = "testuser"
registry.user_service.create_user(username, "testpassword")
# Check initial injection
provider = registry.get_provider(username)
ai_settings = provider.config.get_effective_setting("ai")
assert ai_settings.get("engineer_api_key") == "global-initial-key"
assert ai_settings.get("engineer_model") == "global-model"
# 2. Modify global config on disk
global_data["config"]["ai"]["engineer_api_key"] = "global-updated-key"
# Sleep briefly to ensure mtime change is detectable
time.sleep(0.1)
with open(shared_path, "w") as f:
yaml.safe_dump(global_data, f)
# Set the mtime forward explicitly to avoid filesystem resolution limits
new_mtime = os.path.getmtime(shared_path) + 10.0
os.utime(shared_path, (new_mtime, new_mtime))
# Retrieve provider again - should trigger hot-reload of shared config
provider2 = registry.get_provider(username)
ai_settings_updated = provider2.config.get_effective_setting("ai")
assert ai_settings_updated.get("engineer_api_key") == "global-updated-key"
assert ai_settings_updated.get("engineer_model") == "global-model"
def test_shared_ai_credential_isolation(temp_config_dir):
"""Test that setting user engineer/architect credentials discards corresponding shared credentials."""
shared_dir = os.path.join(temp_config_dir, "shared_isolation")
user_dir = os.path.join(temp_config_dir, "user_isolation")
os.makedirs(shared_dir, exist_ok=True)
os.makedirs(user_dir, exist_ok=True)
shared_path = os.path.join(shared_dir, "config.yaml")
user_path = os.path.join(user_dir, "config.yaml")
# Shared has both api_key and auth
shared_data = {
"config": {
"ai": {
"engineer_api_key": "global-initial-key",
"engineer_auth": {"vertex_project": "shared-project", "api_key": "shared-auth-key"},
"architect_api_key": "global-arch-key",
"architect_auth": {"project": "arch-project"}
}
},
"connections": {},
"profiles": {}
}
with open(shared_path, "w") as f:
yaml.safe_dump(shared_data, f)
# User configures ONLY engineer_api_key (expects engineer_auth to be discarded)
# and ONLY architect_auth (expects architect_api_key to be discarded)
user_data = {
"config": {
"ai": {
"engineer_api_key": "user-custom-key",
"architect_auth": {"project": "user-project", "api_key": "user-auth-key"}
}
},
"connections": {},
"profiles": {}
}
with open(user_path, "w") as f:
yaml.safe_dump(user_data, f)
shared_config = configfile(conf=shared_path)
user_config = configfile(conf=user_path, shared_config=shared_config)
effective_ai = user_config.get_effective_setting("ai")
# 1. Engineer: local api_key is present, so shared engineer_auth must be completely discarded
assert effective_ai.get("engineer_api_key") == "user-custom-key"
assert "engineer_auth" not in effective_ai
# 2. Architect: local auth is present, so shared architect_api_key must be completely discarded
assert effective_ai.get("architect_auth") == {"project": "user-project", "api_key": "user-auth-key"}
assert "architect_api_key" not in effective_ai
+134
View File
@@ -0,0 +1,134 @@
import os
import pytest
from connpy.grpc_layer.user_registry import UserRegistry
from connpy.services.provider import ServiceProvider
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
config_dir = tmp_path / "conn_config"
config_dir.mkdir()
return config_dir
@pytest.fixture
def registry(test_config_dir):
"""Initializes UserRegistry pointing to a temporary directory."""
return UserRegistry(str(test_config_dir))
class TestUserRegistry:
def test_has_users_empty(self, registry):
"""Verifies has_users is False when no users exist."""
assert registry.has_users() is False
def test_get_provider_returns_service_provider(self, registry):
"""Tests that get_provider lazy-loads a valid ServiceProvider instance."""
username = "alice"
registry.user_service.create_user(username, "password")
assert registry.has_users() is True
provider = registry.get_provider(username)
assert isinstance(provider, ServiceProvider)
assert provider.mode == "local"
def test_get_provider_cached(self, registry):
"""Verifies that subsequent calls return the cached singleton instance."""
username = "bob"
registry.user_service.create_user(username, "password")
p1 = registry.get_provider(username)
p2 = registry.get_provider(username)
assert p1 is p2 # must be exact same object reference
def test_two_users_isolated(self, registry):
"""Ensures different users get completely separate ServiceProviders and configs."""
u1 = "user1"
u2 = "user2"
registry.user_service.create_user(u1, "pass1")
registry.user_service.create_user(u2, "pass2")
p1 = registry.get_provider(u1)
p2 = registry.get_provider(u2)
assert p1 is not p2
assert p1.config is not p2.config
# Add a node for user1 and verify user2 is unaffected
p1.nodes.add_node("node1", {"host": "1.1.1.1"})
assert "node1" in p1.nodes.list_nodes()
assert "node1" not in p2.nodes.list_nodes()
def test_evict_clears_cache(self, registry):
"""Verifies that eviction deletes the cached provider from memory."""
username = "evictuser"
registry.user_service.create_user(username, "pass")
p1 = registry.get_provider(username)
assert username in registry._providers
registry.evict(username)
assert username not in registry._providers
# Calling get_provider again spawns a new instance
p2 = registry.get_provider(username)
assert p1 is not p2
def test_provider_hot_reload_on_external_change(self, registry):
"""Verifies that UserRegistry hot-reloads the provider if config.yaml is updated externally."""
username = "charlie"
registry.user_service.create_user(username, "password")
# Initial load (no nodes)
p1 = registry.get_provider(username)
assert len(p1.nodes.list_nodes()) == 0
# Resolve config.yaml file path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Modify the config file physically on disk by appending a node
from connpy.configfile import configfile
cfg = configfile(conf=conf_file)
cfg._connections_add(id="testnode", host="8.8.8.8")
cfg._saveconfig(cfg.file)
# Artificially increase mtime to force reload
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetch provider again
p2 = registry.get_provider(username)
# Verify it hot-reloaded and the new node is immediately visible
assert p1 is not p2
assert "testnode" in p2.nodes.list_nodes()
def test_provider_hot_reload_fails_on_corrupt_file_keeps_old_provider(self, registry):
"""Verifies that UserRegistry keeps serving the old provider if disk config is corrupt."""
username = "danny"
registry.user_service.create_user(username, "password")
# Initial load
p1 = registry.get_provider(username)
p1.nodes.add_node("nodeA", {"host": "2.2.2.2"})
assert "nodeA" in p1.nodes.list_nodes()
# Resolve config.yaml path
conf_file = os.path.join(registry.server_config_dir, "users", username, "config.yaml")
# Write corrupted content directly to config.yaml
with open(conf_file, "w") as f:
f.write("corrupt yaml content ::: invalid syntax :::")
# Artificially increase mtime to force reload attempt
mtime = os.path.getmtime(conf_file)
os.utime(conf_file, (mtime + 5.0, mtime + 5.0))
# Fetching provider again should fallback to old_provider instead of failing completely
p2 = registry.get_provider(username)
# Verify fallback
assert p1 is p2
assert "nodeA" in p2.nodes.list_nodes()
+217
View File
@@ -0,0 +1,217 @@
import os
import shutil
import pytest
import datetime
import jwt
import yaml
from pathlib import Path
from connpy.services.user_service import UserService
@pytest.fixture
def test_config_dir(tmp_path):
"""Creates a temporary config directory for testing user registry."""
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))
class TestUserService:
def test_no_users(self, user_service):
"""Verifies that a new registry is empty by default."""
users = user_service.list_users()
assert users == []
def test_create_user_default(self, user_service):
"""Tests Mode A: fresh user config and key creation."""
username = "testuser"
res = user_service.create_user(username, "mypassword")
assert res["username"] == username
assert res["config_path"] is None
assert "created" in res
# Verify folder, config.yaml and .osk key are created
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
assert os.path.isdir(os.path.join(user_dir, "plugins"))
assert os.path.isdir(os.path.join(user_dir, "ai_sessions"))
assert os.path.isfile(os.path.join(user_dir, "config.yaml"))
assert os.path.isfile(os.path.join(user_dir, ".osk"))
def test_create_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: using an existing valid config path."""
# Setup existing custom config directory
custom_dir = tmp_path / "custom_user_conn"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
# Write basic config.yaml
config_data = {
"config": {"case": False, "idletime": 30, "fzf": False},
"connections": {},
"profiles": {}
}
with open(config_file, "w") as f:
yaml.dump(config_data, f)
res = user_service.create_user("fluzzi", "fluzzipass", config_path=str(custom_dir))
assert res["username"] == "fluzzi"
assert res["config_path"] == str(custom_dir)
# Verify no directory is created under the server's user folder
user_dir = os.path.join(user_service.users_dir, "fluzzi")
assert not os.path.exists(user_dir)
def test_create_user_custom_path_auto_init(self, user_service, tmp_path):
"""Ensures create_user automatically initializes a missing directory and default config.yaml."""
custom_dir = tmp_path / "new_custom_config"
# Test creation where the directory does not exist yet
res = user_service.create_user("john", "pass", config_path=str(custom_dir))
assert res["username"] == "john"
assert res["config_path"] == str(custom_dir)
# Verify custom path and subdirs/configs were created
assert os.path.isdir(custom_dir)
assert os.path.exists(os.path.join(custom_dir, "config.yaml"))
assert os.path.isdir(os.path.join(custom_dir, "plugins"))
assert os.path.isdir(os.path.join(custom_dir, "ai_sessions"))
def test_create_duplicate_user(self, user_service):
"""Ensures duplicate usernames are rejected."""
user_service.create_user("dupuser", "password")
with pytest.raises(ValueError, match="already exists"):
user_service.create_user("dupuser", "anotherpass")
def test_delete_user_default(self, user_service):
"""Tests Mode A: deleting a server-managed user cleans up directories."""
username = "deluser"
user_service.create_user(username, "password")
user_dir = os.path.join(user_service.users_dir, username)
assert os.path.isdir(user_dir)
user_service.delete_user(username)
# Directory should be cleaned up
assert not os.path.exists(user_dir)
# Registry should be updated
assert len(user_service.list_users()) == 0
def test_delete_user_custom_path(self, user_service, tmp_path):
"""Tests Mode B: deleting a custom-path user leaves files untouched."""
custom_dir = tmp_path / "fluzzi_custom"
custom_dir.mkdir()
config_file = custom_dir / "config.yaml"
with open(config_file, "w") as f:
yaml.dump({"config": {}, "connections": {}, "profiles": {}}, f)
username = "fluzzi"
user_service.create_user(username, "pass", config_path=str(custom_dir))
user_service.delete_user(username)
# Registry cleared
assert len(user_service.list_users()) == 0
# Files remain untouched
assert os.path.isdir(str(custom_dir))
assert os.path.isfile(str(config_file))
def test_list_users(self, user_service):
"""Tests listing all registered users with their metadata."""
user_service.create_user("user1", "pass1")
user_service.create_user("user2", "pass2")
users = user_service.list_users()
assert len(users) == 2
usernames = [u["username"] for u in users]
assert "user1" in usernames
assert "user2" in usernames
def test_get_user(self, user_service):
"""Tests retrieving a single user's configuration metadata."""
user_service.create_user("user1", "pass1")
user = user_service.get_user("user1")
assert user["username"] == "user1"
assert user["config_path"] is None
assert "created" in user
with pytest.raises(ValueError, match="not found"):
user_service.get_user("nonexistent")
def test_authenticate_valid(self, user_service):
"""Verifies successful authentication."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "my-secure-password") is True
def test_authenticate_invalid(self, user_service):
"""Verifies unsuccessful authentication on incorrect or missing credentials."""
user_service.create_user("john", "my-secure-password")
assert user_service.authenticate("john", "wrong-password") is False
assert user_service.authenticate("nonexistent", "my-secure-password") is False
def test_jwt_roundtrip(self, user_service):
"""Tests generating a JWT token and verifying it back to the username."""
username = "jwttester"
user_service.create_user(username, "pass")
token = user_service.generate_jwt(username)
assert isinstance(token, str)
verified_user = user_service.verify_jwt(token)
assert verified_user == username
def test_jwt_expired(self, user_service):
"""Tests that expired JWT tokens are rejected and return None."""
username = "jwttester"
user_service.create_user(username, "pass")
# Manually generate an expired token by setting exp to the past
registry = user_service._load_registry()
expired_time = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(seconds=10)
payload = {
"sub": username,
"exp": expired_time
}
token = jwt.encode(payload, registry["jwt_secret"], algorithm="HS256")
if isinstance(token, bytes):
token = token.decode("utf-8")
verified_user = user_service.verify_jwt(token)
assert verified_user is None
def test_change_password(self, user_service):
"""Tests changing password for a user."""
username = "passchanger"
user_service.create_user(username, "oldpass")
# Old credentials authenticate
assert user_service.authenticate(username, "oldpass") is True
# Change password
user_service.change_password(username, "oldpass", "newpass")
# Old password fails, new password works
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
# Change with invalid old password should fail
with pytest.raises(ValueError, match="Invalid credentials"):
user_service.change_password(username, "wrongold", "evennewer")
def test_admin_change_password(self, user_service):
"""Tests administrative password change (no old password required)."""
username = "adminpasschanger"
user_service.create_user(username, "oldpass")
# Admin changes password directly
user_service.admin_change_password(username, "newpass")
# Verify credentials
assert user_service.authenticate(username, "oldpass") is False
assert user_service.authenticate(username, "newpass") is True
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.ai_handler API documentation</title> <title>connpy.cli.ai_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -666,7 +666,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.api_handler API documentation</title> <title>connpy.cli.api_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -193,7 +193,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.config_handler API documentation</title> <title>connpy.cli.config_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -551,7 +551,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.context_handler API documentation</title> <title>connpy.cli.context_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -249,7 +249,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.forms API documentation</title> <title>connpy.cli.forms API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -690,7 +690,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.help_text API documentation</title> <title>connpy.cli.help_text API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -303,7 +303,7 @@ tasks:
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.helpers API documentation</title> <title>connpy.cli.helpers API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -333,7 +333,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.import_export_handler API documentation</title> <title>connpy.cli.import_export_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -272,7 +272,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+12 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli API documentation</title> <title>connpy.cli API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -72,6 +72,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.cli.login_handler" href="login_handler.html">connpy.cli.login_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></dt> <dt><code class="name"><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -96,6 +100,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></dt> <dt><code class="name"><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -129,12 +137,14 @@ el.replaceWith(d);
<li><code><a title="connpy.cli.help_text" href="help_text.html">connpy.cli.help_text</a></code></li> <li><code><a title="connpy.cli.help_text" href="help_text.html">connpy.cli.help_text</a></code></li>
<li><code><a title="connpy.cli.helpers" href="helpers.html">connpy.cli.helpers</a></code></li> <li><code><a title="connpy.cli.helpers" href="helpers.html">connpy.cli.helpers</a></code></li>
<li><code><a title="connpy.cli.import_export_handler" href="import_export_handler.html">connpy.cli.import_export_handler</a></code></li> <li><code><a title="connpy.cli.import_export_handler" href="import_export_handler.html">connpy.cli.import_export_handler</a></code></li>
<li><code><a title="connpy.cli.login_handler" href="login_handler.html">connpy.cli.login_handler</a></code></li>
<li><code><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_handler</a></code></li> <li><code><a title="connpy.cli.node_handler" href="node_handler.html">connpy.cli.node_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.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.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>
<li><code><a title="connpy.cli.user_handler" href="user_handler.html">connpy.cli.user_handler</a></code></li>
<li><code><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></li> <li><code><a title="connpy.cli.validators" href="validators.html">connpy.cli.validators</a></code></li>
</ul> </ul>
</li> </li>
@@ -142,7 +152,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+408
View File
@@ -0,0 +1,408 @@
<!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.login_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.login_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.login_handler.LoginHandler"><code class="flex name class">
<span>class <span class="ident">LoginHandler</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 LoginHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def login(self, args):
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
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:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)
def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)
def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
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;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.login_handler.LoginHandler.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):
action = getattr(args, &#34;action&#34;, None)
if action == &#34;login&#34;:
return self.login(args)
elif action == &#34;logout&#34;:
return self.logout(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.login"><code class="name flex">
<span>def <span class="ident">login</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def login(self, args):
if getattr(args, &#34;status&#34;, False):
return self.show_status()
if self.app.services.mode != &#34;remote&#34;:
printer.warning(&#34;Note: Your current configuration is set to local mode. Logging in will save credentials, but they will only apply when service-mode is set to &#39;remote&#39;.&#34;)
username = getattr(args, &#34;username&#34;, None)
if not username:
try:
username = input(&#34;Username: &#34;).strip()
if not username:
printer.error(&#34;Username cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
password = getpass.getpass(&#34;Password: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
# Make the gRPC login call via self.app.services.auth stub
# We need to make sure auth is initialized in remote mode.
# If we are in local mode, self.app.services.auth is not initialized on ServiceProvider.
# Let&#39;s instantiate it dynamically if it&#39;s not present.
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:
channel = grpc.insecure_channel(remote_host)
auth_service = AuthStub(channel, remote_host=remote_host)
except Exception as e:
printer.error(f&#34;Failed to connect to remote server for login: {e}&#34;)
sys.exit(1)
try:
res = auth_service.login(username, password)
token = res[&#34;token&#34;]
# Save token to ~/.config/conn/.token
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
with open(token_path, &#34;w&#34;) as f:
f.write(token)
os.chmod(token_path, 0o600)
printer.success(f&#34;Logged in successfully as &#39;{username}&#39;. Session expires in 8 hours.&#34;)
except ConnpyError as e:
printer.error(f&#34;Login failed: {e}&#34;)
sys.exit(1)
except Exception as e:
printer.error(f&#34;Login failed with unexpected error: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.login_handler.LoginHandler.logout"><code class="name flex">
<span>def <span class="ident">logout</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def logout(self, args):
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
os.remove(token_path)
printer.success(&#34;Logged out successfully. Local session cleared.&#34;)
except Exception as e:
printer.error(f&#34;Failed to clear session: {e}&#34;)
sys.exit(1)
else:
printer.info(&#34;No active session found (already logged out).&#34;)</code></pre>
</details>
<div class="desc"></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_status(self):
import base64
import json
import datetime
token_path = os.path.join(self.app.config.defaultdir, &#34;.token&#34;)
if not os.path.exists(token_path):
printer.warning(&#34;No active session found. You can log in using &#39;connpy login&#39;.&#34;)
return
try:
with open(token_path, &#34;r&#34;) as f:
token = f.read().strip()
parts = token.split(&#34;.&#34;)
if len(parts) != 3:
printer.error(&#34;Invalid local session token format.&#34;)
return
payload_b64 = parts[1]
payload_b64 += &#34;=&#34; * ((4 - len(payload_b64) % 4) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
payload = json.loads(payload_bytes.decode(&#34;utf-8&#34;))
username = payload.get(&#34;sub&#34;)
exp = payload.get(&#34;exp&#34;)
if not exp:
printer.success(f&#34;Active session as &#39;{username}&#39; (Indefinite expiration).&#34;)
return
now = datetime.datetime.now(datetime.timezone.utc).timestamp()
if now &gt; exp:
printer.error(&#34;Session has expired. Please log in again using &#39;connpy login&#39;.&#34;)
return
remaining = exp - now
hours = int(remaining // 3600)
minutes = int((remaining % 3600) // 60)
printer.success(f&#34;Logged in as &#39;{username}&#39;&#34;)
printer.info(f&#34;Time remaining: {hours}h {minutes}m&#34;)
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;)
except Exception as e:
printer.error(f&#34;Failed to check local session status: {e}&#34;)</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.login_handler.LoginHandler" href="#connpy.cli.login_handler.LoginHandler">LoginHandler</a></code></h4>
<ul class="">
<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.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.show_status" href="#connpy.cli.login_handler.LoginHandler.show_status">show_status</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>
+47 -12
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.node_handler API documentation</title> <title>connpy.cli.node_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -60,6 +60,23 @@ el.replaceWith(d);
self.app = app self.app = app
self.forms = Forms(app) self.forms = Forms(app)
def _filter_exact_match(self, matches, query):
if not query or len(matches) &lt;= 1:
return matches
exact_matches = []
for m in matches:
if self.app.case:
if m == query:
exact_matches.append(m)
else:
if m.lower() == query.lower():
exact_matches.append(m)
if len(exact_matches) == 1:
return exact_matches
return matches
def dispatch(self, args): def dispatch(self, args):
if not self.app.case and args.data != None: if not self.app.case and args.data != None:
args.data = args.data.lower() args.data = args.data.lower()
@@ -85,6 +102,7 @@ el.replaceWith(d);
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -119,6 +137,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -133,8 +152,9 @@ el.replaceWith(d);
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;) printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -190,6 +210,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -217,6 +238,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -255,7 +277,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;) printer.success(f&#34;{args.data} edited successfully&#34;)
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34; updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -268,8 +290,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info(&#34;Nothing to do here&#34;) printer.info(&#34;Nothing to do here&#34;)
@@ -354,6 +380,7 @@ el.replaceWith(d);
else: else:
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -398,6 +425,7 @@ el.replaceWith(d);
matches = self.app.services.nodes.list_folders(args.data) matches = self.app.services.nodes.list_folders(args.data)
else: else:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -412,8 +440,9 @@ el.replaceWith(d);
sys.exit(7) sys.exit(7)
try: try:
for item in matches: for i, item in enumerate(matches):
self.app.services.nodes.delete_node(item, is_folder=is_folder) save_on_last = (i == len(matches) - 1)
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
if len(matches) == 1: if len(matches) == 1:
printer.success(f&#34;{matches[0]} deleted successfully&#34;) printer.success(f&#34;{matches[0]} deleted successfully&#34;)
@@ -456,6 +485,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -494,7 +524,7 @@ el.replaceWith(d);
self.app.services.nodes.update_node(matches[0], updatenode) self.app.services.nodes.update_node(matches[0], updatenode)
printer.success(f&#34;{args.data} edited successfully&#34;) printer.success(f&#34;{args.data} edited successfully&#34;)
else: else:
editcount = 0 changed_items = []
for k in matches: for k in matches:
updated_item = self.app.services.nodes.explode_unique(k) updated_item = self.app.services.nodes.explode_unique(k)
updated_item[&#34;type&#34;] = &#34;connection&#34; updated_item[&#34;type&#34;] = &#34;connection&#34;
@@ -507,8 +537,12 @@ el.replaceWith(d);
updated_item[key] = updatenode[key] updated_item[key] = updatenode[key]
if this_item_changed: if this_item_changed:
editcount += 1 changed_items.append((k, updated_item))
self.app.services.nodes.update_node(k, updated_item)
editcount = len(changed_items)
for i, (k, updated_item) in enumerate(changed_items):
save_on_last = (i == editcount - 1)
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
if editcount == 0: if editcount == 0:
printer.info(&#34;Nothing to do here&#34;) printer.info(&#34;Nothing to do here&#34;)
@@ -535,6 +569,7 @@ el.replaceWith(d);
try: try:
matches = self.app.services.nodes.list_nodes(args.data) matches = self.app.services.nodes.list_nodes(args.data)
matches = self._filter_exact_match(matches, args.data)
except Exception: except Exception:
matches = [] matches = []
@@ -606,7 +641,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.plugin_handler API documentation</title> <title>connpy.cli.plugin_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -385,7 +385,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.profile_handler API documentation</title> <title>connpy.cli.profile_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -314,7 +314,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+72 -6
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.run_handler API documentation</title> <title>connpy.cli.run_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -68,6 +68,17 @@ el.replaceWith(d);
def node_run(self, args): def node_run(self, args):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f&#34;No nodes found matching filter: {nodes_filter}&#34;)
sys.exit(2)
commands = [&#34; &#34;.join(args.data[1:])] commands = [&#34; &#34;.join(args.data[1:])]
try: try:
@@ -84,7 +95,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -101,7 +112,7 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
@@ -151,6 +162,28 @@ el.replaceWith(d);
folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None
prompt = options.get(&#34;prompt&#34;) prompt = options.get(&#34;prompt&#34;)
# Resolve and filter nodes through context-aware list_nodes
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
if not resolved_nodes:
printer.error(f&#34;[{name}] No nodes found matching filter: {nodelist}&#34;)
sys.exit(11)
nodelist = resolved_nodes
try: try:
header_printed = False header_printed = False
if action == &#34;run&#34;: if action == &#34;run&#34;:
@@ -242,6 +275,28 @@ el.replaceWith(d);
folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None folder = output_cfg if output_cfg not in [None, &#34;stdout&#34;] else None
prompt = options.get(&#34;prompt&#34;) prompt = options.get(&#34;prompt&#34;)
# Resolve and filter nodes through context-aware list_nodes
try:
if isinstance(nodelist, str):
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
elif isinstance(nodelist, list):
resolved_nodes = []
for item in nodelist:
matches = self.app.services.nodes.list_nodes(item)
for m in matches:
if m not in resolved_nodes:
resolved_nodes.append(m)
else:
resolved_nodes = []
except Exception:
resolved_nodes = []
if not resolved_nodes:
printer.error(f&#34;[{name}] No nodes found matching filter: {nodelist}&#34;)
sys.exit(11)
nodelist = resolved_nodes
try: try:
header_printed = False header_printed = False
if action == &#34;run&#34;: if action == &#34;run&#34;:
@@ -333,6 +388,17 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def node_run(self, args): <pre><code class="python">def node_run(self, args):
nodes_filter = args.data[0] nodes_filter = args.data[0]
# Resolve and filter nodes through context-aware list_nodes
try:
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
except Exception:
matched_nodes = []
if not matched_nodes:
printer.error(f&#34;No nodes found matching filter: {nodes_filter}&#34;)
sys.exit(2)
commands = [&#34; &#34;.join(args.data[1:])] commands = [&#34; &#34;.join(args.data[1:])]
try: try:
@@ -349,7 +415,7 @@ el.replaceWith(d);
printer.test_panel(unique, node_output, node_status, node_result) printer.test_panel(unique, node_output, node_status, node_result)
results = self.app.services.execution.test_commands( results = self.app.services.execution.test_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
expected=args.test_expected, expected=args.test_expected,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
@@ -366,7 +432,7 @@ el.replaceWith(d);
printer.node_panel(unique, node_output, node_status) printer.node_panel(unique, node_output, node_status)
results = self.app.services.execution.run_commands( results = self.app.services.execution.run_commands(
nodes_filter=nodes_filter, nodes_filter=matched_nodes,
commands=commands, commands=commands,
on_node_complete=_on_node_complete on_node_complete=_on_node_complete
) )
@@ -454,7 +520,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.sync_handler API documentation</title> <title>connpy.cli.sync_handler API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -427,7 +427,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.terminal_ui API documentation</title> <title>connpy.cli.terminal_ui API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -1017,7 +1017,7 @@ on_ai_call: async function(active_buffer, question) -&gt; result_dict</p></div>
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+522
View File
@@ -0,0 +1,522 @@
<!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.user_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.user_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.user_handler.UserHandler"><code class="flex name class">
<span>class <span class="ident">UserHandler</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 UserHandler:
def __init__(self, app):
self.app = app
def dispatch(self, args):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)
def add_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)
def delete_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)
def list_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)
def show_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.cli.user_handler.UserHandler.add_user"><code class="name flex">
<span>def <span class="ident">add_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --add &lt;username&gt;&#34;)
sys.exit(1)
custom_path = getattr(args, &#34;path&#34;, None)
if custom_path:
custom_path = custom_path[0] if isinstance(custom_path, list) else custom_path
try:
password = getpass.getpass(&#34;Enter password for new user: &#34;)
if not password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm password: &#34;)
if password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.create_user(username, password, config_path=custom_path)
printer.success(f&#34;User &#39;{username}&#39; created successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to create user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.delete_user"><code class="name flex">
<span>def <span class="ident">delete_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --del &lt;username&gt;&#34;)
sys.exit(1)
try:
self.app.services.users.delete_user(username)
printer.success(f&#34;User &#39;{username}&#39; deleted successfully.&#34;)
except ConnpyError as e:
printer.error(str(e))
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to delete user: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.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):
if self.app.services.mode == &#34;remote&#34;:
printer.error(&#34;User management commands are only available in local/server-side mode.&#34;)
sys.exit(1)
# Parse actions from argparse mutually exclusive options
if getattr(args, &#34;add&#34;, None):
args.action = &#34;add&#34;
args.username = args.add[0]
elif getattr(args, &#34;delete&#34;, None):
args.action = &#34;del&#34;
args.username = args.delete[0]
elif getattr(args, &#34;list&#34;, False):
args.action = &#34;list&#34;
elif getattr(args, &#34;show&#34;, None):
args.action = &#34;show&#34;
args.username = args.show[0]
elif getattr(args, &#34;regen_password&#34;, None):
args.action = &#34;regen_password&#34;
args.username = args.regen_password[0]
action = getattr(args, &#34;action&#34;, None)
if action == &#34;add&#34;:
return self.add_user(args)
elif action == &#34;del&#34;:
return self.delete_user(args)
elif action == &#34;list&#34;:
return self.list_users(args)
elif action == &#34;show&#34;:
return self.show_user(args)
elif action == &#34;regen_password&#34;:
return self.regen_password(args)
else:
printer.error(f&#34;Unknown action: {action}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.list_users"><code class="name flex">
<span>def <span class="ident">list_users</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_users(self, args):
try:
users = self.app.services.users.list_users()
if not users:
printer.warning(&#34;No users registered.&#34;)
return
# Format custom config path, falling back to computed default path instead of null/None
formatted_users = []
for u in users:
formatted_u = u.copy()
if not formatted_u.get(&#34;config_path&#34;):
formatted_u[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, formatted_u[&#34;username&#34;])
formatted_users.append(formatted_u)
yaml_str = yaml.dump(formatted_users, sort_keys=False, default_flow_style=False)
printer.data(&#34;Registered Users&#34;, yaml_str)
except Exception as e:
printer.error(f&#34;Failed to list users: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.regen_password"><code class="name flex">
<span>def <span class="ident">regen_password</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def regen_password(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --regen-password &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)
try:
new_password = getpass.getpass(&#34;Enter new password: &#34;)
if not new_password:
printer.error(&#34;Password cannot be empty.&#34;)
sys.exit(1)
confirm = getpass.getpass(&#34;Confirm new password: &#34;)
if new_password != confirm:
printer.error(&#34;Passwords do not match.&#34;)
sys.exit(1)
except (KeyboardInterrupt, EOFError):
printer.warning(&#34;\nOperation cancelled.&#34;)
sys.exit(130)
try:
self.app.services.users.admin_change_password(username, new_password)
printer.success(f&#34;Password for user &#39;{username}&#39; regenerated successfully.&#34;)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to regenerate password: {e}&#34;)
sys.exit(1)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.cli.user_handler.UserHandler.show_user"><code class="name flex">
<span>def <span class="ident">show_user</span></span>(<span>self, args)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def show_user(self, args):
username = getattr(args, &#34;username&#34;, None)
if not username:
printer.error(&#34;Username is required. Usage: connpy user --show &lt;username&gt;&#34;)
sys.exit(1)
try:
user = self.app.services.users.get_user(username)
if not user:
printer.error(f&#34;User &#39;{username}&#39; not found.&#34;)
sys.exit(1)
# Hide the password hash from the CLI output for safety
safe_user = {k: v for k, v in user.items() if k != &#34;password_hash&#34;}
if not safe_user.get(&#34;config_path&#34;):
safe_user[&#34;config_path&#34;] = os.path.join(self.app.services.users.users_dir, username)
yaml_str = yaml.dump(safe_user, sort_keys=False, default_flow_style=False)
printer.data(f&#34;User: {username}&#34;, yaml_str)
except ValueError as e:
printer.error(str(e))
sys.exit(1)
except Exception as e:
printer.error(f&#34;Failed to retrieve user details: {e}&#34;)
sys.exit(1)</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.user_handler.UserHandler" href="#connpy.cli.user_handler.UserHandler">UserHandler</a></code></h4>
<ul class="two-column">
<li><code><a title="connpy.cli.user_handler.UserHandler.add_user" href="#connpy.cli.user_handler.UserHandler.add_user">add_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.delete_user" href="#connpy.cli.user_handler.UserHandler.delete_user">delete_user</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.dispatch" href="#connpy.cli.user_handler.UserHandler.dispatch">dispatch</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.list_users" href="#connpy.cli.user_handler.UserHandler.list_users">list_users</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.regen_password" href="#connpy.cli.user_handler.UserHandler.regen_password">regen_password</a></code></li>
<li><code><a title="connpy.cli.user_handler.UserHandler.show_user" href="#connpy.cli.user_handler.UserHandler.show_user">show_user</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>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.cli.validators API documentation</title> <title>connpy.cli.validators API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -508,7 +508,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.connpy_pb2 API documentation</title> <title>connpy.grpc_layer.connpy_pb2 API documentation</title>
<meta name="description" content="Generated protocol buffer code."> <meta name="description" content="Generated protocol buffer code.">
<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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -61,7 +61,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+293 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.connpy_pb2_grpc API documentation</title> <title>connpy.grpc_layer.connpy_pb2_grpc API documentation</title>
<meta name="description" content="Client and server classes corresponding to protobuf-defined services."> <meta name="description" content="Client and server classes corresponding to protobuf-defined services.">
<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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -108,6 +108,34 @@ el.replaceWith(d);
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.add_AuthServiceServicer_to_server"><code class="name flex">
<span>def <span class="ident">add_AuthServiceServicer_to_server</span></span>(<span>servicer, server)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def add_AuthServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
&#39;login&#39;: grpc.unary_unary_rpc_method_handler(
servicer.login,
request_deserializer=connpy__pb2.LoginRequest.FromString,
response_serializer=connpy__pb2.LoginResponse.SerializeToString,
),
&#39;change_password&#39;: grpc.unary_unary_rpc_method_handler(
servicer.change_password,
request_deserializer=connpy__pb2.ChangePasswordRequest.FromString,
response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
),
}
generic_handler = grpc.method_handlers_generic_handler(
&#39;connpy.AuthService&#39;, rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
server.add_registered_method_handlers(&#39;connpy.AuthService&#39;, rpc_method_handlers)</code></pre>
</details>
<div class="desc"></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server"><code class="name flex"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server"><code class="name flex">
<span>def <span class="ident">add_ConfigServiceServicer_to_server</span></span>(<span>servicer, server)</span> <span>def <span class="ident">add_ConfigServiceServicer_to_server</span></span>(<span>servicer, server)</span>
</code></dt> </code></dt>
@@ -1341,6 +1369,251 @@ def load_session_data(request,
<dd>A grpc.Channel.</dd> <dd>A grpc.Channel.</dd>
</dl></div> </dl></div>
</dd> </dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService"><code class="flex name class">
<span>class <span class="ident">AuthService</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthService(object):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
@staticmethod
def login(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/login&#39;,
connpy__pb2.LoginRequest.SerializeToString,
connpy__pb2.LoginResponse.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@staticmethod
def change_password(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/change_password&#39;,
connpy__pb2.ChangePasswordRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p></div>
<h3>Static methods</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthService.change_password"><code class="name flex">
<span>def <span class="ident">change_password</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 change_password(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/change_password&#39;,
connpy__pb2.ChangePasswordRequest.SerializeToString,
google_dot_protobuf_dot_empty__pb2.Empty.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)</code></pre>
</details>
<div class="desc"></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@staticmethod
def login(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
&#39;/connpy.AuthService/login&#39;,
connpy__pb2.LoginRequest.SerializeToString,
connpy__pb2.LoginResponse.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>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer"><code class="flex name class">
<span>class <span class="ident">AuthServiceServicer</span></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthServiceServicer(object):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
def login(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 change_password(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>
<h3>Subclasses</h3>
<ul class="hlist">
<li><a title="connpy.grpc_layer.server.AuthServicer" href="server.html#connpy.grpc_layer.server.AuthServicer">AuthServicer</a></li>
</ul>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.change_password"><code class="name flex">
<span>def <span class="ident">change_password</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 change_password(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">
<span>def <span class="ident">login</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 login(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>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub"><code class="flex name class">
<span>class <span class="ident">AuthServiceStub</span></span>
<span>(</span><span>channel)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthServiceStub(object):
&#34;&#34;&#34;Missing associated documentation comment in .proto file.&#34;&#34;&#34;
def __init__(self, channel):
&#34;&#34;&#34;Constructor.
Args:
channel: A grpc.Channel.
&#34;&#34;&#34;
self.login = channel.unary_unary(
&#39;/connpy.AuthService/login&#39;,
request_serializer=connpy__pb2.LoginRequest.SerializeToString,
response_deserializer=connpy__pb2.LoginResponse.FromString,
_registered_method=True)
self.change_password = channel.unary_unary(
&#39;/connpy.AuthService/change_password&#39;,
request_serializer=connpy__pb2.ChangePasswordRequest.SerializeToString,
response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
_registered_method=True)</code></pre>
</details>
<div class="desc"><p>Missing associated documentation comment in .proto file.</p>
<p>Constructor.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>channel</code></strong></dt>
<dd>A grpc.Channel.</dd>
</dl></div>
</dd>
<dt id="connpy.grpc_layer.connpy_pb2_grpc.ConfigService"><code class="flex name class"> <dt id="connpy.grpc_layer.connpy_pb2_grpc.ConfigService"><code class="flex name class">
<span>class <span class="ident">ConfigService</span></span> <span>class <span class="ident">ConfigService</span></span>
</code></dt> </code></dt>
@@ -5802,6 +6075,7 @@ def stop_api(request,
<li><h3><a href="#header-functions">Functions</a></h3> <li><h3><a href="#header-functions">Functions</a></h3>
<ul class=""> <ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_AIServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_AIServiceServicer_to_server">add_AIServiceServicer_to_server</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_AIServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_AIServiceServicer_to_server">add_AIServiceServicer_to_server</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_AuthServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_AuthServiceServicer_to_server">add_AuthServiceServicer_to_server</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server">add_ConfigServiceServicer_to_server</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ConfigServiceServicer_to_server">add_ConfigServiceServicer_to_server</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ExecutionServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ExecutionServiceServicer_to_server">add_ExecutionServiceServicer_to_server</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ExecutionServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ExecutionServiceServicer_to_server">add_ExecutionServiceServicer_to_server</a></code></li>
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ImportExportServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ImportExportServiceServicer_to_server">add_ImportExportServiceServicer_to_server</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.add_ImportExportServiceServicer_to_server" href="#connpy.grpc_layer.connpy_pb2_grpc.add_ImportExportServiceServicer_to_server">add_ImportExportServiceServicer_to_server</a></code></li>
@@ -5845,6 +6119,23 @@ def stop_api(request,
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub">AIServiceStub</a></code></h4> <h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub" href="#connpy.grpc_layer.connpy_pb2_grpc.AIServiceStub">AIServiceStub</a></code></h4>
</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>
<ul class="">
<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.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthService.login">login</a></code></li>
</ul>
</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>
<ul class="">
<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.login" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceServicer.login">login</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub" href="#connpy.grpc_layer.connpy_pb2_grpc.AuthServiceStub">AuthServiceStub</a></code></h4>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ConfigService" href="#connpy.grpc_layer.connpy_pb2_grpc.ConfigService">ConfigService</a></code></h4> <h4><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ConfigService" href="#connpy.grpc_layer.connpy_pb2_grpc.ConfigService">ConfigService</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ConfigService.apply_theme_from_file" href="#connpy.grpc_layer.connpy_pb2_grpc.ConfigService.apply_theme_from_file">apply_theme_from_file</a></code></li> <li><code><a title="connpy.grpc_layer.connpy_pb2_grpc.ConfigService.apply_theme_from_file" href="#connpy.grpc_layer.connpy_pb2_grpc.ConfigService.apply_theme_from_file">apply_theme_from_file</a></code></li>
@@ -6029,7 +6320,7 @@ def stop_api(request,
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+7 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer API documentation</title> <title>connpy.grpc_layer API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -64,6 +64,10 @@ el.replaceWith(d);
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt><code class="name"><a title="connpy.grpc_layer.user_registry" href="user_registry.html">connpy.grpc_layer.user_registry</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
<dt><code class="name"><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></dt> <dt><code class="name"><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></dt>
<dd> <dd>
<div class="desc"></div> <div class="desc"></div>
@@ -95,6 +99,7 @@ el.replaceWith(d);
<li><code><a title="connpy.grpc_layer.remote_plugin_pb2_grpc" href="remote_plugin_pb2_grpc.html">connpy.grpc_layer.remote_plugin_pb2_grpc</a></code></li> <li><code><a title="connpy.grpc_layer.remote_plugin_pb2_grpc" href="remote_plugin_pb2_grpc.html">connpy.grpc_layer.remote_plugin_pb2_grpc</a></code></li>
<li><code><a title="connpy.grpc_layer.server" href="server.html">connpy.grpc_layer.server</a></code></li> <li><code><a title="connpy.grpc_layer.server" href="server.html">connpy.grpc_layer.server</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs" href="stubs.html">connpy.grpc_layer.stubs</a></code></li> <li><code><a title="connpy.grpc_layer.stubs" href="stubs.html">connpy.grpc_layer.stubs</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry" href="user_registry.html">connpy.grpc_layer.user_registry</a></code></li>
<li><code><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></li> <li><code><a title="connpy.grpc_layer.utils" href="utils.html">connpy.grpc_layer.utils</a></code></li>
</ul> </ul>
</li> </li>
@@ -102,7 +107,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.remote_plugin_pb2 API documentation</title> <title>connpy.grpc_layer.remote_plugin_pb2 API documentation</title>
<meta name="description" content="Generated protocol buffer code."> <meta name="description" content="Generated protocol buffer code.">
<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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -62,7 +62,7 @@ el.replaceWith(d);
<dl> <dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.IdRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt> <dt id="connpy.grpc_layer.remote_plugin_pb2.IdRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
</dd> </dd>
@@ -81,7 +81,7 @@ el.replaceWith(d);
<dl> <dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.OutputChunk.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt> <dt id="connpy.grpc_layer.remote_plugin_pb2.OutputChunk.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
</dd> </dd>
@@ -100,7 +100,7 @@ el.replaceWith(d);
<dl> <dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.PluginInvokeRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt> <dt id="connpy.grpc_layer.remote_plugin_pb2.PluginInvokeRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
</dd> </dd>
@@ -119,7 +119,7 @@ el.replaceWith(d);
<dl> <dl>
<dt id="connpy.grpc_layer.remote_plugin_pb2.StringResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt> <dt id="connpy.grpc_layer.remote_plugin_pb2.StringResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
</dd> </dd>
@@ -168,7 +168,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.remote_plugin_pb2_grpc API documentation</title> <title>connpy.grpc_layer.remote_plugin_pb2_grpc API documentation</title>
<meta name="description" content="Client and server classes corresponding to protobuf-defined services."> <meta name="description" content="Client and server classes corresponding to protobuf-defined services.">
<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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -366,7 +366,7 @@ def invoke_plugin(request,
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
File diff suppressed because it is too large Load Diff
+329 -18
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.stubs API documentation</title> <title>connpy.grpc_layer.stubs API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -272,9 +272,6 @@ el.replaceWith(d);
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get(&#34;response&#34;):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -517,9 +514,6 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
from ..printer import connpy_theme, get_original_stdout from ..printer import connpy_theme, get_original_stdout
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout()) stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
stable_console.print(Rule(style=alias)) stable_console.print(Rule(style=alias))
elif not full_content and final_result.get(&#34;response&#34;):
# If nothing streamed but we have response (e.g. error or direct guide)
printer.console.print(Panel(Markdown(final_result[&#34;response&#34;]), title=title, border_style=alias, expand=False))
break break
except Exception as e: except Exception as e:
# Check if it was a gRPC error that we should let handle_errors catch # Check if it was a gRPC error that we should let handle_errors catch
@@ -652,6 +646,303 @@ def load_session_data(self, session_id):
</dd> </dd>
</dl> </dl>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor"><code class="flex name class">
<span>class <span class="ident">AuthClientInterceptor</span></span>
<span>(</span><span>token_provider)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthClientInterceptor(grpc.UnaryUnaryClientInterceptor,
grpc.UnaryStreamClientInterceptor,
grpc.StreamUnaryClientInterceptor,
grpc.StreamStreamClientInterceptor):
def __init__(self, token_provider):
self.token_provider = token_provider
def _add_metadata(self, client_call_details):
token = self.token_provider()
if not token:
return client_call_details
metadata = []
if client_call_details.metadata:
metadata = list(client_call_details.metadata)
# Check if already present to avoid duplicates
if not any(k.lower() == &#34;authorization&#34; for k, v in metadata):
metadata.append((&#34;authorization&#34;, f&#34;Bearer {token}&#34;))
return _ClientCallDetails(
method=client_call_details.method,
timeout=client_call_details.timeout,
metadata=metadata,
credentials=client_call_details.credentials,
wait_for_ready=client_call_details.wait_for_ready,
compression=client_call_details.compression,
)
def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)
def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)
def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Affords intercepting unary-unary invocations.</p></div>
<h3>Ancestors</h3>
<ul class="hlist">
<li>grpc.UnaryUnaryClientInterceptor</li>
<li>grpc.UnaryStreamClientInterceptor</li>
<li>grpc.StreamUnaryClientInterceptor</li>
<li>grpc.StreamStreamClientInterceptor</li>
<li>abc.ABC</li>
</ul>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream"><code class="name flex">
<span>def <span class="ident">intercept_stream_stream</span></span>(<span>self, continuation, client_call_details, request_iterator)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_stream_stream(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Intercepts a stream-stream invocation.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_iterator = continuation(client_call_details, request_iterator)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and an iterator for response values.
Drawing response values from the returned Call-iterator may
raise RpcError indicating termination of the RPC with non-OK
status.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request_iterator</code></strong></dt>
<dd>An iterator that yields request values for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and an iterator of
response values. Drawing response values from the returned
Call-iterator may raise RpcError indicating termination of
the RPC with non-OK status. This object <em>should</em> also fulfill the
Future interface, though it may not.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary"><code class="name flex">
<span>def <span class="ident">intercept_stream_unary</span></span>(<span>self, continuation, client_call_details, request_iterator)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_stream_unary(self, continuation, client_call_details, request_iterator):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request_iterator)</code></pre>
</details>
<div class="desc"><p>Intercepts a stream-unary invocation asynchronously.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_future = continuation(client_call_details, request_iterator)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and a Future. In the event of RPC completion,
the return Call-Future's result value will be the response message
of the RPC. Should the event terminate with non-OK status, the
returned Call-Future's exception value will be an RpcError.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request_iterator</code></strong></dt>
<dd>An iterator that yields request values for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and a Future.
In the event of RPC completion, the return Call-Future's
result value will be the response message of the RPC.
Should the event terminate with non-OK status, the returned
Call-Future's exception value will be an RpcError.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream"><code class="name flex">
<span>def <span class="ident">intercept_unary_stream</span></span>(<span>self, continuation, client_call_details, request)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_unary_stream(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)</code></pre>
</details>
<div class="desc"><p>Intercepts a unary-stream invocation.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_iterator = continuation(client_call_details, request)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and an iterator for response values.
Drawing response values from the returned Call-iterator may
raise RpcError indicating termination of the RPC with non-OK
status.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request</code></strong></dt>
<dd>The request value for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and an iterator of
response values. Drawing response values from the returned
Call-iterator may raise RpcError indicating termination of
the RPC with non-OK status. This object <em>should</em> also fulfill the
Future interface, though it may not.</p></div>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary"><code class="name flex">
<span>def <span class="ident">intercept_unary_unary</span></span>(<span>self, continuation, client_call_details, request)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def intercept_unary_unary(self, continuation, client_call_details, request):
new_details = self._add_metadata(client_call_details)
return continuation(new_details, request)</code></pre>
</details>
<div class="desc"><p>Intercepts a unary-unary invocation asynchronously.</p>
<h2 id="args">Args</h2>
<dl>
<dt><strong><code>continuation</code></strong></dt>
<dd>A function that proceeds with the invocation by
executing the next interceptor in chain or invoking the
actual RPC on the underlying Channel. It is the interceptor's
responsibility to call it if it decides to move the RPC forward.
The interceptor can use
<code>response_future = continuation(client_call_details, request)</code>
to continue with the RPC. <code>continuation</code> returns an object that is
both a Call for the RPC and a Future. In the event of RPC
completion, the return Call-Future's result value will be
the response message of the RPC. Should the event terminate
with non-OK status, the returned Call-Future's exception value
will be an RpcError.</dd>
<dt><strong><code>client_call_details</code></strong></dt>
<dd>A ClientCallDetails object describing the
outgoing RPC.</dd>
<dt><strong><code>request</code></strong></dt>
<dd>The request value for the RPC.</dd>
</dl>
<h2 id="returns">Returns</h2>
<p>An object that is both a Call for the RPC and a Future.
In the event of RPC completion, the return Call-Future's
result value will be the response message of the RPC.
Should the event terminate with non-OK status, the returned
Call-Future's exception value will be an RpcError.</p></div>
</dd>
</dl>
</dd>
<dt id="connpy.grpc_layer.stubs.AuthStub"><code class="flex name class">
<span>class <span class="ident">AuthStub</span></span>
<span>(</span><span>channel, remote_host)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class AuthStub:
def __init__(self, channel, remote_host):
self.stub = connpy_pb2_grpc.AuthServiceStub(channel)
self.remote_host = remote_host
@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
&#34;token&#34;: resp.token,
&#34;username&#34;: resp.username,
&#34;expires_at&#34;: resp.expires_at
}
@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.stubs.AuthStub.change_password"><code class="name flex">
<span>def <span class="ident">change_password</span></span>(<span>self, old_password, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def change_password(self, old_password, new_password):
req = connpy_pb2.ChangePasswordRequest(old_password=old_password, new_password=new_password)
self.stub.change_password(req)</code></pre>
</details>
<div class="desc"></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">@handle_errors
def login(self, username, password):
req = connpy_pb2.LoginRequest(username=username, password=password)
resp = self.stub.login(req)
return {
&#34;token&#34;: resp.token,
&#34;username&#34;: resp.username,
&#34;expires_at&#34;: resp.expires_at
}</code></pre>
</details>
<div class="desc"></div>
</dd>
</dl>
</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">
<span>class <span class="ident">ConfigStub</span></span> <span>class <span class="ident">ConfigStub</span></span>
<span>(</span><span>channel, remote_host)</span> <span>(</span><span>channel, remote_host)</span>
@@ -1467,16 +1758,18 @@ def set_reserved_names(self, names):
self._trigger_local_cache_sync() self._trigger_local_cache_sync()
@handle_errors @handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
self._trigger_local_cache_sync() if save:
self._trigger_local_cache_sync()
@handle_errors @handle_errors
def move_node(self, src_id, dst_id, copy=False): def move_node(self, src_id, dst_id, copy=False):
@@ -1857,7 +2150,7 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.NodeStub.delete_node"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.NodeStub.delete_node"><code class="name flex">
<span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False)</span> <span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -1865,10 +2158,11 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder) req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
self.stub.delete_node(req) self.stub.delete_node(req)
self._trigger_local_cache_sync()</code></pre> if save:
self._trigger_local_cache_sync()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
@@ -2028,7 +2322,7 @@ def set_reserved_names(self, names):
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
<dt id="connpy.grpc_layer.stubs.NodeStub.update_node"><code class="name flex"> <dt id="connpy.grpc_layer.stubs.NodeStub.update_node"><code class="name flex">
<span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data)</span> <span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -2036,10 +2330,11 @@ def set_reserved_names(self, names):
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">@handle_errors <pre><code class="python">@handle_errors
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False) req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
self.stub.update_node(req) self.stub.update_node(req)
self._trigger_local_cache_sync()</code></pre> if save:
self._trigger_local_cache_sync()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
</dd> </dd>
@@ -2532,6 +2827,22 @@ def stop_api(self):
</ul> </ul>
</li> </li>
<li> <li>
<h4><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor" href="#connpy.grpc_layer.stubs.AuthClientInterceptor">AuthClientInterceptor</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_stream">intercept_stream_stream</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_stream_unary">intercept_stream_unary</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_stream">intercept_unary_stream</a></code></li>
<li><code><a title="connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary" href="#connpy.grpc_layer.stubs.AuthClientInterceptor.intercept_unary_unary">intercept_unary_unary</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.stubs.AuthStub" href="#connpy.grpc_layer.stubs.AuthStub">AuthStub</a></code></h4>
<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.login" href="#connpy.grpc_layer.stubs.AuthStub.login">login</a></code></li>
</ul>
</li>
<li>
<h4><code><a title="connpy.grpc_layer.stubs.ConfigStub" href="#connpy.grpc_layer.stubs.ConfigStub">ConfigStub</a></code></h4> <h4><code><a title="connpy.grpc_layer.stubs.ConfigStub" href="#connpy.grpc_layer.stubs.ConfigStub">ConfigStub</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.grpc_layer.stubs.ConfigStub.encrypt_password" href="#connpy.grpc_layer.stubs.ConfigStub.encrypt_password">encrypt_password</a></code></li> <li><code><a title="connpy.grpc_layer.stubs.ConfigStub.encrypt_password" href="#connpy.grpc_layer.stubs.ConfigStub.encrypt_password">encrypt_password</a></code></li>
@@ -2618,7 +2929,7 @@ def stop_api(self):
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+295
View File
@@ -0,0 +1,295 @@
<!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.grpc_layer.user_registry 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.grpc_layer.user_registry</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.grpc_layer.user_registry.UserRegistry"><code class="flex name class">
<span>class <span class="ident">UserRegistry</span></span>
<span>(</span><span>server_config_dir)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class UserRegistry:
&#34;&#34;&#34;Holds per-user ServiceProviders in memory, thread-safe with hot-reloading.&#34;&#34;&#34;
def __init__(self, server_config_dir):
self.server_config_dir = os.path.abspath(server_config_dir)
self.user_service = UserService(self.server_config_dir)
self._providers = {} # username → ServiceProvider
self._mtimes = {} # username → last loaded mtime (float)
self._lock = threading.Lock()
# Load shared/global config
self._shared_conf_file = os.path.join(self.server_config_dir, &#34;config.yaml&#34;)
if os.path.exists(self._shared_conf_file):
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = os.path.getmtime(self._shared_conf_file)
else:
self._shared_config = None
self._shared_mtime = 0.0
def _refresh_shared(self):
&#34;&#34;&#34;Hot-reload shared config if the file changed on disk.&#34;&#34;&#34;
if not os.path.exists(self._shared_conf_file):
return
current_mtime = os.path.getmtime(self._shared_conf_file)
if current_mtime &gt; self._shared_mtime:
try:
self._shared_config = configfile(conf=self._shared_conf_file)
self._shared_mtime = current_mtime
# Clear all user providers so they pick up the new shared config
self._providers.clear()
self._mtimes.clear()
except Exception as e:
from connpy import printer
printer.warning(f&#34;Failed to reload shared config: {e}&#34;)
def get_provider(self, username) -&gt; ServiceProvider:
&#34;&#34;&#34;Get, lazy-load, or hot-reload a user&#39;s full ServiceProvider.&#34;&#34;&#34;
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user&#39;s config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get(&#34;config_path&#34;)
if config_path:
conf_file = os.path.join(config_path, &#34;config.yaml&#34;)
else:
conf_file = os.path.join(self.server_config_dir, &#34;users&#34;, username, &#34;config.yaml&#34;)
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) &lt; current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode=&#34;local&#34;)
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, &#34;close&#34;):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f&#34;Failed to hot-reload config for user &#39;{username}&#39; (file may be corrupt/incomplete): {e}&#34;)
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]
def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())
def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, &#34;close&#34;):
try:
provider.close()
except Exception:
pass</code></pre>
</details>
<div class="desc"><p>Holds per-user ServiceProviders in memory, thread-safe with hot-reloading.</p></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.evict"><code class="name flex">
<span>def <span class="ident">evict</span></span>(<span>self, username)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def evict(self, username):
&#34;&#34;&#34;Remove and cleanly shut down cached provider (after delete or password change).&#34;&#34;&#34;
with self._lock:
provider = self._providers.pop(username, None)
self._mtimes.pop(username, None)
if provider:
# Explicit cleanup of user-scoped resources if custom close/cleanup exists
if hasattr(provider, &#34;close&#34;):
try:
provider.close()
except Exception:
pass</code></pre>
</details>
<div class="desc"><p>Remove and cleanly shut down cached provider (after delete or password change).</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.get_provider"><code class="name flex">
<span>def <span class="ident">get_provider</span></span>(<span>self, username) > <a title="connpy.services.provider.ServiceProvider" href="../services/provider.html#connpy.services.provider.ServiceProvider">ServiceProvider</a></span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_provider(self, username) -&gt; ServiceProvider:
&#34;&#34;&#34;Get, lazy-load, or hot-reload a user&#39;s full ServiceProvider.&#34;&#34;&#34;
with self._lock:
# Refresh shared/global config if it has changed
self._refresh_shared()
# 1. Resolve physical path of the user&#39;s config.yaml file
user_data = self.user_service.get_user(username)
config_path = user_data.get(&#34;config_path&#34;)
if config_path:
conf_file = os.path.join(config_path, &#34;config.yaml&#34;)
else:
conf_file = os.path.join(self.server_config_dir, &#34;users&#34;, username, &#34;config.yaml&#34;)
# 2. Retrieve actual modification time in disk
current_mtime = os.path.getmtime(conf_file) if os.path.exists(conf_file) else 0.0
# 3. Validate if initial load or hot-reload is required
if username not in self._providers or self._mtimes.get(username, 0.0) &lt; current_mtime:
old_provider = self._providers.get(username)
try:
# Attempt a fresh configuration load
config = configfile(conf=conf_file, shared_config=self._shared_config)
new_provider = ServiceProvider(config, mode=&#34;local&#34;)
# Successfully loaded, clean up the old provider
if old_provider:
self._providers.pop(username, None)
if hasattr(old_provider, &#34;close&#34;):
try:
old_provider.close()
except Exception:
pass
self._providers[username] = new_provider
self._mtimes[username] = current_mtime
except Exception as e:
# Log warning but fallback to the old stable provider in memory if available
from connpy import printer
printer.warning(f&#34;Failed to hot-reload config for user &#39;{username}&#39; (file may be corrupt/incomplete): {e}&#34;)
if old_provider:
# Keep serving with the old cached instance to ensure service continuity
self._mtimes[username] = current_mtime
else:
# No fallback exists, propagate the exception
raise e
return self._providers[username]</code></pre>
</details>
<div class="desc"><p>Get, lazy-load, or hot-reload a user's full ServiceProvider.</p></div>
</dd>
<dt id="connpy.grpc_layer.user_registry.UserRegistry.has_users"><code class="name flex">
<span>def <span class="ident">has_users</span></span>(<span>self) > bool</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def has_users(self) -&gt; bool:
&#34;&#34;&#34;Check if any users are registered (enables auth enforcement).&#34;&#34;&#34;
return bool(self.user_service.list_users())</code></pre>
</details>
<div class="desc"><p>Check if any users are registered (enables auth enforcement).</p></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.grpc_layer" href="index.html">connpy.grpc_layer</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.grpc_layer.user_registry.UserRegistry" href="#connpy.grpc_layer.user_registry.UserRegistry">UserRegistry</a></code></h4>
<ul class="">
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.evict" href="#connpy.grpc_layer.user_registry.UserRegistry.evict">evict</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.get_provider" href="#connpy.grpc_layer.user_registry.UserRegistry.get_provider">get_provider</a></code></li>
<li><code><a title="connpy.grpc_layer.user_registry.UserRegistry.has_users" href="#connpy.grpc_layer.user_registry.UserRegistry.has_users">has_users</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>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.grpc_layer.utils API documentation</title> <title>connpy.grpc_layer.utils API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -138,7 +138,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+103 -27
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy API documentation</title> <title>connpy API documentation</title>
<meta name="description" content="&lt;p align=&#34;center&#34;&gt; <meta name="description" content="&lt;p align=&#34;center&#34;&gt;
&lt;img src=&#34;https://nginx.gederico.dynu.net/images/CONNPY-resized.png&#34; alt=&#34;App Logo&#34;&gt; &lt;img src=&#34;https://nginx.gederico.dynu.net/images/CONNPY-resized.png&#34; alt=&#34;App Logo&#34;&gt;
@@ -51,8 +51,12 @@ el.replaceWith(d);
<h2 id="ai-copilot-new-in-v6">🤖 AI Copilot (New in v6)</h2> <h2 id="ai-copilot-new-in-v6">🤖 AI Copilot (New in v6)</h2>
<p>The AI Copilot is deeply integrated into your terminal workflow: <p>The AI Copilot is deeply integrated into your terminal workflow:
- <strong>Terminal Context Awareness</strong>: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time. - <strong>Terminal Context Awareness</strong>: The Copilot can "see" your screen output, helping you diagnose errors or analyze command results in real-time.
- <strong>Dynamic Context Selection</strong>: Flexibly select single, range, or line-based terminal blocks to feed the Copilot, filtering out interactive scrolling garbage automatically (e.g., Cisco IOS/XR scrolling, paginators).
- <strong>Hybrid Multi-Agent System</strong>: Automatically escalates complex tasks between the <strong>Network Engineer</strong> (execution) and the <strong>Network Architect</strong> (strategy). - <strong>Hybrid Multi-Agent System</strong>: Automatically escalates complex tasks between the <strong>Network Engineer</strong> (execution) and the <strong>Network Architect</strong> (strategy).
- <strong>MCP Integration</strong>: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol. - <strong>MCP Integration</strong>: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
- <strong>Flexible Auth &amp; Keyless AI</strong>: Support for advanced LiteLLM credentials (<code>--engineer-auth</code> / <code>--architect-auth</code>) allowing keyless local models (Ollama), cloud engines (Vertex AI), or custom endpoints.
- <strong>Enhanced Session Management</strong>: Uniquely generated sessions, robust pagination, and interactive styling translating prompt themes directly to terminal escapes.
- <strong>Semantic Prompt Integration</strong>: Emit standard OSC prompt sequences (<code>]133;B</code>) for real-time remote/web front-end command tracking.
- <strong>Interactive Chat</strong>: Launch with <code>conn <a title="connpy.ai" href="#connpy.ai">ai</a></code> for a collaborative troubleshooting session.</p> - <strong>Interactive Chat</strong>: Launch with <code>conn <a title="connpy.ai" href="#connpy.ai">ai</a></code> for a collaborative troubleshooting session.</p>
<h2 id="core-features">Core Features</h2> <h2 id="core-features">Core Features</h2>
<ul> <ul>
@@ -642,8 +646,11 @@ class ai:
self.interrupted = False self.interrupted = False
# 1. Cargar configuración genérica # 1. Cargar configuración genérica con herencia/merge global
aiconfig = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
aiconfig = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
aiconfig = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
# Modelos (Prioridad: Argumento -&gt; Config -&gt; Default) # Modelos (Prioridad: Argumento -&gt; Config -&gt; Default)
self.engineer_model = engineer_model or aiconfig.get(&#34;engineer_model&#34;) or &#34;gemini/gemini-3.1-flash-lite&#34; self.engineer_model = engineer_model or aiconfig.get(&#34;engineer_model&#34;) or &#34;gemini/gemini-3.1-flash-lite&#34;
@@ -1534,9 +1541,11 @@ class ai:
@MethodHook @MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower() is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless: if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;) raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
@@ -2144,7 +2153,7 @@ Node: {node_name}&#34;&#34;&#34;
<dl> <dl>
<dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt> <dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt>
<dd> <dd>
<div class="desc"><p>The type of the None singleton.</p></div> <div class="desc"></div>
</dd> </dd>
</dl> </dl>
<h3>Instance variables</h3> <h3>Instance variables</h3>
@@ -2479,9 +2488,11 @@ Node: {node_name}&#34;&#34;&#34;
</summary> </summary>
<pre><code class="python">@MethodHook <pre><code class="python">@MethodHook
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None): def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
soft_limit_warned = False
is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower() is_engineer_keyless = &#34;vertex&#34; in self.engineer_model.lower() or &#34;ollama&#34; in self.engineer_model.lower() or &#34;local&#34; in self.engineer_model.lower()
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless: if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;) raise ValueError(&#34;Engineer API key or authentication not configured. Use &#39;connpy config --engineer-auth &lt;auth&gt;&#39; to set it.&#34;)
if chat_history is None: chat_history = [] if chat_history is None: chat_history = []
@@ -3184,7 +3195,7 @@ def confirm(self, user_input): return True</code></pre>
</dd> </dd>
<dt id="connpy.configfile"><code class="flex name class"> <dt id="connpy.configfile"><code class="flex name class">
<span>class <span class="ident">configfile</span></span> <span>class <span class="ident">configfile</span></span>
<span>(</span><span>conf=None, key=None)</span> <span>(</span><span>conf=None, key=None, shared_config=None)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
@@ -3217,7 +3228,8 @@ class configfile:
passwords. passwords.
&#39;&#39;&#39; &#39;&#39;&#39;
def __init__(self, conf = None, key = None): def __init__(self, conf = None, key = None, shared_config = None):
self._shared_config = shared_config
&#39;&#39;&#39; &#39;&#39;&#39;
### Optional Parameters: ### Optional Parameters:
@@ -3323,6 +3335,42 @@ class configfile:
self._generate_nodes_cache() self._generate_nodes_cache()
def get_effective_setting(self, key, default=None):
&#34;&#34;&#34;Get config setting with shared fallback for inheritable keys.&#34;&#34;&#34;
val = self.config.get(key)
if key == &#34;ai&#34;:
if val is not None:
if self._shared_config:
import copy
# Deep merge: shared as base, user overrides
base = copy.deepcopy(self._shared_config.config.get(key, {}))
if isinstance(base, dict) and isinstance(val, dict):
# Credential isolation:
# If user defines engineer credentials, discard shared ones
if &#34;engineer_api_key&#34; in val or &#34;engineer_auth&#34; in val:
base.pop(&#34;engineer_api_key&#34;, None)
base.pop(&#34;engineer_auth&#34;, None)
# If user defines architect credentials, discard shared ones
if &#34;architect_api_key&#34; in val or &#34;architect_auth&#34; in val:
base.pop(&#34;architect_api_key&#34;, None)
base.pop(&#34;architect_auth&#34;, None)
# Recursive update for inner dictionaries (like mcp_servers or model details)
def deep_merge(d1, d2):
for k, v in d2.items():
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
deep_merge(d1[k], v)
else:
d1[k] = copy.deepcopy(v)
deep_merge(base, val)
return base
return val
elif self._shared_config:
return self._shared_config.config.get(key, default)
return val if val is not None else default
def _validate_config(self, data): def _validate_config(self, data):
&#34;&#34;&#34;Verify config data has the required structure.&#34;&#34;&#34; &#34;&#34;&#34;Verify config data has the required structure.&#34;&#34;&#34;
if not isinstance(data, dict): if not isinstance(data, dict):
@@ -3663,7 +3711,8 @@ class configfile:
else: else:
printer.error(&#34;Filter must be a string or a list of strings&#34;) printer.error(&#34;Filter must be a string or a list of strings&#34;)
sys.exit(1) sys.exit(1)
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)] flags = re.IGNORECASE if not self.config.get(&#34;case&#34;, False) else 0
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
return nodes return nodes
@MethodHook @MethodHook
@@ -3786,13 +3835,6 @@ class configfile:
- publickey (obj): Object containing the public key to decrypt - publickey (obj): Object containing the public key to decrypt
passwords. passwords.
</code></pre>
<h3 id="optional-parameters">Optional Parameters:</h3>
<pre><code>- conf (str): Path/file to config file. If left empty default
path is ~/.config/conn/config.yaml
- key (str): Path/file to RSA key file. If left empty default
path is ~/.config/conn/.osk
</code></pre></div> </code></pre></div>
<h3>Methods</h3> <h3>Methods</h3>
<dl> <dl>
@@ -3844,6 +3886,51 @@ def encrypt(self, password, keyfile=None):
<pre><code>str: Encrypted password. <pre><code>str: Encrypted password.
</code></pre></div> </code></pre></div>
</dd> </dd>
<dt id="connpy.configfile.get_effective_setting"><code class="name flex">
<span>def <span class="ident">get_effective_setting</span></span>(<span>self, key, default=None)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_effective_setting(self, key, default=None):
&#34;&#34;&#34;Get config setting with shared fallback for inheritable keys.&#34;&#34;&#34;
val = self.config.get(key)
if key == &#34;ai&#34;:
if val is not None:
if self._shared_config:
import copy
# Deep merge: shared as base, user overrides
base = copy.deepcopy(self._shared_config.config.get(key, {}))
if isinstance(base, dict) and isinstance(val, dict):
# Credential isolation:
# If user defines engineer credentials, discard shared ones
if &#34;engineer_api_key&#34; in val or &#34;engineer_auth&#34; in val:
base.pop(&#34;engineer_api_key&#34;, None)
base.pop(&#34;engineer_auth&#34;, None)
# If user defines architect credentials, discard shared ones
if &#34;architect_api_key&#34; in val or &#34;architect_auth&#34; in val:
base.pop(&#34;architect_api_key&#34;, None)
base.pop(&#34;architect_auth&#34;, None)
# Recursive update for inner dictionaries (like mcp_servers or model details)
def deep_merge(d1, d2):
for k, v in d2.items():
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
deep_merge(d1[k], v)
else:
d1[k] = copy.deepcopy(v)
deep_merge(base, val)
return base
return val
elif self._shared_config:
return self._shared_config.config.get(key, default)
return val if val is not None else default</code></pre>
</details>
<div class="desc"><p>Get config setting with shared fallback for inheritable keys.</p></div>
</dd>
<dt id="connpy.configfile.getitem"><code class="name flex"> <dt id="connpy.configfile.getitem"><code class="name flex">
<span>def <span class="ident">getitem</span></span>(<span>self, unique, keys=None, extract=False)</span> <span>def <span class="ident">getitem</span></span>(<span>self, unique, keys=None, extract=False)</span>
</code></dt> </code></dt>
@@ -5000,18 +5087,6 @@ class node:
cmd += f&#34; {self.options}&#34; cmd += f&#34; {self.options}&#34;
return cmd return cmd
@MethodHook
def _generate_ssm_cmd(self):
region = self.tags.get(&#34;region&#34;, &#34;&#34;) if isinstance(self.tags, dict) else &#34;&#34;
profile = self.tags.get(&#34;profile&#34;, &#34;&#34;) if isinstance(self.tags, dict) else &#34;&#34;
cmd = f&#34;aws ssm start-session --target {self.host}&#34;
if region:
cmd += f&#34; --region {region}&#34;
if profile:
cmd += f&#34; --profile {profile}&#34;
if self.options:
cmd += f&#34; {self.options}&#34;
return cmd
@MethodHook @MethodHook
def _get_cmd(self): def _get_cmd(self):
@@ -6358,6 +6433,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
<h4><code><a title="connpy.configfile" href="#connpy.configfile">configfile</a></code></h4> <h4><code><a title="connpy.configfile" href="#connpy.configfile">configfile</a></code></h4>
<ul class=""> <ul class="">
<li><code><a title="connpy.configfile.encrypt" href="#connpy.configfile.encrypt">encrypt</a></code></li> <li><code><a title="connpy.configfile.encrypt" href="#connpy.configfile.encrypt">encrypt</a></code></li>
<li><code><a title="connpy.configfile.get_effective_setting" href="#connpy.configfile.get_effective_setting">get_effective_setting</a></code></li>
<li><code><a title="connpy.configfile.getitem" href="#connpy.configfile.getitem">getitem</a></code></li> <li><code><a title="connpy.configfile.getitem" href="#connpy.configfile.getitem">getitem</a></code></li>
<li><code><a title="connpy.configfile.getitems" href="#connpy.configfile.getitems">getitems</a></code></li> <li><code><a title="connpy.configfile.getitems" href="#connpy.configfile.getitems">getitems</a></code></li>
</ul> </ul>
@@ -6384,7 +6460,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+10 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.mcp_client API documentation</title> <title>connpy.mcp_client API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -86,7 +86,10 @@ el.replaceWith(d);
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
mcp_config = self.config.get_effective_setting(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
else:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
except Exception: except Exception:
return [] return []
@@ -260,7 +263,10 @@ el.replaceWith(d);
all_llm_tools = [] all_llm_tools = []
try: try:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
mcp_config = self.config.get_effective_setting(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {})
else:
mcp_config = self.config.config.get(&#34;ai&#34;, {}).get(&#34;mcp_servers&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
except Exception: except Exception:
return [] return []
@@ -343,7 +349,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.proto API documentation</title> <title>connpy.proto API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -60,7 +60,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+10 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.ai_service API documentation</title> <title>connpy.services.ai_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -359,7 +359,10 @@ el.replaceWith(d);
def list_mcp_servers(self) -&gt; dict: def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {}) return ai_settings.get(&#34;mcp_servers&#34;, {})
def load_session_data(self, session_id): def load_session_data(self, session_id):
@@ -669,7 +672,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def list_mcp_servers(self) -&gt; dict: <pre><code class="python">def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {})</code></pre> return ai_settings.get(&#34;mcp_servers&#34;, {})</code></pre>
</details> </details>
<div class="desc"><p>Get the configured MCP servers.</p></div> <div class="desc"><p>Get the configured MCP servers.</p></div>
@@ -826,7 +832,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.base API documentation</title> <title>connpy.services.base API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -152,7 +152,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.config_service API documentation</title> <title>connpy.services.config_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -319,7 +319,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.context_service API documentation</title> <title>connpy.services.context_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -370,7 +370,7 @@ def current_context(self) -&gt; str:
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.exceptions API documentation</title> <title>connpy.services.exceptions API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -268,7 +268,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.execution_service API documentation</title> <title>connpy.services.execution_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -449,7 +449,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.import_export_service API documentation</title> <title>connpy.services.import_export_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -361,7 +361,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+240 -96
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services API documentation</title> <title>connpy.services API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -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.services.user_service" href="user_service.html">connpy.services.user_service</a></code></dt>
<dd>
<div class="desc"></div>
</dd>
</dl> </dl>
</section> </section>
<section> <section>
@@ -414,7 +418,10 @@ el.replaceWith(d);
def list_mcp_servers(self) -&gt; dict: def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {}) return ai_settings.get(&#34;mcp_servers&#34;, {})
def load_session_data(self, session_id): def load_session_data(self, session_id):
@@ -724,7 +731,10 @@ el.replaceWith(d);
</summary> </summary>
<pre><code class="python">def list_mcp_servers(self) -&gt; dict: <pre><code class="python">def list_mcp_servers(self) -&gt; dict:
&#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34; &#34;&#34;&#34;Get the configured MCP servers.&#34;&#34;&#34;
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;get_effective_setting&#34;):
ai_settings = self.config.get_effective_setting(&#34;ai&#34;, {})
else:
ai_settings = self.config.config.get(&#34;ai&#34;, {}) if hasattr(self.config, &#34;config&#34;) else {}
return ai_settings.get(&#34;mcp_servers&#34;, {})</code></pre> return ai_settings.get(&#34;mcp_servers&#34;, {})</code></pre>
</details> </details>
<div class="desc"><p>Get the configured MCP servers.</p></div> <div class="desc"><p>Get the configured MCP servers.</p></div>
@@ -2008,7 +2018,7 @@ el.replaceWith(d);
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -2022,9 +2032,10 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -2037,7 +2048,8 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
&#34;&#34;&#34;Interact with a node directly.&#34;&#34;&#34; &#34;&#34;&#34;Interact with a node directly.&#34;&#34;&#34;
@@ -2267,14 +2279,14 @@ el.replaceWith(d);
<div class="desc"><p>Interact with a node directly.</p></div> <div class="desc"><p>Interact with a node directly.</p></div>
</dd> </dd>
<dt id="connpy.services.NodeService.delete_node"><code class="name flex"> <dt id="connpy.services.NodeService.delete_node"><code class="name flex">
<span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False)</span> <span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def delete_node(self, unique_id, is_folder=False): <pre><code class="python">def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -2287,7 +2299,8 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file)</code></pre> if save:
self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Logic for deleting a node or folder.</p></div> <div class="desc"><p>Logic for deleting a node or folder.</p></div>
</dd> </dd>
@@ -2496,14 +2509,14 @@ el.replaceWith(d);
<div class="desc"><p>Move or copy a node.</p></div> <div class="desc"><p>Move or copy a node.</p></div>
</dd> </dd>
<dt id="connpy.services.NodeService.update_node"><code class="name flex"> <dt id="connpy.services.NodeService.update_node"><code class="name flex">
<span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data)</span> <span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def update_node(self, unique_id, data): <pre><code class="python">def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -2517,7 +2530,8 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file)</code></pre> if save:
self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Explicitly update an existing node.</p></div> <div class="desc"><p>Explicitly update an existing node.</p></div>
</dd> </dd>
@@ -2568,16 +2582,47 @@ el.replaceWith(d);
<pre><code class="python">class PluginService(BaseService): <pre><code class="python">class PluginService(BaseService):
&#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34;
def _get_plugin_path(self, name, include_disabled=True):
&#34;&#34;&#34;Resolves the physical path of a plugin by name. Priority: user, shared/global, core.&#34;&#34;&#34;
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;user&#34;, True
if include_disabled:
bkp_file = os.path.join(user_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;user&#34;, False
# 2. Shared/Global directory
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;shared&#34;, True
if include_disabled:
bkp_file = os.path.join(shared_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;shared&#34;, False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
p_file = os.path.join(core_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;core&#34;, True
return None, None, False
def list_plugins(self): def list_plugins(self):
&#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34; &#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34;
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -2587,12 +2632,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
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)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
@@ -2600,6 +2668,7 @@ el.replaceWith(d);
return all_plugin_info return all_plugin_info
def add_plugin(self, name, source_file, update=False): def add_plugin(self, name, source_file, update=False):
&#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34; &#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34;
import os import os
@@ -2680,6 +2749,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
def enable_plugin(self, name): def enable_plugin(self, name):
@@ -2688,17 +2761,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file): if os.path.exists(plugin_file):
return False # Already enabled return False # Already enabled
if not os.path.exists(disabled_file): # If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
try: raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
def disable_plugin(self, name): def disable_plugin(self, name):
&#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34; &#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34;
@@ -2706,33 +2800,41 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file): if os.path.exists(disabled_file):
return False # Already disabled return False # Already disabled
if not os.path.exists(plugin_file): # Check if it exists in shared or core
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
try: # Shadow disable it by creating an empty .py.bkp in user plugins dir
os.rename(plugin_file, disabled_file) plugin_dir = os.path.dirname(plugin_file)
return True os.makedirs(plugin_dir, exist_ok=True)
except OSError as e: try:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;) with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)
def get_plugin_source(self, name): def get_plugin_source(self, name):
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read() return f.read()
def invoke_plugin(self, name, args_dict): def invoke_plugin(self, name, args_dict):
@@ -2772,17 +2874,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -2935,6 +3032,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre> raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Remove a plugin file permanently.</p></div> <div class="desc"><p>Remove a plugin file permanently.</p></div>
@@ -2953,17 +3054,31 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file): if os.path.exists(disabled_file):
return False # Already disabled return False # Already disabled
if not os.path.exists(plugin_file): # Check if it exists in shared or core
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
try: # Shadow disable it by creating an empty .py.bkp in user plugins dir
os.rename(plugin_file, disabled_file) plugin_dir = os.path.dirname(plugin_file)
return True os.makedirs(plugin_dir, exist_ok=True)
except OSError as e: try:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)</code></pre> with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div> <div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
</dd> </dd>
@@ -2981,17 +3096,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file): if os.path.exists(plugin_file):
return False # Already enabled return False # Already enabled
if not os.path.exists(disabled_file): # If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
try: raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div> <div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
</dd> </dd>
@@ -3007,17 +3143,11 @@ el.replaceWith(d);
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read()</code></pre> return f.read()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -3067,17 +3197,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -3146,11 +3271,6 @@ el.replaceWith(d);
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -3160,12 +3280,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
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)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
@@ -3854,6 +3997,7 @@ el.replaceWith(d);
<li><code><a title="connpy.services.provider" href="provider.html">connpy.services.provider</a></code></li> <li><code><a title="connpy.services.provider" href="provider.html">connpy.services.provider</a></code></li>
<li><code><a title="connpy.services.sync_service" href="sync_service.html">connpy.services.sync_service</a></code></li> <li><code><a title="connpy.services.sync_service" href="sync_service.html">connpy.services.sync_service</a></code></li>
<li><code><a title="connpy.services.system_service" href="system_service.html">connpy.services.system_service</a></code></li> <li><code><a title="connpy.services.system_service" href="system_service.html">connpy.services.system_service</a></code></li>
<li><code><a title="connpy.services.user_service" href="user_service.html">connpy.services.user_service</a></code></li>
</ul> </ul>
</li> </li>
<li><h3><a href="#header-classes">Classes</a></h3> <li><h3><a href="#header-classes">Classes</a></h3>
@@ -3984,7 +4128,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+16 -12
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.node_service API documentation</title> <title>connpy.services.node_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -198,7 +198,7 @@ el.replaceWith(d);
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) self.config._saveconfig(self.config.file)
def update_node(self, unique_id, data): def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -212,9 +212,10 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def delete_node(self, unique_id, is_folder=False): def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -227,7 +228,8 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file) if save:
self.config._saveconfig(self.config.file)
def connect_node(self, unique_id, sftp=False, debug=False, logger=None): def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
&#34;&#34;&#34;Interact with a node directly.&#34;&#34;&#34; &#34;&#34;&#34;Interact with a node directly.&#34;&#34;&#34;
@@ -457,14 +459,14 @@ el.replaceWith(d);
<div class="desc"><p>Interact with a node directly.</p></div> <div class="desc"><p>Interact with a node directly.</p></div>
</dd> </dd>
<dt id="connpy.services.node_service.NodeService.delete_node"><code class="name flex"> <dt id="connpy.services.node_service.NodeService.delete_node"><code class="name flex">
<span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False)</span> <span>def <span class="ident">delete_node</span></span>(<span>self, unique_id, is_folder=False, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def delete_node(self, unique_id, is_folder=False): <pre><code class="python">def delete_node(self, unique_id, is_folder=False, save=True):
&#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34; &#34;&#34;&#34;Logic for deleting a node or folder.&#34;&#34;&#34;
if is_folder: if is_folder:
uniques = self.config._explode_unique(unique_id) uniques = self.config._explode_unique(unique_id)
@@ -477,7 +479,8 @@ el.replaceWith(d);
raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;) raise NodeNotFoundError(f&#34;Node &#39;{unique_id}&#39; not found or invalid.&#34;)
self.config._connections_del(**uniques) self.config._connections_del(**uniques)
self.config._saveconfig(self.config.file)</code></pre> if save:
self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Logic for deleting a node or folder.</p></div> <div class="desc"><p>Logic for deleting a node or folder.</p></div>
</dd> </dd>
@@ -686,14 +689,14 @@ el.replaceWith(d);
<div class="desc"><p>Move or copy a node.</p></div> <div class="desc"><p>Move or copy a node.</p></div>
</dd> </dd>
<dt id="connpy.services.node_service.NodeService.update_node"><code class="name flex"> <dt id="connpy.services.node_service.NodeService.update_node"><code class="name flex">
<span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data)</span> <span>def <span class="ident">update_node</span></span>(<span>self, unique_id, data, save=True)</span>
</code></dt> </code></dt>
<dd> <dd>
<details class="source"> <details class="source">
<summary> <summary>
<span>Expand source code</span> <span>Expand source code</span>
</summary> </summary>
<pre><code class="python">def update_node(self, unique_id, data): <pre><code class="python">def update_node(self, unique_id, data, save=True):
&#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34; &#34;&#34;&#34;Explicitly update an existing node.&#34;&#34;&#34;
all_nodes = self.config._getallnodes() all_nodes = self.config._getallnodes()
if unique_id not in all_nodes: if unique_id not in all_nodes:
@@ -707,7 +710,8 @@ el.replaceWith(d);
# config._connections_add actually handles updates if ID exists correctly # config._connections_add actually handles updates if ID exists correctly
self.config._connections_add(**data) self.config._connections_add(**data)
self.config._saveconfig(self.config.file)</code></pre> if save:
self.config._saveconfig(self.config.file)</code></pre>
</details> </details>
<div class="desc"><p>Explicitly update an existing node.</p></div> <div class="desc"><p>Explicitly update an existing node.</p></div>
</dd> </dd>
@@ -786,7 +790,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+213 -84
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.plugin_service API documentation</title> <title>connpy.services.plugin_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -58,16 +58,47 @@ el.replaceWith(d);
<pre><code class="python">class PluginService(BaseService): <pre><code class="python">class PluginService(BaseService):
&#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34; &#34;&#34;&#34;Business logic for enabling, disabling, and listing plugins.&#34;&#34;&#34;
def _get_plugin_path(self, name, include_disabled=True):
&#34;&#34;&#34;Resolves the physical path of a plugin by name. Priority: user, shared/global, core.&#34;&#34;&#34;
import os
# 1. User directory
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
p_file = os.path.join(user_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;user&#34;, True
if include_disabled:
bkp_file = os.path.join(user_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;user&#34;, False
# 2. Shared/Global directory
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
p_file = os.path.join(shared_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;shared&#34;, True
if include_disabled:
bkp_file = os.path.join(shared_dir, f&#34;{name}.py.bkp&#34;)
if os.path.exists(bkp_file):
return bkp_file, &#34;shared&#34;, False
# 3. Core plugins
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
p_file = os.path.join(core_dir, f&#34;{name}.py&#34;)
if os.path.exists(p_file):
return p_file, &#34;core&#34;, True
return None, None, False
def list_plugins(self): def list_plugins(self):
&#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34; &#34;&#34;&#34;List all core and user-defined plugins with their status and hash.&#34;&#34;&#34;
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -77,12 +108,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
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)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
@@ -90,6 +144,7 @@ el.replaceWith(d);
return all_plugin_info return all_plugin_info
def add_plugin(self, name, source_file, update=False): def add_plugin(self, name, source_file, update=False):
&#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34; &#34;&#34;&#34;Add or update a plugin from a local file.&#34;&#34;&#34;
import os import os
@@ -170,6 +225,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
def enable_plugin(self, name): def enable_plugin(self, name):
@@ -178,17 +237,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file): if os.path.exists(plugin_file):
return False # Already enabled return False # Already enabled
if not os.path.exists(disabled_file): # If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
try: raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
def disable_plugin(self, name): def disable_plugin(self, name):
&#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34; &#34;&#34;&#34;Deactivate a plugin by renaming it to a backup file.&#34;&#34;&#34;
@@ -196,33 +276,41 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file): if os.path.exists(disabled_file):
return False # Already disabled return False # Already disabled
if not os.path.exists(plugin_file): # Check if it exists in shared or core
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
try: # Shadow disable it by creating an empty .py.bkp in user plugins dir
os.rename(plugin_file, disabled_file) plugin_dir = os.path.dirname(plugin_file)
return True os.makedirs(plugin_dir, exist_ok=True)
except OSError as e: try:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;) with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)
def get_plugin_source(self, name): def get_plugin_source(self, name):
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read() return f.read()
def invoke_plugin(self, name, args_dict): def invoke_plugin(self, name, args_dict):
@@ -262,17 +350,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -425,6 +508,10 @@ el.replaceWith(d);
raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;) raise InvalidConfigurationError(f&#34;Failed to delete plugin file &#39;{f}&#39;: {e}&#34;)
if not deleted: if not deleted:
# If not deleted from user directory, check if it&#39;s in shared or core
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
raise InvalidConfigurationError(&#34;Global and core plugins are read-only and cannot be deleted by users.&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre> raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Remove a plugin file permanently.</p></div> <div class="desc"><p>Remove a plugin file permanently.</p></div>
@@ -443,17 +530,31 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(plugin_file):
# Regular user-level plugin exists. Rename to bkp
try:
os.rename(plugin_file, disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(disabled_file): if os.path.exists(disabled_file):
return False # Already disabled return False # Already disabled
if not os.path.exists(plugin_file): # Check if it exists in shared or core
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is a core plugin.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
try: # Shadow disable it by creating an empty .py.bkp in user plugins dir
os.rename(plugin_file, disabled_file) plugin_dir = os.path.dirname(plugin_file)
return True os.makedirs(plugin_dir, exist_ok=True)
except OSError as e: try:
raise InvalidConfigurationError(f&#34;Failed to disable plugin &#39;{name}&#39;: {e}&#34;)</code></pre> with open(disabled_file, &#34;w&#34;) as f:
f.write(&#34;&#34;)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to create shadow disable file: {e}&#34;)
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found or is already disabled.&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div> <div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
</dd> </dd>
@@ -471,17 +572,38 @@ el.replaceWith(d);
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
disabled_file = f&#34;{plugin_file}.bkp&#34; disabled_file = f&#34;{plugin_file}.bkp&#34;
if os.path.exists(disabled_file):
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
is_shadow = False
if os.path.getsize(disabled_file) == 0:
# Resolve without the local bkp file to verify if shared/core has it
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
is_shadow = True
if is_shadow:
# Remove shadow file to restore inheritance
try:
os.remove(disabled_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to remove shadow file &#39;{disabled_file}&#39;: {e}&#34;)
else:
try:
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)
if os.path.exists(plugin_file): if os.path.exists(plugin_file):
return False # Already enabled return False # Already enabled
if not os.path.exists(disabled_file): # If it doesn&#39;t exist locally, check if it&#39;s already an active shared/core plugin
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
if origin in [&#34;shared&#34;, &#34;core&#34;]:
return False # Already active/enabled through inheritance
try: raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found.&#34;)</code></pre>
os.rename(disabled_file, plugin_file)
return True
except OSError as e:
raise InvalidConfigurationError(f&#34;Failed to enable plugin &#39;{name}&#39;: {e}&#34;)</code></pre>
</details> </details>
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div> <div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
</dd> </dd>
@@ -497,17 +619,11 @@ el.replaceWith(d);
import os import os
from ..services.exceptions import InvalidConfigurationError from ..services.exceptions import InvalidConfigurationError
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;) path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34; if not path:
if os.path.exists(plugin_file):
target = plugin_file
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
with open(target, &#34;r&#34;) as f: with open(path, &#34;r&#34;) as f:
return f.read()</code></pre> return f.read()</code></pre>
</details> </details>
<div class="desc"></div> <div class="desc"></div>
@@ -557,17 +673,12 @@ el.replaceWith(d);
p_manager = Plugins() p_manager = Plugins()
import os import os
plugin_file = os.path.join(self.config.defaultdir, &#34;plugins&#34;, f&#34;{name}.py&#34;)
core_path = os.path.dirname(os.path.realpath(__file__)) + f&#34;/../core_plugins/{name}.py&#34;
if os.path.exists(plugin_file): path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
target = plugin_file if not path:
elif os.path.exists(core_path):
target = core_path
else:
raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;) raise InvalidConfigurationError(f&#34;Plugin &#39;{name}&#39; not found&#34;)
module = p_manager._import_from_path(target) module = p_manager._import_from_path(path)
parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None parser = module.Parser().parser if hasattr(module, &#34;Parser&#34;) else None
if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]): if &#34;__func_name__&#34; in args_dict and hasattr(module, args_dict[&#34;__func_name__&#34;]):
@@ -636,11 +747,6 @@ el.replaceWith(d);
import os import os
import hashlib import hashlib
# Check for user plugins directory
plugin_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
# Check for core plugins directory
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
all_plugin_info = {} all_plugin_info = {}
def get_hash(path): def get_hash(path):
@@ -650,12 +756,35 @@ el.replaceWith(d);
except Exception: except Exception:
return &#34;&#34; return &#34;&#34;
# User plugins # 1. Scan core plugins (lowest priority)
if os.path.exists(plugin_dir): core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), &#34;..&#34;, &#34;core_plugins&#34;)
for f in os.listdir(plugin_dir): if os.path.exists(core_dir):
for f in os.listdir(core_dir):
if f.endswith(&#34;.py&#34;): if f.endswith(&#34;.py&#34;):
name = f[:-3] name = f[:-3]
path = os.path.join(plugin_dir, f) path = os.path.join(core_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
# 2. Scan shared plugins (medium priority)
if hasattr(self.config, &#34;_shared_config&#34;) and self.config._shared_config:
shared_dir = os.path.join(self.config._shared_config.defaultdir, &#34;plugins&#34;)
if os.path.exists(shared_dir):
for f in os.listdir(shared_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
path = os.path.join(shared_dir, f)
all_plugin_info[name] = {&#34;enabled&#34;: True, &#34;hash&#34;: get_hash(path)}
elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7]
all_plugin_info[name] = {&#34;enabled&#34;: False}
# 3. Scan user plugins (highest priority)
user_dir = os.path.join(self.config.defaultdir, &#34;plugins&#34;)
if os.path.exists(user_dir):
for f in os.listdir(user_dir):
if f.endswith(&#34;.py&#34;):
name = f[:-3]
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)}
elif f.endswith(&#34;.py.bkp&#34;): elif f.endswith(&#34;.py.bkp&#34;):
name = f[:-7] name = f[:-7]
@@ -709,7 +838,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.profile_service API documentation</title> <title>connpy.services.profile_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -429,7 +429,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+30 -4
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.provider API documentation</title> <title>connpy.services.provider API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -98,6 +98,7 @@ el.replaceWith(d);
from .import_export_service import ImportExportService from .import_export_service import ImportExportService
from .context_service import ContextService from .context_service import ContextService
from .sync_service import SyncService 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)
@@ -109,6 +110,7 @@ el.replaceWith(d);
self.import_export = ImportExportService(self.config) self.import_export = ImportExportService(self.config)
self.context = ContextService(self.config) self.context = ContextService(self.config)
self.sync = SyncService(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
@@ -118,22 +120,46 @@ el.replaceWith(d);
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.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;)
import grpc import grpc
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub import os
from ..grpc_layer.stubs import (
NodeStub, ProfileStub, PluginStub, AIStub,
ExecutionStub, ImportExportStub, SystemStub,
ConfigStub, AuthClientInterceptor, AuthStub
)
def get_token():
token_path = os.path.join(self.config.defaultdir, &#34;.token&#34;)
if os.path.exists(token_path):
try:
with open(token_path, &#34;r&#34;) as f:
return f.read().strip()
except Exception:
pass
return None
channel = grpc.insecure_channel(self.remote_host) channel = grpc.insecure_channel(self.remote_host)
interceptor = AuthClientInterceptor(get_token)
channel = grpc.intercept_channel(channel, interceptor)
# Surgical fix: Keep ConfigService local for mode/theme management,
# but delegate encryption to the server stub.
config_remote = ConfigStub(channel, remote_host=self.remote_host)
self.config_svc.encrypt_password = config_remote.encrypt_password
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config) self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes) self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
self.plugins = PluginStub(channel, remote_host=self.remote_host) self.plugins = PluginStub(channel, remote_host=self.remote_host)
self.ai = AIStub(channel, remote_host=self.remote_host) self.ai = AIStub(channel, remote_host=self.remote_host)
self.system = SystemStub(channel, remote_host=self.remote_host) self.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)</code></pre> self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
self.auth = AuthStub(channel, remote_host=self.remote_host)</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>
</dd> </dd>
@@ -164,7 +190,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.sync_service API documentation</title> <title>connpy.services.sync_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -964,7 +964,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.services.system_service API documentation</title> <title>connpy.services.system_service API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -325,7 +325,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+595
View File
@@ -0,0 +1,595 @@
<!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.services.user_service 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.services.user_service</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.services.user_service.UserService"><code class="flex name class">
<span>class <span class="ident">UserService</span></span>
<span>(</span><span>config_dir)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">class UserService:
def __init__(self, config_dir):
self.config_dir = os.path.abspath(config_dir)
self.users_dir = os.path.join(self.config_dir, &#34;users&#34;)
self.registry_file = os.path.join(self.users_dir, &#34;registry.yaml&#34;)
# Ensure users directory exists
os.makedirs(self.users_dir, exist_ok=True)
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;
if not os.path.exists(self.registry_file):
registry = {
&#34;jwt_secret&#34;: secrets.token_hex(32),
&#34;users&#34;: {}
}
self._save_registry(registry)
return registry
try:
with open(self.registry_file, &#34;r&#34;) as f:
registry = yaml.safe_load(f) or {}
except Exception:
registry = {}
if not isinstance(registry, dict):
registry = {}
if &#34;jwt_secret&#34; not in registry:
registry[&#34;jwt_secret&#34;] = secrets.token_hex(32)
if &#34;users&#34; not in registry or not isinstance(registry[&#34;users&#34;], dict):
registry[&#34;users&#34;] = {}
return registry
def _save_registry(self, data: dict):
&#34;&#34;&#34;Safely saves registry structure to registry.yaml.&#34;&#34;&#34;
tmp_file = self.registry_file + &#34;.tmp&#34;
try:
with open(tmp_file, &#34;w&#34;) as f:
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
os.replace(tmp_file, self.registry_file)
os.chmod(self.registry_file, 0o600)
except Exception as e:
if os.path.exists(tmp_file):
try:
os.remove(tmp_file)
except OSError:
pass
raise e
def create_user(self, username, password, config_path=None) -&gt; dict:
&#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.
&#34;&#34;&#34;
if not username or not isinstance(username, str):
raise ValueError(&#34;Username cannot be empty&#34;)
if not re.match(r&#34;^[a-zA-Z0-9_-]+$&#34;, username):
raise ValueError(&#34;Username must contain only alphanumeric characters, dashes, or underscores&#34;)
if not password or not isinstance(password, str):
raise ValueError(&#34;Password cannot be empty&#34;)
registry = self._load_registry()
if username in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; already exists&#34;)
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(user_dir, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile
conf_file = os.path.join(user_dir, &#34;config.yaml&#34;)
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, &#34;config.yaml&#34;)
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
user_entry = {
&#34;password_hash&#34;: password_hash,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry[&#34;users&#34;][username] = user_entry
self._save_registry(registry)
return {
&#34;username&#34;: username,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: user_entry[&#34;created&#34;]
}
def delete_user(self, username):
&#34;&#34;&#34;Removes user from the registry and cleans up config directory if server-managed.&#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;)
user_data = registry[&#34;users&#34;][username]
config_path = user_data.get(&#34;config_path&#34;)
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry[&#34;users&#34;][username]
self._save_registry(registry)
def list_users(self) -&gt; list[dict]:
&#34;&#34;&#34;Lists all registered users with metadata.&#34;&#34;&#34;
registry = self._load_registry()
return [
{
&#34;username&#34;: name,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;)
}
for name, data in registry.get(&#34;users&#34;, {}).items()
]
def get_user(self, username) -&gt; dict:
&#34;&#34;&#34;Retrieves raw metadata for a specific user.&#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;)
data = registry[&#34;users&#34;][username]
return {
&#34;username&#34;: username,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;),
&#34;password_hash&#34;: data.get(&#34;password_hash&#34;)
}
def change_password(self, username, old_password, new_password):
&#34;&#34;&#34;Verifies old password and updates registry with new hashed password.&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password 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 not bcrypt.checkpw(old_password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;)):
raise ValueError(&#34;Invalid credentials&#34;)
# Update hash
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)
def admin_change_password(self, username, new_password):
&#34;&#34;&#34;Administrative password override (does not require old password).&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password 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]
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)
def authenticate(self, username, password) -&gt; bool:
&#34;&#34;&#34;Verifies if the credentials are valid using bcrypt.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
return False
user_data = registry[&#34;users&#34;][username]
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))
def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#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;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token
def verify_jwt(self, token) -&gt; str | None:
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
</details>
<div class="desc"></div>
<h3>Methods</h3>
<dl>
<dt id="connpy.services.user_service.UserService.admin_change_password"><code class="name flex">
<span>def <span class="ident">admin_change_password</span></span>(<span>self, username, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def admin_change_password(self, username, new_password):
&#34;&#34;&#34;Administrative password override (does not require old password).&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password 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]
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Administrative password override (does not require old password).</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.authenticate"><code class="name flex">
<span>def <span class="ident">authenticate</span></span>(<span>self, username, password) > bool</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def authenticate(self, username, password) -&gt; bool:
&#34;&#34;&#34;Verifies if the credentials are valid using bcrypt.&#34;&#34;&#34;
registry = self._load_registry()
if username not in registry[&#34;users&#34;]:
return False
user_data = registry[&#34;users&#34;][username]
return bcrypt.checkpw(password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;))</code></pre>
</details>
<div class="desc"><p>Verifies if the credentials are valid using bcrypt.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.change_password"><code class="name flex">
<span>def <span class="ident">change_password</span></span>(<span>self, username, old_password, new_password)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def change_password(self, username, old_password, new_password):
&#34;&#34;&#34;Verifies old password and updates registry with new hashed password.&#34;&#34;&#34;
if not new_password or not isinstance(new_password, str):
raise ValueError(&#34;New password 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 not bcrypt.checkpw(old_password.encode(&#34;utf-8&#34;), user_data[&#34;password_hash&#34;].encode(&#34;utf-8&#34;)):
raise ValueError(&#34;Invalid credentials&#34;)
# Update hash
user_data[&#34;password_hash&#34;] = bcrypt.hashpw(new_password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Verifies old password and updates registry with new hashed password.</p></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def create_user(self, username, password, config_path=None) -&gt; dict:
&#34;&#34;&#34;Creates a new user with bcrypt-hashed credentials.
Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.
&#34;&#34;&#34;
if not username or not isinstance(username, str):
raise ValueError(&#34;Username cannot be empty&#34;)
if not re.match(r&#34;^[a-zA-Z0-9_-]+$&#34;, username):
raise ValueError(&#34;Username must contain only alphanumeric characters, dashes, or underscores&#34;)
if not password or not isinstance(password, str):
raise ValueError(&#34;Password cannot be empty&#34;)
registry = self._load_registry()
if username in registry[&#34;users&#34;]:
raise ValueError(f&#34;User &#39;{username}&#39; already exists&#34;)
# Resolve path and initialize configuration
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
os.makedirs(user_dir, exist_ok=True)
# Create subdirs for plugins and sessions
os.makedirs(os.path.join(user_dir, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(user_dir, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile
conf_file = os.path.join(user_dir, &#34;config.yaml&#34;)
configfile(conf=conf_file)
stored_config_path = None
else:
abs_config_path = os.path.abspath(config_path)
os.makedirs(abs_config_path, exist_ok=True)
# Create subdirs for plugins and sessions in the custom path
os.makedirs(os.path.join(abs_config_path, &#34;plugins&#34;), exist_ok=True)
os.makedirs(os.path.join(abs_config_path, &#34;ai_sessions&#34;), exist_ok=True)
# Create default config.yaml &amp; .osk key via configfile if config.yaml is not present
conf_file = os.path.join(abs_config_path, &#34;config.yaml&#34;)
if not os.path.exists(conf_file):
configfile(conf=conf_file)
stored_config_path = abs_config_path
# Hash password securely
password_hash = bcrypt.hashpw(password.encode(&#34;utf-8&#34;), bcrypt.gensalt()).decode(&#34;utf-8&#34;)
user_entry = {
&#34;password_hash&#34;: password_hash,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: datetime.datetime.now(datetime.timezone.utc).isoformat()
}
registry[&#34;users&#34;][username] = user_entry
self._save_registry(registry)
return {
&#34;username&#34;: username,
&#34;config_path&#34;: stored_config_path,
&#34;created&#34;: user_entry[&#34;created&#34;]
}</code></pre>
</details>
<div class="desc"><p>Creates a new user with bcrypt-hashed credentials.</p>
<p>Mode A: config_path=None (fresh user) -&gt; Generates config.yaml and .osk key.
Mode B: config_path set -&gt; Reuses existing directory after validating its structure.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.delete_user"><code class="name flex">
<span>def <span class="ident">delete_user</span></span>(<span>self, username)</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def delete_user(self, username):
&#34;&#34;&#34;Removes user from the registry and cleans up config directory if server-managed.&#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;)
user_data = registry[&#34;users&#34;][username]
config_path = user_data.get(&#34;config_path&#34;)
if config_path is None:
user_dir = os.path.join(self.users_dir, username)
if os.path.exists(user_dir):
shutil.rmtree(user_dir, ignore_errors=True)
del registry[&#34;users&#34;][username]
self._save_registry(registry)</code></pre>
</details>
<div class="desc"><p>Removes user from the registry and cleans up config directory if server-managed.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.generate_jwt"><code class="name flex">
<span>def <span class="ident">generate_jwt</span></span>(<span>self, username) > str</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def generate_jwt(self, username) -&gt; str:
&#34;&#34;&#34;Generates a secure JSON Web Token for the user expiring in 8 hours.&#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;)
expiration = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=8)
payload = {
&#34;sub&#34;: username,
&#34;exp&#34;: expiration
}
token = jwt.encode(payload, registry[&#34;jwt_secret&#34;], algorithm=&#34;HS256&#34;)
if isinstance(token, bytes):
token = token.decode(&#34;utf-8&#34;)
return token</code></pre>
</details>
<div class="desc"><p>Generates a secure JSON Web Token for the user expiring in 8 hours.</p></div>
</dd>
<dt id="connpy.services.user_service.UserService.get_user"><code class="name flex">
<span>def <span class="ident">get_user</span></span>(<span>self, username) > dict</span>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def get_user(self, username) -&gt; dict:
&#34;&#34;&#34;Retrieves raw metadata for a specific user.&#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;)
data = registry[&#34;users&#34;][username]
return {
&#34;username&#34;: username,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;),
&#34;password_hash&#34;: data.get(&#34;password_hash&#34;)
}</code></pre>
</details>
<div class="desc"><p>Retrieves raw metadata for a specific user.</p></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def list_users(self) -&gt; list[dict]:
&#34;&#34;&#34;Lists all registered users with metadata.&#34;&#34;&#34;
registry = self._load_registry()
return [
{
&#34;username&#34;: name,
&#34;config_path&#34;: data.get(&#34;config_path&#34;),
&#34;created&#34;: data.get(&#34;created&#34;)
}
for name, data in registry.get(&#34;users&#34;, {}).items()
]</code></pre>
</details>
<div class="desc"><p>Lists all registered users with metadata.</p></div>
</dd>
<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>
</code></dt>
<dd>
<details class="source">
<summary>
<span>Expand source code</span>
</summary>
<pre><code class="python">def verify_jwt(self, token) -&gt; str | None:
&#34;&#34;&#34;Decodes JWT and returns username if token is valid and unexpired.&#34;&#34;&#34;
registry = self._load_registry()
try:
payload = jwt.decode(token, registry[&#34;jwt_secret&#34;], algorithms=[&#34;HS256&#34;])
return payload.get(&#34;sub&#34;)
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, KeyError):
return None</code></pre>
</details>
<div class="desc"><p>Decodes JWT and returns username if token is valid and unexpired.</p></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.services" href="index.html">connpy.services</a></code></li>
</ul>
</li>
<li><h3><a href="#header-classes">Classes</a></h3>
<ul>
<li>
<h4><code><a title="connpy.services.user_service.UserService" href="#connpy.services.user_service.UserService">UserService</a></code></h4>
<ul class="">
<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.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_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.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.list_users" href="#connpy.services.user_service.UserService.list_users">list_users</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>
</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>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.tunnels API documentation</title> <title>connpy.tunnels API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -545,7 +545,7 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.</p></di
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2 -2
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
<meta name="generator" content="pdoc3 0.11.6"> <meta name="generator" content="pdoc3 0.11.5">
<title>connpy.utils API documentation</title> <title>connpy.utils API documentation</title>
<meta name="description" content=""> <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/sanitize.min.css" integrity="sha512-y1dtMcuvtTMJc1yPgEqF0ZjQbhnc/bFhyvIyVNb9Zk5mIGtqVaAB1Ttl28su8AvFMOY0EwRbAe+HCLqj6W7/KA==" crossorigin>
@@ -147,7 +147,7 @@ el.replaceWith(d);
</nav> </nav>
</main> </main>
<footer id="footer"> <footer id="footer">
<p>Generated by <a href="https://pdoc3.github.io/pdoc" title="pdoc: Python API documentation generator"><cite>pdoc</cite> 0.11.6</a>.</p> <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> </footer>
</body> </body>
</html> </html>
+2
View File
@@ -20,3 +20,5 @@ httpx>=0.27.0
requests>=2.31.0 requests>=2.31.0
pytest>=8.0.0 pytest>=8.0.0
pytest-mock>=3.12.0 pytest-mock>=3.12.0
bcrypt>=4.1.0
PyJWT>=2.8.0
+8 -6
View File
@@ -37,18 +37,20 @@ install_requires =
pycryptodome>=3.18.0 pycryptodome>=3.18.0
PyYAML>=6.0.1 PyYAML>=6.0.1
pyfzf>=0.3.1 pyfzf>=0.3.1
litellm>=1.40.0 litellm>=1.40.0,<2.0.0
grpcio>=1.62.0 grpcio>=1.62.0,<2.0.0
grpcio-tools>=1.62.0 grpcio-tools>=1.62.0,<2.0.0
protobuf>=6.31.1,<7.0.0 protobuf>=6.31.1,<7.0.0
google-api-python-client>=2.125.0 google-api-python-client>=2.125.0
google-auth-oauthlib>=1.2.0 google-auth-oauthlib>=1.2.0
google-auth-httplib2>=0.2.0 google-auth-httplib2>=0.2.0
prompt-toolkit>=3.0.0 prompt-toolkit>=3.0.0
mcp>=1.2.0 mcp>=1.2.0,<2.0.0
aiohttp>=3.9.0 aiohttp>=3.9.0,<4.0.0
httpx>=0.27.0 httpx>=0.27.0,<1.0.0
requests>=2.31.0 requests>=2.31.0
bcrypt>=4.1.0
PyJWT>=2.8.0
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =