Compare commits
10 Commits
6ee953edcf
...
e52d300cf1
| Author | SHA1 | Date | |
|---|---|---|---|
| e52d300cf1 | |||
| cf866d782a | |||
| 49dfa805e4 | |||
| 1b9751bd23 | |||
| f5e09a55ab | |||
| f6ce48ed8a | |||
| 7b053998f9 | |||
| 58c81a19cb | |||
| 0adaaad971 | |||
| aa542cb6eb |
@@ -20,3 +20,10 @@ scratch
|
||||
testall
|
||||
testremote
|
||||
automation-template.yaml
|
||||
|
||||
# Sensitive local files and credentials
|
||||
auth.json
|
||||
key.db
|
||||
config.db
|
||||
*.db
|
||||
testnew/
|
||||
|
||||
@@ -146,6 +146,7 @@ package.json
|
||||
|
||||
# Development docs
|
||||
connpy_roadmap.md
|
||||
testfew/
|
||||
testnew/
|
||||
testall/
|
||||
testremote/
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
__version__ = "6.0.0b12"
|
||||
__version__ = "6.0.0b13"
|
||||
|
||||
+7
-2
@@ -116,8 +116,11 @@ class ai:
|
||||
self.interrupted = False
|
||||
|
||||
|
||||
# 1. Cargar configuración genérica
|
||||
aiconfig = self.config.config.get("ai", {})
|
||||
# 1. Cargar configuración genérica con herencia/merge global
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
aiconfig = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
|
||||
# Modelos (Prioridad: Argumento -> Config -> Default)
|
||||
self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
|
||||
@@ -1008,10 +1011,12 @@ class ai:
|
||||
|
||||
@MethodHook
|
||||
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
||||
soft_limit_warned = False
|
||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||
|
||||
|
||||
if chat_history is None: chat_history = []
|
||||
|
||||
# Load session if provided and history is empty
|
||||
|
||||
@@ -48,6 +48,36 @@ def stop_api():
|
||||
return port
|
||||
|
||||
def debug_api(port=8048, config=None):
|
||||
# Check if already running via PID file verification
|
||||
for pid_file in [PID_FILE1, PID_FILE2]:
|
||||
if os.path.exists(pid_file):
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.readline().strip())
|
||||
os.kill(pid, 0)
|
||||
# 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)
|
||||
@@ -56,6 +86,12 @@ def debug_api(port=8048, config=None):
|
||||
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):
|
||||
try:
|
||||
|
||||
@@ -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}")
|
||||
@@ -14,6 +14,23 @@ class NodeHandler:
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
|
||||
def _filter_exact_match(self, matches, query):
|
||||
if not query or len(matches) <= 1:
|
||||
return matches
|
||||
|
||||
exact_matches = []
|
||||
for m in matches:
|
||||
if self.app.case:
|
||||
if m == query:
|
||||
exact_matches.append(m)
|
||||
else:
|
||||
if m.lower() == query.lower():
|
||||
exact_matches.append(m)
|
||||
|
||||
if len(exact_matches) == 1:
|
||||
return exact_matches
|
||||
return matches
|
||||
|
||||
def dispatch(self, args):
|
||||
if not self.app.case and args.data != None:
|
||||
args.data = args.data.lower()
|
||||
@@ -39,6 +56,7 @@ class NodeHandler:
|
||||
else:
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -73,6 +91,7 @@ class NodeHandler:
|
||||
matches = self.app.services.nodes.list_folders(args.data)
|
||||
else:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -87,8 +106,9 @@ class NodeHandler:
|
||||
sys.exit(7)
|
||||
|
||||
try:
|
||||
for item in matches:
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder)
|
||||
for i, item in enumerate(matches):
|
||||
save_on_last = (i == len(matches) - 1)
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
|
||||
|
||||
if len(matches) == 1:
|
||||
printer.success(f"{matches[0]} deleted successfully")
|
||||
@@ -144,6 +164,7 @@ class NodeHandler:
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -171,6 +192,7 @@ class NodeHandler:
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -209,7 +231,7 @@ class NodeHandler:
|
||||
self.app.services.nodes.update_node(matches[0], updatenode)
|
||||
printer.success(f"{args.data} edited successfully")
|
||||
else:
|
||||
editcount = 0
|
||||
changed_items = []
|
||||
for k in matches:
|
||||
updated_item = self.app.services.nodes.explode_unique(k)
|
||||
updated_item["type"] = "connection"
|
||||
@@ -222,8 +244,12 @@ class NodeHandler:
|
||||
updated_item[key] = updatenode[key]
|
||||
|
||||
if this_item_changed:
|
||||
editcount += 1
|
||||
self.app.services.nodes.update_node(k, updated_item)
|
||||
changed_items.append((k, updated_item))
|
||||
|
||||
editcount = len(changed_items)
|
||||
for i, (k, updated_item) in enumerate(changed_items):
|
||||
save_on_last = (i == editcount - 1)
|
||||
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
|
||||
|
||||
if editcount == 0:
|
||||
printer.info("Nothing to do here")
|
||||
|
||||
@@ -20,6 +20,17 @@ class RunHandler:
|
||||
|
||||
def node_run(self, args):
|
||||
nodes_filter = args.data[0]
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
|
||||
except Exception:
|
||||
matched_nodes = []
|
||||
|
||||
if not matched_nodes:
|
||||
printer.error(f"No nodes found matching filter: {nodes_filter}")
|
||||
sys.exit(2)
|
||||
|
||||
commands = [" ".join(args.data[1:])]
|
||||
|
||||
try:
|
||||
@@ -36,7 +47,7 @@ class RunHandler:
|
||||
printer.test_panel(unique, node_output, node_status, node_result)
|
||||
|
||||
results = self.app.services.execution.test_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
expected=args.test_expected,
|
||||
on_node_complete=_on_node_complete
|
||||
@@ -53,7 +64,7 @@ class RunHandler:
|
||||
printer.node_panel(unique, node_output, node_status)
|
||||
|
||||
results = self.app.services.execution.run_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
on_node_complete=_on_node_complete
|
||||
)
|
||||
@@ -103,6 +114,28 @@ class RunHandler:
|
||||
folder = output_cfg if output_cfg not in [None, "stdout"] else None
|
||||
prompt = options.get("prompt")
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
if isinstance(nodelist, str):
|
||||
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||
elif isinstance(nodelist, list):
|
||||
resolved_nodes = []
|
||||
for item in nodelist:
|
||||
matches = self.app.services.nodes.list_nodes(item)
|
||||
for m in matches:
|
||||
if m not in resolved_nodes:
|
||||
resolved_nodes.append(m)
|
||||
else:
|
||||
resolved_nodes = []
|
||||
except Exception:
|
||||
resolved_nodes = []
|
||||
|
||||
if not resolved_nodes:
|
||||
printer.error(f"[{name}] No nodes found matching filter: {nodelist}")
|
||||
sys.exit(11)
|
||||
|
||||
nodelist = resolved_nodes
|
||||
|
||||
try:
|
||||
header_printed = False
|
||||
if action == "run":
|
||||
|
||||
@@ -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)
|
||||
@@ -105,6 +105,21 @@ def _get_plugins(which, defaultdir):
|
||||
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):
|
||||
"""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["--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}
|
||||
cp_state = {"__extra__": _nodes, "--help": None, "-h": None}
|
||||
ls_state = {
|
||||
@@ -297,6 +325,9 @@ def _build_tree(nodes, folders, profiles, plugins, configdir):
|
||||
"--list": None, "--help": None,
|
||||
"-h": None,
|
||||
},
|
||||
"user": user_dict,
|
||||
"login": {"--help": None, "-h": None, "*": None},
|
||||
"logout": {"--help": None, "-h": None},
|
||||
"config": config_dict,
|
||||
"sync": {
|
||||
"--login": None, "--logout": None,
|
||||
|
||||
+40
-2
@@ -43,7 +43,8 @@ class configfile:
|
||||
passwords.
|
||||
'''
|
||||
|
||||
def __init__(self, conf = None, key = None):
|
||||
def __init__(self, conf = None, key = None, shared_config = None):
|
||||
self._shared_config = shared_config
|
||||
'''
|
||||
|
||||
### Optional Parameters:
|
||||
@@ -149,6 +150,42 @@ class configfile:
|
||||
self._generate_nodes_cache()
|
||||
|
||||
|
||||
def get_effective_setting(self, key, default=None):
|
||||
"""Get config setting with shared fallback for inheritable keys."""
|
||||
val = self.config.get(key)
|
||||
if key == "ai":
|
||||
if val is not None:
|
||||
if self._shared_config:
|
||||
import copy
|
||||
# Deep merge: shared as base, user overrides
|
||||
base = copy.deepcopy(self._shared_config.config.get(key, {}))
|
||||
if isinstance(base, dict) and isinstance(val, dict):
|
||||
# Credential isolation:
|
||||
# If user defines engineer credentials, discard shared ones
|
||||
if "engineer_api_key" in val or "engineer_auth" in val:
|
||||
base.pop("engineer_api_key", None)
|
||||
base.pop("engineer_auth", None)
|
||||
# If user defines architect credentials, discard shared ones
|
||||
if "architect_api_key" in val or "architect_auth" in val:
|
||||
base.pop("architect_api_key", None)
|
||||
base.pop("architect_auth", None)
|
||||
|
||||
# Recursive update for inner dictionaries (like mcp_servers or model details)
|
||||
def deep_merge(d1, d2):
|
||||
for k, v in d2.items():
|
||||
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
|
||||
deep_merge(d1[k], v)
|
||||
else:
|
||||
d1[k] = copy.deepcopy(v)
|
||||
deep_merge(base, val)
|
||||
return base
|
||||
return val
|
||||
elif self._shared_config:
|
||||
return self._shared_config.config.get(key, default)
|
||||
|
||||
return val if val is not None else default
|
||||
|
||||
|
||||
def _validate_config(self, data):
|
||||
"""Verify config data has the required structure."""
|
||||
if not isinstance(data, dict):
|
||||
@@ -489,7 +526,8 @@ class configfile:
|
||||
else:
|
||||
printer.error("Filter must be a string or a list of strings")
|
||||
sys.exit(1)
|
||||
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)]
|
||||
flags = re.IGNORECASE if not self.config.get("case", False) else 0
|
||||
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
|
||||
return nodes
|
||||
|
||||
@MethodHook
|
||||
|
||||
+35
-2
@@ -79,11 +79,12 @@ class connapp:
|
||||
self.debug_api = debug_api
|
||||
self.ai = ai
|
||||
|
||||
# Register context filtering hooks
|
||||
# Register context filtering hooks (only on Client CLI, bypass on gRPC Server)
|
||||
is_api_server = len(sys.argv) > 1 and sys.argv[1] == "api"
|
||||
if not is_api_server:
|
||||
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)
|
||||
self.services.context.config._getallnodesfull.register_post_hook(self.services.context.filter_node_dict)
|
||||
|
||||
if hasattr(self.services.nodes, "list_nodes") and hasattr(self.services.nodes.list_nodes, "register_post_hook"):
|
||||
self.services.nodes.list_nodes.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"):
|
||||
@@ -109,6 +110,9 @@ class connapp:
|
||||
except ConnpyError as e:
|
||||
# If in remote mode, connectivity issues should be reported
|
||||
if mode == "remote":
|
||||
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.folders = []
|
||||
@@ -135,6 +139,8 @@ class connapp:
|
||||
from .cli.context_handler import ContextHandler
|
||||
from .cli.import_export_handler import ImportExportHandler
|
||||
from .cli.sync_handler import SyncHandler
|
||||
from .cli.user_handler import UserHandler
|
||||
from .cli.login_handler import LoginHandler
|
||||
|
||||
# Instantiate Handlers
|
||||
self._node = NodeHandler(self)
|
||||
@@ -147,6 +153,8 @@ class connapp:
|
||||
self._context = ContextHandler(self)
|
||||
self._import_export = ImportExportHandler(self)
|
||||
self._sync = SyncHandler(self)
|
||||
self._user = UserHandler(self)
|
||||
self._login = LoginHandler(self)
|
||||
|
||||
# Register auto-sync hook to trigger after config saves
|
||||
from .configfile import configfile
|
||||
@@ -354,6 +362,31 @@ class connapp:
|
||||
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)
|
||||
|
||||
#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 = subparsers.add_parser("sync", help="Sync config with Google Drive", description="Sync config with Google Drive", formatter_class=RichHelpFormatter)
|
||||
syncparser.error = self._custom_error
|
||||
|
||||
@@ -1016,18 +1016,6 @@ class node:
|
||||
cmd += f" {self.options}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _generate_ssm_cmd(self):
|
||||
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
|
||||
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
|
||||
cmd = f"aws ssm start-session --target {self.host}"
|
||||
if region:
|
||||
cmd += f" --region {region}"
|
||||
if profile:
|
||||
cmd += f" --profile {profile}"
|
||||
if self.options:
|
||||
cmd += f" {self.options}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _get_cmd(self):
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2535,3 +2535,118 @@ class SystemService(object):
|
||||
timeout,
|
||||
metadata,
|
||||
_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
@@ -4,6 +4,8 @@ from google.protobuf.empty_pb2 import Empty
|
||||
import os
|
||||
import ctypes
|
||||
import threading
|
||||
import contextvars
|
||||
import datetime
|
||||
|
||||
# Suppress harmless but noisy gRPC fork() warnings from pexpect child processes
|
||||
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 .. import printer
|
||||
|
||||
# Import local services
|
||||
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
|
||||
_current_user = contextvars.ContextVar("current_user", default=None)
|
||||
|
||||
def handle_errors(func):
|
||||
import inspect
|
||||
@@ -31,10 +25,16 @@ def handle_errors(func):
|
||||
try:
|
||||
for item in func(*args, **kwargs):
|
||||
yield item
|
||||
except grpc.RpcError:
|
||||
raise
|
||||
except ConnpyError as e:
|
||||
context = kwargs.get("context") or args[-1]
|
||||
context.abort(grpc.StatusCode.INTERNAL, str(e))
|
||||
except Exception as e:
|
||||
if type(e) is Exception and not e.args:
|
||||
raise e
|
||||
if e.__class__.__name__ in ("_AbortError", "RpcError"):
|
||||
raise e
|
||||
context = kwargs.get("context") or args[-1]
|
||||
context.abort(grpc.StatusCode.UNKNOWN, str(e))
|
||||
finally:
|
||||
@@ -44,10 +44,16 @@ def handle_errors(func):
|
||||
def wrapper(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except grpc.RpcError:
|
||||
raise
|
||||
except ConnpyError as e:
|
||||
context = kwargs.get("context") or args[-1]
|
||||
context.abort(grpc.StatusCode.INTERNAL, str(e))
|
||||
except Exception as e:
|
||||
if type(e) is Exception and not e.args:
|
||||
raise e
|
||||
if e.__class__.__name__ in ("_AbortError", "RpcError"):
|
||||
raise e
|
||||
context = kwargs.get("context") or args[-1]
|
||||
context.abort(grpc.StatusCode.UNKNOWN, str(e))
|
||||
finally:
|
||||
@@ -55,25 +61,46 @@ def handle_errors(func):
|
||||
return wrapper
|
||||
|
||||
class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
def __init__(self, config, debug=False):
|
||||
self.service = NodeService(config)
|
||||
def __init__(self, provider, registry=None, debug=False):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
self.server_debug = debug
|
||||
if debug:
|
||||
from rich.console import Console
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().nodes
|
||||
|
||||
@handle_errors
|
||||
def interact_node(self, request_iterator, context):
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
from connpy.core import node
|
||||
from ..services.profile_service import ProfileService
|
||||
from connpy.tunnels import RemoteStream
|
||||
import queue
|
||||
import threading
|
||||
|
||||
# Resolve provider once at the start of the RPC stream
|
||||
provider = self._get_provider()
|
||||
nodes_service = provider.nodes
|
||||
profile_service = provider.profiles
|
||||
ai_service = provider.ai
|
||||
user_config = provider.config
|
||||
|
||||
# Fetch first setup packet
|
||||
try:
|
||||
first_req = next(request_iterator)
|
||||
@@ -100,9 +127,9 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
|
||||
if base_node_id:
|
||||
# Look up the base node in config and use its full data
|
||||
nodes = self.service.config._getallnodes(base_node_id)
|
||||
nodes = user_config._getallnodes(base_node_id)
|
||||
if nodes:
|
||||
device = self.service.config.getitem(nodes[0])
|
||||
device = user_config.getitem(nodes[0])
|
||||
# Override device properties with any passed in params
|
||||
for attr in valid_attrs:
|
||||
if attr in params:
|
||||
@@ -116,11 +143,11 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
device["tags"] = device_tags
|
||||
|
||||
node_name = params.get("name", base_node_id)
|
||||
n = node(node_name, **device, config=self.service.config)
|
||||
n = node(node_name, **device, config=user_config)
|
||||
else:
|
||||
# base_node not found, fall back to dynamic
|
||||
node_name = params.get("name", fallback_id)
|
||||
n = node(node_name, host=params.get("host", ""), config=self.service.config)
|
||||
n = node(node_name, host=params.get("host", ""), config=user_config)
|
||||
for attr in valid_attrs:
|
||||
if attr in params:
|
||||
setattr(n, attr, params[attr])
|
||||
@@ -128,19 +155,22 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
n.tags = params["tags"]
|
||||
else:
|
||||
node_name = params.get("name", fallback_id)
|
||||
n = node(node_name, host=params.get("host", ""), config=self.service.config)
|
||||
n = node(node_name, host=params.get("host", ""), config=user_config)
|
||||
for attr in valid_attrs:
|
||||
if attr in params:
|
||||
setattr(n, attr, params[attr])
|
||||
if "tags" in params:
|
||||
n.tags = params["tags"]
|
||||
else:
|
||||
node_data = self.service.config.getitem(unique_id, extract=False)
|
||||
try:
|
||||
node_data = user_config.getitem(unique_id, extract=False)
|
||||
except (KeyError, TypeError):
|
||||
node_data = None
|
||||
|
||||
if not node_data:
|
||||
context.abort(grpc.StatusCode.NOT_FOUND, f"Node {unique_id} not found")
|
||||
profile_service = ProfileService(self.service.config)
|
||||
resolved_data = profile_service.resolve_node_data(node_data)
|
||||
n = node(unique_id, **resolved_data, config=self.service.config)
|
||||
n = node(unique_id, **resolved_data, config=user_config)
|
||||
if sftp:
|
||||
n.protocol = "sftp"
|
||||
|
||||
@@ -207,9 +237,8 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
from ..services.ai_service import AIService
|
||||
|
||||
service = AIService(self.service.config)
|
||||
service = ai_service
|
||||
|
||||
if node_info is None:
|
||||
node_info = {}
|
||||
@@ -479,10 +508,27 @@ class NodeServicer(connpy_pb2_grpc.NodeServiceServicer):
|
||||
)
|
||||
|
||||
class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = ProfileService(config)
|
||||
self.node_service = NodeService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().profiles
|
||||
|
||||
@property
|
||||
def node_service(self):
|
||||
return self._get_provider().nodes
|
||||
|
||||
@handle_errors
|
||||
def list_profiles(self, request, context):
|
||||
@@ -516,8 +562,23 @@ class ProfileServicer(connpy_pb2_grpc.ProfileServiceServicer):
|
||||
return Empty()
|
||||
|
||||
class ConfigServicer(connpy_pb2_grpc.ConfigServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = ConfigService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().config_svc
|
||||
|
||||
@handle_errors
|
||||
def get_settings(self, request, context):
|
||||
@@ -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)))
|
||||
|
||||
class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_grpc.RemotePluginServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = PluginService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().plugins
|
||||
|
||||
@handle_errors
|
||||
def list_plugins(self, request, context):
|
||||
@@ -589,8 +665,23 @@ class PluginServicer(connpy_pb2_grpc.PluginServiceServicer, remote_plugin_pb2_gr
|
||||
yield remote_plugin_pb2.OutputChunk(text=chunk)
|
||||
|
||||
class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = ExecutionService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().execution
|
||||
|
||||
@handle_errors
|
||||
def run_commands(self, request, context):
|
||||
@@ -599,6 +690,11 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
|
||||
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
|
||||
|
||||
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
|
||||
# threading.Thread does NOT inherit ContextVars, so self.service inside
|
||||
# _worker() would fall back to the admin provider.
|
||||
execution_service = self.service
|
||||
|
||||
q = queue.Queue()
|
||||
|
||||
def _on_complete(unique, output, status):
|
||||
@@ -606,7 +702,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
self.service.run_commands( nodes_filter=nodes_filter,
|
||||
execution_service.run_commands( nodes_filter=nodes_filter,
|
||||
commands=list(request.commands),
|
||||
folder=request.folder if request.folder else None,
|
||||
prompt=request.prompt if request.prompt else None,
|
||||
@@ -645,6 +741,9 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
|
||||
nodes_filter = request.nodes[0] if len(request.nodes) == 1 else list(request.nodes)
|
||||
|
||||
# Resolve provider in the main gRPC thread where _current_user ContextVar is set.
|
||||
execution_service = self.service
|
||||
|
||||
q = queue.Queue()
|
||||
|
||||
def _on_complete(unique, node_output, node_status, node_result):
|
||||
@@ -652,7 +751,7 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
self.service.test_commands(
|
||||
execution_service.test_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
commands=list(request.commands),
|
||||
expected=list(request.expected),
|
||||
@@ -698,9 +797,27 @@ class ExecutionServicer(connpy_pb2_grpc.ExecutionServiceServicer):
|
||||
return connpy_pb2.StructResponse(data=to_struct(res))
|
||||
|
||||
class ImportExportServicer(connpy_pb2_grpc.ImportExportServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = ImportExportService(config)
|
||||
self.node_service = NodeService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().import_export
|
||||
|
||||
@property
|
||||
def node_service(self):
|
||||
return self._get_provider().nodes
|
||||
|
||||
@handle_errors
|
||||
def export_to_file(self, request, context):
|
||||
@@ -815,14 +932,35 @@ class StatusBridge:
|
||||
return default
|
||||
|
||||
class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = AIService(config)
|
||||
def __init__(self, provider, registry=None, debug=False):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
self.server_debug = debug
|
||||
if debug:
|
||||
from rich.console import Console
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
self.server_console = Console(theme=connpy_theme, file=get_original_stdout())
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().ai
|
||||
|
||||
@handle_errors
|
||||
def ask(self, request_iterator, context):
|
||||
import queue
|
||||
import threading
|
||||
|
||||
ai_service = self.service
|
||||
chunk_queue = queue.Queue()
|
||||
request_queue = queue.Queue()
|
||||
bridge = None
|
||||
@@ -840,7 +978,7 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
||||
nonlocal history, bridge, agent_instance
|
||||
try:
|
||||
# Run the AI interaction (this blocks this specific thread)
|
||||
res = self.service.ask(
|
||||
res = ai_service.ask(
|
||||
input_text,
|
||||
chat_history=history if history else None,
|
||||
session_id=session_id,
|
||||
@@ -859,6 +997,16 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
||||
|
||||
# Send final chunk marker
|
||||
chunk_queue.put(("final_mark", res))
|
||||
except ValueError as e:
|
||||
# Configuration or LLM provider connection errors are expected, only print in debug mode
|
||||
if debug or getattr(self, "server_debug", False):
|
||||
from rich.console import Console
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
c = getattr(self, "server_console", None) or Console(theme=connpy_theme, file=get_original_stdout())
|
||||
c.print(f"[debug][DEBUG][/debug] AI Task Error: {e}")
|
||||
chunk_queue.put(("status", f"Error: {str(e)}"))
|
||||
# Crucial: always send final_mark to avoid client deadlock
|
||||
chunk_queue.put(("final_mark", {"response": f"Error: {str(e)}", "chat_history": history, "error": True}))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"AI Task Error: {e}")
|
||||
@@ -996,8 +1144,23 @@ class AIServicer(connpy_pb2_grpc.AIServiceServicer):
|
||||
return connpy_pb2.StructResponse(data=to_struct(self.service.load_session_data(request.value)))
|
||||
|
||||
class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
|
||||
def __init__(self, config):
|
||||
self.service = SystemService(config)
|
||||
def __init__(self, provider, registry=None):
|
||||
if not hasattr(provider, "mode"):
|
||||
from connpy.services.provider import ServiceProvider
|
||||
provider = ServiceProvider(provider, mode="local")
|
||||
self._fallback_provider = provider
|
||||
self._registry = registry
|
||||
|
||||
def _get_provider(self):
|
||||
if self._registry:
|
||||
username = _current_user.get()
|
||||
if username:
|
||||
return self._registry.get_provider(username)
|
||||
return self._fallback_provider
|
||||
|
||||
@property
|
||||
def service(self):
|
||||
return self._get_provider().system
|
||||
|
||||
@handle_errors
|
||||
def start_api(self, request, context):
|
||||
@@ -1023,6 +1186,138 @@ class SystemServicer(connpy_pb2_grpc.SystemServiceServicer):
|
||||
def get_api_status(self, request, context):
|
||||
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):
|
||||
def __init__(self):
|
||||
from rich.console import Console
|
||||
@@ -1047,19 +1342,30 @@ class LoggingInterceptor(grpc.ServerInterceptor):
|
||||
return result
|
||||
|
||||
def serve(config, port=8048, debug=False):
|
||||
interceptors = [LoggingInterceptor()] if debug else []
|
||||
from connpy.grpc_layer.user_registry import UserRegistry
|
||||
from connpy.services.provider import ServiceProvider
|
||||
|
||||
fallback_provider = ServiceProvider(config, mode="local")
|
||||
registry = UserRegistry(config.defaultdir)
|
||||
|
||||
interceptors = []
|
||||
if debug:
|
||||
interceptors.append(LoggingInterceptor())
|
||||
interceptors.append(AuthInterceptor(registry))
|
||||
|
||||
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10), interceptors=interceptors)
|
||||
|
||||
connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(config, debug=debug), server)
|
||||
connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(config), server)
|
||||
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(config), server)
|
||||
plugin_servicer = PluginServicer(config)
|
||||
connpy_pb2_grpc.add_NodeServiceServicer_to_server(NodeServicer(fallback_provider, registry=registry, debug=debug), server)
|
||||
connpy_pb2_grpc.add_ProfileServiceServicer_to_server(ProfileServicer(fallback_provider, registry=registry), server)
|
||||
connpy_pb2_grpc.add_ConfigServiceServicer_to_server(ConfigServicer(fallback_provider, registry=registry), server)
|
||||
plugin_servicer = PluginServicer(fallback_provider, registry=registry)
|
||||
connpy_pb2_grpc.add_PluginServiceServicer_to_server(plugin_servicer, server)
|
||||
remote_plugin_pb2_grpc.add_RemotePluginServiceServicer_to_server(plugin_servicer, server)
|
||||
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(config), server)
|
||||
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(config), server)
|
||||
connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(config), server)
|
||||
connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(config), server)
|
||||
connpy_pb2_grpc.add_ExecutionServiceServicer_to_server(ExecutionServicer(fallback_provider, registry=registry), server)
|
||||
connpy_pb2_grpc.add_ImportExportServiceServicer_to_server(ImportExportServicer(fallback_provider, registry=registry), server)
|
||||
connpy_pb2_grpc.add_AIServiceServicer_to_server(AIServicer(fallback_provider, registry=registry, debug=debug), server)
|
||||
connpy_pb2_grpc.add_SystemServiceServicer_to_server(SystemServicer(fallback_provider, registry=registry), server)
|
||||
connpy_pb2_grpc.add_AuthServiceServicer_to_server(AuthServicer(registry), server)
|
||||
|
||||
server.add_insecure_port(f'[::]:{port}')
|
||||
server.start()
|
||||
|
||||
@@ -462,15 +462,17 @@ class NodeStub:
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
def update_node(self, unique_id, data):
|
||||
def update_node(self, unique_id, data, save=True):
|
||||
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
|
||||
self.stub.update_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
def delete_node(self, unique_id, is_folder=False):
|
||||
def delete_node(self, unique_id, is_folder=False, save=True):
|
||||
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
|
||||
self.stub.delete_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
@@ -895,9 +897,6 @@ class AIStub:
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||
stable_console.print(Rule(style=alias))
|
||||
elif not full_content and final_result.get("response"):
|
||||
# If nothing streamed but we have response (e.g. error or direct guide)
|
||||
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
|
||||
break
|
||||
except Exception as e:
|
||||
# Check if it was a gRPC error that we should let handle_errors catch
|
||||
@@ -980,3 +979,78 @@ class SystemStub:
|
||||
@handle_errors
|
||||
def get_api_status(self):
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -48,7 +48,10 @@ class MCPClientManager:
|
||||
|
||||
all_llm_tools = []
|
||||
try:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {})
|
||||
else:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {}
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@@ -296,3 +296,24 @@ message MCPRequest {
|
||||
string auto_load_on_os = 4;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -307,7 +307,10 @@ class AIService(BaseService):
|
||||
|
||||
def list_mcp_servers(self) -> dict:
|
||||
"""Get the configured MCP servers."""
|
||||
ai_settings = self.config.config.get("ai", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
ai_settings = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
return ai_settings.get("mcp_servers", {})
|
||||
|
||||
def load_session_data(self, session_id):
|
||||
|
||||
@@ -148,7 +148,7 @@ class NodeService(BaseService):
|
||||
self.config._connections_add(**data)
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def update_node(self, unique_id, data):
|
||||
def update_node(self, unique_id, data, save=True):
|
||||
"""Explicitly update an existing node."""
|
||||
all_nodes = self.config._getallnodes()
|
||||
if unique_id not in all_nodes:
|
||||
@@ -162,9 +162,10 @@ class NodeService(BaseService):
|
||||
|
||||
# config._connections_add actually handles updates if ID exists correctly
|
||||
self.config._connections_add(**data)
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def delete_node(self, unique_id, is_folder=False):
|
||||
def delete_node(self, unique_id, is_folder=False, save=True):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -177,6 +178,7 @@ class NodeService(BaseService):
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
self.config._connections_del(**uniques)
|
||||
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
|
||||
|
||||
@@ -7,16 +7,47 @@ from .exceptions import InvalidConfigurationError, NodeNotFoundError
|
||||
class PluginService(BaseService):
|
||||
"""Business logic for enabling, disabling, and listing plugins."""
|
||||
|
||||
def _get_plugin_path(self, name, include_disabled=True):
|
||||
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
|
||||
import os
|
||||
|
||||
# 1. User directory
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
p_file = os.path.join(user_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "user", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "user", False
|
||||
|
||||
# 2. Shared/Global directory
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
p_file = os.path.join(shared_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "shared", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "shared", False
|
||||
|
||||
# 3. Core plugins
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
p_file = os.path.join(core_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "core", True
|
||||
|
||||
return None, None, False
|
||||
|
||||
|
||||
def list_plugins(self):
|
||||
"""List all core and user-defined plugins with their status and hash."""
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# Check for user plugins directory
|
||||
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
# Check for core plugins directory
|
||||
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
|
||||
all_plugin_info = {}
|
||||
|
||||
def get_hash(path):
|
||||
@@ -26,12 +57,35 @@ class PluginService(BaseService):
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# User plugins
|
||||
if os.path.exists(plugin_dir):
|
||||
for f in os.listdir(plugin_dir):
|
||||
# 1. Scan core plugins (lowest priority)
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
if os.path.exists(core_dir):
|
||||
for f in os.listdir(core_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(plugin_dir, f)
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
for f in os.listdir(shared_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
@@ -39,6 +93,7 @@ class PluginService(BaseService):
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
|
||||
def add_plugin(self, name, source_file, update=False):
|
||||
"""Add or update a plugin from a local file."""
|
||||
import os
|
||||
@@ -119,6 +174,10 @@ class PluginService(BaseService):
|
||||
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
|
||||
|
||||
if not deleted:
|
||||
# If not deleted from user directory, check if it's in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
|
||||
if origin in ["shared", "core"]:
|
||||
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def enable_plugin(self, name):
|
||||
@@ -127,51 +186,80 @@ class PluginService(BaseService):
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
if not os.path.exists(disabled_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
if os.path.exists(disabled_file):
|
||||
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
|
||||
is_shadow = False
|
||||
if os.path.getsize(disabled_file) == 0:
|
||||
# Resolve without the local bkp file to verify if shared/core has it
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
is_shadow = True
|
||||
|
||||
if is_shadow:
|
||||
# Remove shadow file to restore inheritance
|
||||
try:
|
||||
os.remove(disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
|
||||
else:
|
||||
try:
|
||||
os.rename(disabled_file, plugin_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
# If it doesn't exist locally, check if it's already an active shared/core plugin
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
return False # Already active/enabled through inheritance
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def disable_plugin(self, name):
|
||||
"""Deactivate a plugin by renaming it to a backup file."""
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
if not os.path.exists(plugin_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
# Regular user-level plugin exists. Rename to bkp
|
||||
try:
|
||||
os.rename(plugin_file, disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
# Check if it exists in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
# Shadow disable it by creating an empty .py.bkp in user plugins dir
|
||||
plugin_dir = os.path.dirname(plugin_file)
|
||||
os.makedirs(plugin_dir, exist_ok=True)
|
||||
try:
|
||||
with open(disabled_file, "w") as f:
|
||||
f.write("")
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
|
||||
|
||||
def get_plugin_source(self, name):
|
||||
import os
|
||||
from ..services.exceptions import InvalidConfigurationError
|
||||
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
with open(target, "r") as f:
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def invoke_plugin(self, name, args_dict):
|
||||
@@ -211,17 +299,12 @@ class PluginService(BaseService):
|
||||
|
||||
p_manager = Plugins()
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
module = p_manager._import_from_path(target)
|
||||
module = p_manager._import_from_path(path)
|
||||
parser = module.Parser().parser if hasattr(module, "Parser") else None
|
||||
|
||||
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
|
||||
|
||||
@@ -33,6 +33,7 @@ class ServiceProvider:
|
||||
from .import_export_service import ImportExportService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
from .user_service import UserService
|
||||
|
||||
self.nodes = NodeService(self.config)
|
||||
self.profiles = ProfileService(self.config)
|
||||
@@ -44,6 +45,7 @@ class ServiceProvider:
|
||||
self.import_export = ImportExportService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = UserService(self.config.defaultdir)
|
||||
|
||||
def _init_remote(self):
|
||||
# Allow ConfigService to work locally so the user can revert the mode
|
||||
@@ -53,14 +55,37 @@ class ServiceProvider:
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = None
|
||||
|
||||
if not self.remote_host:
|
||||
raise InvalidConfigurationError("Remote host must be specified in remote mode")
|
||||
|
||||
import grpc
|
||||
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub
|
||||
import os
|
||||
from ..grpc_layer.stubs import (
|
||||
NodeStub, ProfileStub, PluginStub, AIStub,
|
||||
ExecutionStub, ImportExportStub, SystemStub,
|
||||
ConfigStub, AuthClientInterceptor, AuthStub
|
||||
)
|
||||
|
||||
def get_token():
|
||||
token_path = os.path.join(self.config.defaultdir, ".token")
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
with open(token_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
channel = grpc.insecure_channel(self.remote_host)
|
||||
interceptor = AuthClientInterceptor(get_token)
|
||||
channel = grpc.intercept_channel(channel, interceptor)
|
||||
|
||||
# Surgical fix: Keep ConfigService local for mode/theme management,
|
||||
# but delegate encryption to the server stub.
|
||||
config_remote = ConfigStub(channel, remote_host=self.remote_host)
|
||||
self.config_svc.encrypt_password = config_remote.encrypt_password
|
||||
|
||||
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
|
||||
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
|
||||
@@ -69,3 +94,4 @@ class ServiceProvider:
|
||||
self.system = SystemStub(channel, remote_host=self.remote_host)
|
||||
self.execution = ExecutionStub(channel, remote_host=self.remote_host)
|
||||
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
|
||||
self.auth = AuthStub(channel, remote_host=self.remote_host)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -141,4 +141,62 @@ class TestTreeCompletions:
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ def test_node_del(mock_prompt, mock_delete_node, mock_list_nodes, app):
|
||||
mock_list_nodes.return_value = ["router1"]
|
||||
mock_prompt.return_value = {"delete": True}
|
||||
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.get_node_details")
|
||||
@@ -165,9 +165,9 @@ def test_ai(mock_status, mock_ask, app):
|
||||
|
||||
@patch("connpy.services.execution_service.ExecutionService.run_commands")
|
||||
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()
|
||||
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"]
|
||||
|
||||
@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"}
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.ai_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>
|
||||
@@ -666,7 +666,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.api_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>
|
||||
@@ -193,7 +193,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.config_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>
|
||||
@@ -551,7 +551,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.context_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>
|
||||
@@ -249,7 +249,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.forms 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>
|
||||
@@ -690,7 +690,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.help_text 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>
|
||||
@@ -303,7 +303,7 @@ tasks:
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.helpers 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>
|
||||
@@ -333,7 +333,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.import_export_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>
|
||||
@@ -272,7 +272,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli 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>
|
||||
@@ -72,6 +72,10 @@ el.replaceWith(d);
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</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>
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
@@ -96,6 +100,10 @@ el.replaceWith(d);
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</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>
|
||||
<dd>
|
||||
<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.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.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.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.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.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>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -142,7 +152,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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, "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}")</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, "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)</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, "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)</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, ".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).")</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, ".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}")</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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.node_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>
|
||||
@@ -60,6 +60,23 @@ el.replaceWith(d);
|
||||
self.app = app
|
||||
self.forms = Forms(app)
|
||||
|
||||
def _filter_exact_match(self, matches, query):
|
||||
if not query or len(matches) <= 1:
|
||||
return matches
|
||||
|
||||
exact_matches = []
|
||||
for m in matches:
|
||||
if self.app.case:
|
||||
if m == query:
|
||||
exact_matches.append(m)
|
||||
else:
|
||||
if m.lower() == query.lower():
|
||||
exact_matches.append(m)
|
||||
|
||||
if len(exact_matches) == 1:
|
||||
return exact_matches
|
||||
return matches
|
||||
|
||||
def dispatch(self, args):
|
||||
if not self.app.case and args.data != None:
|
||||
args.data = args.data.lower()
|
||||
@@ -85,6 +102,7 @@ el.replaceWith(d);
|
||||
else:
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -119,6 +137,7 @@ el.replaceWith(d);
|
||||
matches = self.app.services.nodes.list_folders(args.data)
|
||||
else:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -133,8 +152,9 @@ el.replaceWith(d);
|
||||
sys.exit(7)
|
||||
|
||||
try:
|
||||
for item in matches:
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder)
|
||||
for i, item in enumerate(matches):
|
||||
save_on_last = (i == len(matches) - 1)
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
|
||||
|
||||
if len(matches) == 1:
|
||||
printer.success(f"{matches[0]} deleted successfully")
|
||||
@@ -190,6 +210,7 @@ el.replaceWith(d);
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -217,6 +238,7 @@ el.replaceWith(d);
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -255,7 +277,7 @@ el.replaceWith(d);
|
||||
self.app.services.nodes.update_node(matches[0], updatenode)
|
||||
printer.success(f"{args.data} edited successfully")
|
||||
else:
|
||||
editcount = 0
|
||||
changed_items = []
|
||||
for k in matches:
|
||||
updated_item = self.app.services.nodes.explode_unique(k)
|
||||
updated_item["type"] = "connection"
|
||||
@@ -268,8 +290,12 @@ el.replaceWith(d);
|
||||
updated_item[key] = updatenode[key]
|
||||
|
||||
if this_item_changed:
|
||||
editcount += 1
|
||||
self.app.services.nodes.update_node(k, updated_item)
|
||||
changed_items.append((k, updated_item))
|
||||
|
||||
editcount = len(changed_items)
|
||||
for i, (k, updated_item) in enumerate(changed_items):
|
||||
save_on_last = (i == editcount - 1)
|
||||
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
|
||||
|
||||
if editcount == 0:
|
||||
printer.info("Nothing to do here")
|
||||
@@ -354,6 +380,7 @@ el.replaceWith(d);
|
||||
else:
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -398,6 +425,7 @@ el.replaceWith(d);
|
||||
matches = self.app.services.nodes.list_folders(args.data)
|
||||
else:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -412,8 +440,9 @@ el.replaceWith(d);
|
||||
sys.exit(7)
|
||||
|
||||
try:
|
||||
for item in matches:
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder)
|
||||
for i, item in enumerate(matches):
|
||||
save_on_last = (i == len(matches) - 1)
|
||||
self.app.services.nodes.delete_node(item, is_folder=is_folder, save=save_on_last)
|
||||
|
||||
if len(matches) == 1:
|
||||
printer.success(f"{matches[0]} deleted successfully")
|
||||
@@ -456,6 +485,7 @@ el.replaceWith(d);
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -494,7 +524,7 @@ el.replaceWith(d);
|
||||
self.app.services.nodes.update_node(matches[0], updatenode)
|
||||
printer.success(f"{args.data} edited successfully")
|
||||
else:
|
||||
editcount = 0
|
||||
changed_items = []
|
||||
for k in matches:
|
||||
updated_item = self.app.services.nodes.explode_unique(k)
|
||||
updated_item["type"] = "connection"
|
||||
@@ -507,8 +537,12 @@ el.replaceWith(d);
|
||||
updated_item[key] = updatenode[key]
|
||||
|
||||
if this_item_changed:
|
||||
editcount += 1
|
||||
self.app.services.nodes.update_node(k, updated_item)
|
||||
changed_items.append((k, updated_item))
|
||||
|
||||
editcount = len(changed_items)
|
||||
for i, (k, updated_item) in enumerate(changed_items):
|
||||
save_on_last = (i == editcount - 1)
|
||||
self.app.services.nodes.update_node(k, updated_item, save=save_on_last)
|
||||
|
||||
if editcount == 0:
|
||||
printer.info("Nothing to do here")
|
||||
@@ -535,6 +569,7 @@ el.replaceWith(d);
|
||||
|
||||
try:
|
||||
matches = self.app.services.nodes.list_nodes(args.data)
|
||||
matches = self._filter_exact_match(matches, args.data)
|
||||
except Exception:
|
||||
matches = []
|
||||
|
||||
@@ -606,7 +641,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.plugin_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>
|
||||
@@ -385,7 +385,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.profile_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>
|
||||
@@ -314,7 +314,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.run_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>
|
||||
@@ -68,6 +68,17 @@ el.replaceWith(d);
|
||||
|
||||
def node_run(self, args):
|
||||
nodes_filter = args.data[0]
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
|
||||
except Exception:
|
||||
matched_nodes = []
|
||||
|
||||
if not matched_nodes:
|
||||
printer.error(f"No nodes found matching filter: {nodes_filter}")
|
||||
sys.exit(2)
|
||||
|
||||
commands = [" ".join(args.data[1:])]
|
||||
|
||||
try:
|
||||
@@ -84,7 +95,7 @@ el.replaceWith(d);
|
||||
printer.test_panel(unique, node_output, node_status, node_result)
|
||||
|
||||
results = self.app.services.execution.test_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
expected=args.test_expected,
|
||||
on_node_complete=_on_node_complete
|
||||
@@ -101,7 +112,7 @@ el.replaceWith(d);
|
||||
printer.node_panel(unique, node_output, node_status)
|
||||
|
||||
results = self.app.services.execution.run_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
on_node_complete=_on_node_complete
|
||||
)
|
||||
@@ -151,6 +162,28 @@ el.replaceWith(d);
|
||||
folder = output_cfg if output_cfg not in [None, "stdout"] else None
|
||||
prompt = options.get("prompt")
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
if isinstance(nodelist, str):
|
||||
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||
elif isinstance(nodelist, list):
|
||||
resolved_nodes = []
|
||||
for item in nodelist:
|
||||
matches = self.app.services.nodes.list_nodes(item)
|
||||
for m in matches:
|
||||
if m not in resolved_nodes:
|
||||
resolved_nodes.append(m)
|
||||
else:
|
||||
resolved_nodes = []
|
||||
except Exception:
|
||||
resolved_nodes = []
|
||||
|
||||
if not resolved_nodes:
|
||||
printer.error(f"[{name}] No nodes found matching filter: {nodelist}")
|
||||
sys.exit(11)
|
||||
|
||||
nodelist = resolved_nodes
|
||||
|
||||
try:
|
||||
header_printed = False
|
||||
if action == "run":
|
||||
@@ -242,6 +275,28 @@ el.replaceWith(d);
|
||||
folder = output_cfg if output_cfg not in [None, "stdout"] else None
|
||||
prompt = options.get("prompt")
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
if isinstance(nodelist, str):
|
||||
resolved_nodes = self.app.services.nodes.list_nodes(nodelist)
|
||||
elif isinstance(nodelist, list):
|
||||
resolved_nodes = []
|
||||
for item in nodelist:
|
||||
matches = self.app.services.nodes.list_nodes(item)
|
||||
for m in matches:
|
||||
if m not in resolved_nodes:
|
||||
resolved_nodes.append(m)
|
||||
else:
|
||||
resolved_nodes = []
|
||||
except Exception:
|
||||
resolved_nodes = []
|
||||
|
||||
if not resolved_nodes:
|
||||
printer.error(f"[{name}] No nodes found matching filter: {nodelist}")
|
||||
sys.exit(11)
|
||||
|
||||
nodelist = resolved_nodes
|
||||
|
||||
try:
|
||||
header_printed = False
|
||||
if action == "run":
|
||||
@@ -333,6 +388,17 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def node_run(self, args):
|
||||
nodes_filter = args.data[0]
|
||||
|
||||
# Resolve and filter nodes through context-aware list_nodes
|
||||
try:
|
||||
matched_nodes = self.app.services.nodes.list_nodes(nodes_filter)
|
||||
except Exception:
|
||||
matched_nodes = []
|
||||
|
||||
if not matched_nodes:
|
||||
printer.error(f"No nodes found matching filter: {nodes_filter}")
|
||||
sys.exit(2)
|
||||
|
||||
commands = [" ".join(args.data[1:])]
|
||||
|
||||
try:
|
||||
@@ -349,7 +415,7 @@ el.replaceWith(d);
|
||||
printer.test_panel(unique, node_output, node_status, node_result)
|
||||
|
||||
results = self.app.services.execution.test_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
expected=args.test_expected,
|
||||
on_node_complete=_on_node_complete
|
||||
@@ -366,7 +432,7 @@ el.replaceWith(d);
|
||||
printer.node_panel(unique, node_output, node_status)
|
||||
|
||||
results = self.app.services.execution.run_commands(
|
||||
nodes_filter=nodes_filter,
|
||||
nodes_filter=matched_nodes,
|
||||
commands=commands,
|
||||
on_node_complete=_on_node_complete
|
||||
)
|
||||
@@ -454,7 +520,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.sync_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>
|
||||
@@ -427,7 +427,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.terminal_ui 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>
|
||||
@@ -1017,7 +1017,7 @@ on_ai_call: async function(active_buffer, question) -> result_dict</p></div>
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 == "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)</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, "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)</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, "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)</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 == "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)</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("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)</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, "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)</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, "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)</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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.cli.validators 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>
|
||||
@@ -508,7 +508,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.connpy_pb2 API documentation</title>
|
||||
<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>
|
||||
@@ -61,7 +61,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.connpy_pb2_grpc API documentation</title>
|
||||
<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>
|
||||
@@ -108,6 +108,34 @@ el.replaceWith(d);
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</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 = {
|
||||
'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)</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<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>
|
||||
</code></dt>
|
||||
@@ -1341,6 +1369,251 @@ def load_session_data(request,
|
||||
<dd>A grpc.Channel.</dd>
|
||||
</dl></div>
|
||||
</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):
|
||||
"""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)</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,
|
||||
'/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)</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,
|
||||
'/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)</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):
|
||||
"""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!')</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):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</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):
|
||||
"""Missing associated documentation comment in .proto file."""
|
||||
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
||||
context.set_details('Method not implemented!')
|
||||
raise NotImplementedError('Method not implemented!')</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):
|
||||
"""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)</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">
|
||||
<span>class <span class="ident">ConfigService</span></span>
|
||||
</code></dt>
|
||||
@@ -5802,6 +6075,7 @@ def stop_api(request,
|
||||
<li><h3><a href="#header-functions">Functions</a></h3>
|
||||
<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_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_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>
|
||||
@@ -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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -6029,7 +6320,7 @@ def stop_api(request,
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer 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>
|
||||
@@ -64,6 +64,10 @@ el.replaceWith(d);
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</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>
|
||||
<dd>
|
||||
<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.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.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>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -102,7 +107,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.remote_plugin_pb2 API documentation</title>
|
||||
<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>
|
||||
@@ -62,7 +62,7 @@ el.replaceWith(d);
|
||||
<dl>
|
||||
<dt id="connpy.grpc_layer.remote_plugin_pb2.IdRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
@@ -81,7 +81,7 @@ el.replaceWith(d);
|
||||
<dl>
|
||||
<dt id="connpy.grpc_layer.remote_plugin_pb2.OutputChunk.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
@@ -100,7 +100,7 @@ el.replaceWith(d);
|
||||
<dl>
|
||||
<dt id="connpy.grpc_layer.remote_plugin_pb2.PluginInvokeRequest.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
@@ -119,7 +119,7 @@ el.replaceWith(d);
|
||||
<dl>
|
||||
<dt id="connpy.grpc_layer.remote_plugin_pb2.StringResponse.DESCRIPTOR"><code class="name">var <span class="ident">DESCRIPTOR</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
</dd>
|
||||
@@ -168,7 +168,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.remote_plugin_pb2_grpc API documentation</title>
|
||||
<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>
|
||||
@@ -366,7 +366,7 @@ def invoke_plugin(request,
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.stubs 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>
|
||||
@@ -272,9 +272,6 @@ el.replaceWith(d);
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||
stable_console.print(Rule(style=alias))
|
||||
elif not full_content and final_result.get("response"):
|
||||
# If nothing streamed but we have response (e.g. error or direct guide)
|
||||
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
|
||||
break
|
||||
except Exception as e:
|
||||
# Check if it was a gRPC error that we should let handle_errors catch
|
||||
@@ -517,9 +514,6 @@ def ask(self, input_text, dryrun=False, chat_history=None, session_id=None, debu
|
||||
from ..printer import connpy_theme, get_original_stdout
|
||||
stable_console = RichConsole(theme=connpy_theme, file=get_original_stdout())
|
||||
stable_console.print(Rule(style=alias))
|
||||
elif not full_content and final_result.get("response"):
|
||||
# If nothing streamed but we have response (e.g. error or direct guide)
|
||||
printer.console.print(Panel(Markdown(final_result["response"]), title=title, border_style=alias, expand=False))
|
||||
break
|
||||
except Exception as e:
|
||||
# Check if it was a gRPC error that we should let handle_errors catch
|
||||
@@ -652,6 +646,303 @@ def load_session_data(self, session_id):
|
||||
</dd>
|
||||
</dl>
|
||||
</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() == "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)</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 {
|
||||
"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)</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 {
|
||||
"token": resp.token,
|
||||
"username": resp.username,
|
||||
"expires_at": 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">
|
||||
<span>class <span class="ident">ConfigStub</span></span>
|
||||
<span>(</span><span>channel, remote_host)</span>
|
||||
@@ -1467,15 +1758,17 @@ def set_reserved_names(self, names):
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
def update_node(self, unique_id, data):
|
||||
def update_node(self, unique_id, data, save=True):
|
||||
req = connpy_pb2.NodeRequest(id=unique_id, data=to_struct(data), is_folder=False)
|
||||
self.stub.update_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
def delete_node(self, unique_id, is_folder=False):
|
||||
def delete_node(self, unique_id, is_folder=False, save=True):
|
||||
req = connpy_pb2.DeleteRequest(id=unique_id, is_folder=is_folder)
|
||||
self.stub.delete_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()
|
||||
|
||||
@handle_errors
|
||||
@@ -1857,7 +2150,7 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
@@ -1865,9 +2158,10 @@ def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<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)
|
||||
self.stub.delete_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -2028,7 +2322,7 @@ def set_reserved_names(self, names):
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
@@ -2036,9 +2330,10 @@ def set_reserved_names(self, names):
|
||||
<span>Expand source code</span>
|
||||
</summary>
|
||||
<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)
|
||||
self.stub.update_node(req)
|
||||
if save:
|
||||
self._trigger_local_cache_sync()</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -2532,6 +2827,22 @@ def stop_api(self):
|
||||
</ul>
|
||||
</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>
|
||||
<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>
|
||||
@@ -2618,7 +2929,7 @@ def stop_api(self):
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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:
|
||||
"""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</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):
|
||||
"""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</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) -> 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]</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) -> bool:
|
||||
"""Check if any users are registered (enables auth enforcement)."""
|
||||
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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.grpc_layer.utils 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>
|
||||
@@ -138,7 +138,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+103
-27
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy API documentation</title>
|
||||
<meta name="description" content="<p align="center">
|
||||
<img src="https://nginx.gederico.dynu.net/images/CONNPY-resized.png" alt="App Logo">
|
||||
@@ -51,8 +51,12 @@ el.replaceWith(d);
|
||||
<h2 id="ai-copilot-new-in-v6">🤖 AI Copilot (New in v6)</h2>
|
||||
<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>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>MCP Integration</strong>: Dynamically load tools from external providers (6WIND, AWS, etc.) via the Model Context Protocol.
|
||||
- <strong>Flexible Auth & 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>
|
||||
<h2 id="core-features">Core Features</h2>
|
||||
<ul>
|
||||
@@ -642,8 +646,11 @@ class ai:
|
||||
self.interrupted = False
|
||||
|
||||
|
||||
# 1. Cargar configuración genérica
|
||||
aiconfig = self.config.config.get("ai", {})
|
||||
# 1. Cargar configuración genérica con herencia/merge global
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
aiconfig = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
aiconfig = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
|
||||
# Modelos (Prioridad: Argumento -> Config -> Default)
|
||||
self.engineer_model = engineer_model or aiconfig.get("engineer_model") or "gemini/gemini-3.1-flash-lite"
|
||||
@@ -1534,10 +1541,12 @@ class ai:
|
||||
|
||||
@MethodHook
|
||||
def ask(self, user_input, dryrun=False, chat_history=None, status=None, debug=False, stream=True, session_id=None, chunk_callback=None):
|
||||
soft_limit_warned = False
|
||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||
|
||||
|
||||
if chat_history is None: chat_history = []
|
||||
|
||||
# Load session if provided and history is empty
|
||||
@@ -2144,7 +2153,7 @@ Node: {node_name}"""
|
||||
<dl>
|
||||
<dt id="connpy.ai.SAFE_COMMANDS"><code class="name">var <span class="ident">SAFE_COMMANDS</span></code></dt>
|
||||
<dd>
|
||||
<div class="desc"><p>The type of the None singleton.</p></div>
|
||||
<div class="desc"></div>
|
||||
</dd>
|
||||
</dl>
|
||||
<h3>Instance variables</h3>
|
||||
@@ -2479,10 +2488,12 @@ Node: {node_name}"""
|
||||
</summary>
|
||||
<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):
|
||||
soft_limit_warned = False
|
||||
is_engineer_keyless = "vertex" in self.engineer_model.lower() or "ollama" in self.engineer_model.lower() or "local" in self.engineer_model.lower()
|
||||
if not self.engineer_key and not self.engineer_auth and not is_engineer_keyless:
|
||||
raise ValueError("Engineer API key or authentication not configured. Use 'connpy config --engineer-auth <auth>' to set it.")
|
||||
|
||||
|
||||
if chat_history is None: chat_history = []
|
||||
|
||||
# Load session if provided and history is empty
|
||||
@@ -3184,7 +3195,7 @@ def confirm(self, user_input): return True</code></pre>
|
||||
</dd>
|
||||
<dt id="connpy.configfile"><code class="flex name class">
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
@@ -3217,7 +3228,8 @@ class configfile:
|
||||
passwords.
|
||||
'''
|
||||
|
||||
def __init__(self, conf = None, key = None):
|
||||
def __init__(self, conf = None, key = None, shared_config = None):
|
||||
self._shared_config = shared_config
|
||||
'''
|
||||
|
||||
### Optional Parameters:
|
||||
@@ -3323,6 +3335,42 @@ class configfile:
|
||||
self._generate_nodes_cache()
|
||||
|
||||
|
||||
def get_effective_setting(self, key, default=None):
|
||||
"""Get config setting with shared fallback for inheritable keys."""
|
||||
val = self.config.get(key)
|
||||
if key == "ai":
|
||||
if val is not None:
|
||||
if self._shared_config:
|
||||
import copy
|
||||
# Deep merge: shared as base, user overrides
|
||||
base = copy.deepcopy(self._shared_config.config.get(key, {}))
|
||||
if isinstance(base, dict) and isinstance(val, dict):
|
||||
# Credential isolation:
|
||||
# If user defines engineer credentials, discard shared ones
|
||||
if "engineer_api_key" in val or "engineer_auth" in val:
|
||||
base.pop("engineer_api_key", None)
|
||||
base.pop("engineer_auth", None)
|
||||
# If user defines architect credentials, discard shared ones
|
||||
if "architect_api_key" in val or "architect_auth" in val:
|
||||
base.pop("architect_api_key", None)
|
||||
base.pop("architect_auth", None)
|
||||
|
||||
# Recursive update for inner dictionaries (like mcp_servers or model details)
|
||||
def deep_merge(d1, d2):
|
||||
for k, v in d2.items():
|
||||
if isinstance(v, dict) and k in d1 and isinstance(d1[k], dict):
|
||||
deep_merge(d1[k], v)
|
||||
else:
|
||||
d1[k] = copy.deepcopy(v)
|
||||
deep_merge(base, val)
|
||||
return base
|
||||
return val
|
||||
elif self._shared_config:
|
||||
return self._shared_config.config.get(key, default)
|
||||
|
||||
return val if val is not None else default
|
||||
|
||||
|
||||
def _validate_config(self, data):
|
||||
"""Verify config data has the required structure."""
|
||||
if not isinstance(data, dict):
|
||||
@@ -3663,7 +3711,8 @@ class configfile:
|
||||
else:
|
||||
printer.error("Filter must be a string or a list of strings")
|
||||
sys.exit(1)
|
||||
nodes = [item for item in nodes if any(re.search(pattern, item) for pattern in flat_filter)]
|
||||
flags = re.IGNORECASE if not self.config.get("case", False) else 0
|
||||
nodes = [item for item in nodes if any(re.search(pattern, item, flags) for pattern in flat_filter)]
|
||||
return nodes
|
||||
|
||||
@MethodHook
|
||||
@@ -3786,13 +3835,6 @@ class configfile:
|
||||
|
||||
- publickey (obj): Object containing the public key to decrypt
|
||||
passwords.
|
||||
</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>
|
||||
<h3>Methods</h3>
|
||||
<dl>
|
||||
@@ -3844,6 +3886,51 @@ def encrypt(self, password, keyfile=None):
|
||||
<pre><code>str: Encrypted password.
|
||||
</code></pre></div>
|
||||
</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):
|
||||
"""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</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">
|
||||
<span>def <span class="ident">getitem</span></span>(<span>self, unique, keys=None, extract=False)</span>
|
||||
</code></dt>
|
||||
@@ -5000,18 +5087,6 @@ class node:
|
||||
cmd += f" {self.options}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _generate_ssm_cmd(self):
|
||||
region = self.tags.get("region", "") if isinstance(self.tags, dict) else ""
|
||||
profile = self.tags.get("profile", "") if isinstance(self.tags, dict) else ""
|
||||
cmd = f"aws ssm start-session --target {self.host}"
|
||||
if region:
|
||||
cmd += f" --region {region}"
|
||||
if profile:
|
||||
cmd += f" --profile {profile}"
|
||||
if self.options:
|
||||
cmd += f" {self.options}"
|
||||
return cmd
|
||||
|
||||
@MethodHook
|
||||
def _get_cmd(self):
|
||||
@@ -6358,6 +6433,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
|
||||
<h4><code><a title="connpy.configfile" href="#connpy.configfile">configfile</a></code></h4>
|
||||
<ul class="">
|
||||
<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.getitems" href="#connpy.configfile.getitems">getitems</a></code></li>
|
||||
</ul>
|
||||
@@ -6384,7 +6460,7 @@ def test(self, commands, expected, vars = None,*, folder = None, prompt = None,
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.mcp_client 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>
|
||||
@@ -86,7 +86,10 @@ el.replaceWith(d);
|
||||
|
||||
all_llm_tools = []
|
||||
try:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {})
|
||||
else:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {}
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -260,7 +263,10 @@ el.replaceWith(d);
|
||||
|
||||
all_llm_tools = []
|
||||
try:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
mcp_config = self.config.get_effective_setting("ai", {}).get("mcp_servers", {})
|
||||
else:
|
||||
mcp_config = self.config.config.get("ai", {}).get("mcp_servers", {}) if hasattr(self.config, "config") else {}
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
@@ -343,7 +349,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.proto 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>
|
||||
@@ -60,7 +60,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.ai_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>
|
||||
@@ -359,7 +359,10 @@ el.replaceWith(d);
|
||||
|
||||
def list_mcp_servers(self) -> dict:
|
||||
"""Get the configured MCP servers."""
|
||||
ai_settings = self.config.config.get("ai", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
ai_settings = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
return ai_settings.get("mcp_servers", {})
|
||||
|
||||
def load_session_data(self, session_id):
|
||||
@@ -669,7 +672,10 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def list_mcp_servers(self) -> dict:
|
||||
"""Get the configured MCP servers."""
|
||||
ai_settings = self.config.config.get("ai", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
ai_settings = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
return ai_settings.get("mcp_servers", {})</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Get the configured MCP servers.</p></div>
|
||||
@@ -826,7 +832,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.base 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>
|
||||
@@ -152,7 +152,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.config_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>
|
||||
@@ -319,7 +319,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.context_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>
|
||||
@@ -370,7 +370,7 @@ def current_context(self) -> str:
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.exceptions 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>
|
||||
@@ -268,7 +268,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.execution_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>
|
||||
@@ -449,7 +449,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.import_export_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>
|
||||
@@ -361,7 +361,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+230
-86
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services 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>
|
||||
@@ -92,6 +92,10 @@ el.replaceWith(d);
|
||||
<dd>
|
||||
<div class="desc"></div>
|
||||
</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>
|
||||
</section>
|
||||
<section>
|
||||
@@ -414,7 +418,10 @@ el.replaceWith(d);
|
||||
|
||||
def list_mcp_servers(self) -> dict:
|
||||
"""Get the configured MCP servers."""
|
||||
ai_settings = self.config.config.get("ai", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
ai_settings = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
return ai_settings.get("mcp_servers", {})
|
||||
|
||||
def load_session_data(self, session_id):
|
||||
@@ -724,7 +731,10 @@ el.replaceWith(d);
|
||||
</summary>
|
||||
<pre><code class="python">def list_mcp_servers(self) -> dict:
|
||||
"""Get the configured MCP servers."""
|
||||
ai_settings = self.config.config.get("ai", {})
|
||||
if hasattr(self.config, "get_effective_setting"):
|
||||
ai_settings = self.config.get_effective_setting("ai", {})
|
||||
else:
|
||||
ai_settings = self.config.config.get("ai", {}) if hasattr(self.config, "config") else {}
|
||||
return ai_settings.get("mcp_servers", {})</code></pre>
|
||||
</details>
|
||||
<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._saveconfig(self.config.file)
|
||||
|
||||
def update_node(self, unique_id, data):
|
||||
def update_node(self, unique_id, data, save=True):
|
||||
"""Explicitly update an existing node."""
|
||||
all_nodes = self.config._getallnodes()
|
||||
if unique_id not in all_nodes:
|
||||
@@ -2022,9 +2032,10 @@ el.replaceWith(d);
|
||||
|
||||
# config._connections_add actually handles updates if ID exists correctly
|
||||
self.config._connections_add(**data)
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def delete_node(self, unique_id, is_folder=False):
|
||||
def delete_node(self, unique_id, is_folder=False, save=True):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -2037,6 +2048,7 @@ el.replaceWith(d);
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
self.config._connections_del(**uniques)
|
||||
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
|
||||
@@ -2267,14 +2279,14 @@ el.replaceWith(d);
|
||||
<div class="desc"><p>Interact with a node directly.</p></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</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):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -2287,6 +2299,7 @@ el.replaceWith(d);
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
self.config._connections_del(**uniques)
|
||||
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Logic for deleting a node or folder.</p></div>
|
||||
@@ -2496,14 +2509,14 @@ el.replaceWith(d);
|
||||
<div class="desc"><p>Move or copy a node.</p></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</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):
|
||||
"""Explicitly update an existing node."""
|
||||
all_nodes = self.config._getallnodes()
|
||||
if unique_id not in all_nodes:
|
||||
@@ -2517,6 +2530,7 @@ el.replaceWith(d);
|
||||
|
||||
# config._connections_add actually handles updates if ID exists correctly
|
||||
self.config._connections_add(**data)
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Explicitly update an existing node.</p></div>
|
||||
@@ -2568,16 +2582,47 @@ el.replaceWith(d);
|
||||
<pre><code class="python">class PluginService(BaseService):
|
||||
"""Business logic for enabling, disabling, and listing plugins."""
|
||||
|
||||
def _get_plugin_path(self, name, include_disabled=True):
|
||||
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
|
||||
import os
|
||||
|
||||
# 1. User directory
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
p_file = os.path.join(user_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "user", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "user", False
|
||||
|
||||
# 2. Shared/Global directory
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
p_file = os.path.join(shared_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "shared", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "shared", False
|
||||
|
||||
# 3. Core plugins
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
p_file = os.path.join(core_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "core", True
|
||||
|
||||
return None, None, False
|
||||
|
||||
|
||||
def list_plugins(self):
|
||||
"""List all core and user-defined plugins with their status and hash."""
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# Check for user plugins directory
|
||||
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
# Check for core plugins directory
|
||||
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
|
||||
all_plugin_info = {}
|
||||
|
||||
def get_hash(path):
|
||||
@@ -2587,12 +2632,35 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# User plugins
|
||||
if os.path.exists(plugin_dir):
|
||||
for f in os.listdir(plugin_dir):
|
||||
# 1. Scan core plugins (lowest priority)
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
if os.path.exists(core_dir):
|
||||
for f in os.listdir(core_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(plugin_dir, f)
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
for f in os.listdir(shared_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
@@ -2600,6 +2668,7 @@ el.replaceWith(d);
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
|
||||
def add_plugin(self, name, source_file, update=False):
|
||||
"""Add or update a plugin from a local file."""
|
||||
import os
|
||||
@@ -2680,6 +2749,10 @@ el.replaceWith(d);
|
||||
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
|
||||
|
||||
if not deleted:
|
||||
# If not deleted from user directory, check if it's in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
|
||||
if origin in ["shared", "core"]:
|
||||
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def enable_plugin(self, name):
|
||||
@@ -2688,51 +2761,80 @@ el.replaceWith(d);
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
if not os.path.exists(disabled_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
if os.path.exists(disabled_file):
|
||||
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
|
||||
is_shadow = False
|
||||
if os.path.getsize(disabled_file) == 0:
|
||||
# Resolve without the local bkp file to verify if shared/core has it
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
is_shadow = True
|
||||
|
||||
if is_shadow:
|
||||
# Remove shadow file to restore inheritance
|
||||
try:
|
||||
os.remove(disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
|
||||
else:
|
||||
try:
|
||||
os.rename(disabled_file, plugin_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
# If it doesn't exist locally, check if it's already an active shared/core plugin
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
return False # Already active/enabled through inheritance
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def disable_plugin(self, name):
|
||||
"""Deactivate a plugin by renaming it to a backup file."""
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
if not os.path.exists(plugin_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
# Regular user-level plugin exists. Rename to bkp
|
||||
try:
|
||||
os.rename(plugin_file, disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
# Check if it exists in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
# Shadow disable it by creating an empty .py.bkp in user plugins dir
|
||||
plugin_dir = os.path.dirname(plugin_file)
|
||||
os.makedirs(plugin_dir, exist_ok=True)
|
||||
try:
|
||||
with open(disabled_file, "w") as f:
|
||||
f.write("")
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
|
||||
|
||||
def get_plugin_source(self, name):
|
||||
import os
|
||||
from ..services.exceptions import InvalidConfigurationError
|
||||
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
with open(target, "r") as f:
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def invoke_plugin(self, name, args_dict):
|
||||
@@ -2772,17 +2874,12 @@ el.replaceWith(d);
|
||||
|
||||
p_manager = Plugins()
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
module = p_manager._import_from_path(target)
|
||||
module = p_manager._import_from_path(path)
|
||||
parser = module.Parser().parser if hasattr(module, "Parser") else None
|
||||
|
||||
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
|
||||
@@ -2935,6 +3032,10 @@ el.replaceWith(d);
|
||||
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
|
||||
|
||||
if not deleted:
|
||||
# If not deleted from user directory, check if it's in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
|
||||
if origin in ["shared", "core"]:
|
||||
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")</code></pre>
|
||||
</details>
|
||||
<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, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
if not os.path.exists(plugin_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
|
||||
|
||||
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}")</code></pre>
|
||||
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
# Check if it exists in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
# Shadow disable it by creating an empty .py.bkp in user plugins dir
|
||||
plugin_dir = os.path.dirname(plugin_file)
|
||||
os.makedirs(plugin_dir, exist_ok=True)
|
||||
try:
|
||||
with open(disabled_file, "w") as f:
|
||||
f.write("")
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
|
||||
</dd>
|
||||
@@ -2981,17 +3096,38 @@ el.replaceWith(d);
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
if not os.path.exists(disabled_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
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}")</code></pre>
|
||||
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
# If it doesn't exist locally, check if it's already an active shared/core plugin
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
return False # Already active/enabled through inheritance
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
|
||||
</dd>
|
||||
@@ -3007,17 +3143,11 @@ el.replaceWith(d);
|
||||
import os
|
||||
from ..services.exceptions import InvalidConfigurationError
|
||||
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
with open(target, "r") as f:
|
||||
with open(path, "r") as f:
|
||||
return f.read()</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -3067,17 +3197,12 @@ el.replaceWith(d);
|
||||
|
||||
p_manager = Plugins()
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
module = p_manager._import_from_path(target)
|
||||
module = p_manager._import_from_path(path)
|
||||
parser = module.Parser().parser if hasattr(module, "Parser") else None
|
||||
|
||||
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
|
||||
@@ -3146,11 +3271,6 @@ el.replaceWith(d);
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# Check for user plugins directory
|
||||
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
# Check for core plugins directory
|
||||
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
|
||||
all_plugin_info = {}
|
||||
|
||||
def get_hash(path):
|
||||
@@ -3160,12 +3280,35 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# User plugins
|
||||
if os.path.exists(plugin_dir):
|
||||
for f in os.listdir(plugin_dir):
|
||||
# 1. Scan core plugins (lowest priority)
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
if os.path.exists(core_dir):
|
||||
for f in os.listdir(core_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(plugin_dir, f)
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
for f in os.listdir(shared_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
@@ -3854,6 +3997,7 @@ el.replaceWith(d);
|
||||
<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.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>
|
||||
</li>
|
||||
<li><h3><a href="#header-classes">Classes</a></h3>
|
||||
@@ -3984,7 +4128,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.node_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>
|
||||
@@ -198,7 +198,7 @@ el.replaceWith(d);
|
||||
self.config._connections_add(**data)
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def update_node(self, unique_id, data):
|
||||
def update_node(self, unique_id, data, save=True):
|
||||
"""Explicitly update an existing node."""
|
||||
all_nodes = self.config._getallnodes()
|
||||
if unique_id not in all_nodes:
|
||||
@@ -212,9 +212,10 @@ el.replaceWith(d);
|
||||
|
||||
# config._connections_add actually handles updates if ID exists correctly
|
||||
self.config._connections_add(**data)
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def delete_node(self, unique_id, is_folder=False):
|
||||
def delete_node(self, unique_id, is_folder=False, save=True):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -227,6 +228,7 @@ el.replaceWith(d);
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
self.config._connections_del(**uniques)
|
||||
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)
|
||||
|
||||
def connect_node(self, unique_id, sftp=False, debug=False, logger=None):
|
||||
@@ -457,14 +459,14 @@ el.replaceWith(d);
|
||||
<div class="desc"><p>Interact with a node directly.</p></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</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):
|
||||
"""Logic for deleting a node or folder."""
|
||||
if is_folder:
|
||||
uniques = self.config._explode_unique(unique_id)
|
||||
@@ -477,6 +479,7 @@ el.replaceWith(d);
|
||||
raise NodeNotFoundError(f"Node '{unique_id}' not found or invalid.")
|
||||
self.config._connections_del(**uniques)
|
||||
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Logic for deleting a node or folder.</p></div>
|
||||
@@ -686,14 +689,14 @@ el.replaceWith(d);
|
||||
<div class="desc"><p>Move or copy a node.</p></div>
|
||||
</dd>
|
||||
<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>
|
||||
<dd>
|
||||
<details class="source">
|
||||
<summary>
|
||||
<span>Expand source code</span>
|
||||
</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):
|
||||
"""Explicitly update an existing node."""
|
||||
all_nodes = self.config._getallnodes()
|
||||
if unique_id not in all_nodes:
|
||||
@@ -707,6 +710,7 @@ el.replaceWith(d);
|
||||
|
||||
# config._connections_add actually handles updates if ID exists correctly
|
||||
self.config._connections_add(**data)
|
||||
if save:
|
||||
self.config._saveconfig(self.config.file)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Explicitly update an existing node.</p></div>
|
||||
@@ -786,7 +790,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.plugin_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>
|
||||
@@ -58,16 +58,47 @@ el.replaceWith(d);
|
||||
<pre><code class="python">class PluginService(BaseService):
|
||||
"""Business logic for enabling, disabling, and listing plugins."""
|
||||
|
||||
def _get_plugin_path(self, name, include_disabled=True):
|
||||
"""Resolves the physical path of a plugin by name. Priority: user, shared/global, core."""
|
||||
import os
|
||||
|
||||
# 1. User directory
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
p_file = os.path.join(user_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "user", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(user_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "user", False
|
||||
|
||||
# 2. Shared/Global directory
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
p_file = os.path.join(shared_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "shared", True
|
||||
if include_disabled:
|
||||
bkp_file = os.path.join(shared_dir, f"{name}.py.bkp")
|
||||
if os.path.exists(bkp_file):
|
||||
return bkp_file, "shared", False
|
||||
|
||||
# 3. Core plugins
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
p_file = os.path.join(core_dir, f"{name}.py")
|
||||
if os.path.exists(p_file):
|
||||
return p_file, "core", True
|
||||
|
||||
return None, None, False
|
||||
|
||||
|
||||
def list_plugins(self):
|
||||
"""List all core and user-defined plugins with their status and hash."""
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# Check for user plugins directory
|
||||
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
# Check for core plugins directory
|
||||
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
|
||||
all_plugin_info = {}
|
||||
|
||||
def get_hash(path):
|
||||
@@ -77,12 +108,35 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# User plugins
|
||||
if os.path.exists(plugin_dir):
|
||||
for f in os.listdir(plugin_dir):
|
||||
# 1. Scan core plugins (lowest priority)
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
if os.path.exists(core_dir):
|
||||
for f in os.listdir(core_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(plugin_dir, f)
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
for f in os.listdir(shared_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
@@ -90,6 +144,7 @@ el.replaceWith(d);
|
||||
|
||||
return all_plugin_info
|
||||
|
||||
|
||||
def add_plugin(self, name, source_file, update=False):
|
||||
"""Add or update a plugin from a local file."""
|
||||
import os
|
||||
@@ -170,6 +225,10 @@ el.replaceWith(d);
|
||||
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
|
||||
|
||||
if not deleted:
|
||||
# If not deleted from user directory, check if it's in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
|
||||
if origin in ["shared", "core"]:
|
||||
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def enable_plugin(self, name):
|
||||
@@ -178,51 +237,80 @@ el.replaceWith(d);
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
if not os.path.exists(disabled_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
if os.path.exists(disabled_file):
|
||||
# Check if it is a shadow bkp file (0 bytes shadowing shared/core)
|
||||
is_shadow = False
|
||||
if os.path.getsize(disabled_file) == 0:
|
||||
# Resolve without the local bkp file to verify if shared/core has it
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
is_shadow = True
|
||||
|
||||
if is_shadow:
|
||||
# Remove shadow file to restore inheritance
|
||||
try:
|
||||
os.remove(disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to remove shadow file '{disabled_file}': {e}")
|
||||
else:
|
||||
try:
|
||||
os.rename(disabled_file, plugin_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
# If it doesn't exist locally, check if it's already an active shared/core plugin
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
return False # Already active/enabled through inheritance
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
|
||||
def disable_plugin(self, name):
|
||||
"""Deactivate a plugin by renaming it to a backup file."""
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
if not os.path.exists(plugin_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
# Regular user-level plugin exists. Rename to bkp
|
||||
try:
|
||||
os.rename(plugin_file, disabled_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
# Check if it exists in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
# Shadow disable it by creating an empty .py.bkp in user plugins dir
|
||||
plugin_dir = os.path.dirname(plugin_file)
|
||||
os.makedirs(plugin_dir, exist_ok=True)
|
||||
try:
|
||||
with open(disabled_file, "w") as f:
|
||||
f.write("")
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")
|
||||
|
||||
def get_plugin_source(self, name):
|
||||
import os
|
||||
from ..services.exceptions import InvalidConfigurationError
|
||||
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
with open(target, "r") as f:
|
||||
with open(path, "r") as f:
|
||||
return f.read()
|
||||
|
||||
def invoke_plugin(self, name, args_dict):
|
||||
@@ -262,17 +350,12 @@ el.replaceWith(d);
|
||||
|
||||
p_manager = Plugins()
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
module = p_manager._import_from_path(target)
|
||||
module = p_manager._import_from_path(path)
|
||||
parser = module.Parser().parser if hasattr(module, "Parser") else None
|
||||
|
||||
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
|
||||
@@ -425,6 +508,10 @@ el.replaceWith(d);
|
||||
raise InvalidConfigurationError(f"Failed to delete plugin file '{f}': {e}")
|
||||
|
||||
if not deleted:
|
||||
# If not deleted from user directory, check if it's in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=True)
|
||||
if origin in ["shared", "core"]:
|
||||
raise InvalidConfigurationError("Global and core plugins are read-only and cannot be deleted by users.")
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")</code></pre>
|
||||
</details>
|
||||
<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, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
if not os.path.exists(plugin_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is a core plugin.")
|
||||
|
||||
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}")</code></pre>
|
||||
raise InvalidConfigurationError(f"Failed to disable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(disabled_file):
|
||||
return False # Already disabled
|
||||
|
||||
# Check if it exists in shared or core
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
# Shadow disable it by creating an empty .py.bkp in user plugins dir
|
||||
plugin_dir = os.path.dirname(plugin_file)
|
||||
os.makedirs(plugin_dir, exist_ok=True)
|
||||
try:
|
||||
with open(disabled_file, "w") as f:
|
||||
f.write("")
|
||||
return True
|
||||
except OSError as e:
|
||||
raise InvalidConfigurationError(f"Failed to create shadow disable file: {e}")
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found or is already disabled.")</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Deactivate a plugin by renaming it to a backup file.</p></div>
|
||||
</dd>
|
||||
@@ -471,17 +572,38 @@ el.replaceWith(d);
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
disabled_file = f"{plugin_file}.bkp"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
if not os.path.exists(disabled_file):
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")
|
||||
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}")</code></pre>
|
||||
raise InvalidConfigurationError(f"Failed to enable plugin '{name}': {e}")
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
return False # Already enabled
|
||||
|
||||
# If it doesn't exist locally, check if it's already an active shared/core plugin
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if origin in ["shared", "core"]:
|
||||
return False # Already active/enabled through inheritance
|
||||
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found.")</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Activate a plugin by renaming its backup file.</p></div>
|
||||
</dd>
|
||||
@@ -497,17 +619,11 @@ el.replaceWith(d);
|
||||
import os
|
||||
from ..services.exceptions import InvalidConfigurationError
|
||||
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
with open(target, "r") as f:
|
||||
with open(path, "r") as f:
|
||||
return f.read()</code></pre>
|
||||
</details>
|
||||
<div class="desc"></div>
|
||||
@@ -557,17 +673,12 @@ el.replaceWith(d);
|
||||
|
||||
p_manager = Plugins()
|
||||
import os
|
||||
plugin_file = os.path.join(self.config.defaultdir, "plugins", f"{name}.py")
|
||||
core_path = os.path.dirname(os.path.realpath(__file__)) + f"/../core_plugins/{name}.py"
|
||||
|
||||
if os.path.exists(plugin_file):
|
||||
target = plugin_file
|
||||
elif os.path.exists(core_path):
|
||||
target = core_path
|
||||
else:
|
||||
path, origin, enabled = self._get_plugin_path(name, include_disabled=False)
|
||||
if not path:
|
||||
raise InvalidConfigurationError(f"Plugin '{name}' not found")
|
||||
|
||||
module = p_manager._import_from_path(target)
|
||||
module = p_manager._import_from_path(path)
|
||||
parser = module.Parser().parser if hasattr(module, "Parser") else None
|
||||
|
||||
if "__func_name__" in args_dict and hasattr(module, args_dict["__func_name__"]):
|
||||
@@ -636,11 +747,6 @@ el.replaceWith(d);
|
||||
import os
|
||||
import hashlib
|
||||
|
||||
# Check for user plugins directory
|
||||
plugin_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
# Check for core plugins directory
|
||||
core_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
|
||||
all_plugin_info = {}
|
||||
|
||||
def get_hash(path):
|
||||
@@ -650,12 +756,35 @@ el.replaceWith(d);
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# User plugins
|
||||
if os.path.exists(plugin_dir):
|
||||
for f in os.listdir(plugin_dir):
|
||||
# 1. Scan core plugins (lowest priority)
|
||||
core_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "..", "core_plugins")
|
||||
if os.path.exists(core_dir):
|
||||
for f in os.listdir(core_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(plugin_dir, f)
|
||||
path = os.path.join(core_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
|
||||
# 2. Scan shared plugins (medium priority)
|
||||
if hasattr(self.config, "_shared_config") and self.config._shared_config:
|
||||
shared_dir = os.path.join(self.config._shared_config.defaultdir, "plugins")
|
||||
if os.path.exists(shared_dir):
|
||||
for f in os.listdir(shared_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(shared_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
all_plugin_info[name] = {"enabled": False}
|
||||
|
||||
# 3. Scan user plugins (highest priority)
|
||||
user_dir = os.path.join(self.config.defaultdir, "plugins")
|
||||
if os.path.exists(user_dir):
|
||||
for f in os.listdir(user_dir):
|
||||
if f.endswith(".py"):
|
||||
name = f[:-3]
|
||||
path = os.path.join(user_dir, f)
|
||||
all_plugin_info[name] = {"enabled": True, "hash": get_hash(path)}
|
||||
elif f.endswith(".py.bkp"):
|
||||
name = f[:-7]
|
||||
@@ -709,7 +838,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.profile_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>
|
||||
@@ -429,7 +429,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.provider 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>
|
||||
@@ -98,6 +98,7 @@ el.replaceWith(d);
|
||||
from .import_export_service import ImportExportService
|
||||
from .context_service import ContextService
|
||||
from .sync_service import SyncService
|
||||
from .user_service import UserService
|
||||
|
||||
self.nodes = NodeService(self.config)
|
||||
self.profiles = ProfileService(self.config)
|
||||
@@ -109,6 +110,7 @@ el.replaceWith(d);
|
||||
self.import_export = ImportExportService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = UserService(self.config.defaultdir)
|
||||
|
||||
def _init_remote(self):
|
||||
# Allow ConfigService to work locally so the user can revert the mode
|
||||
@@ -118,14 +120,37 @@ el.replaceWith(d);
|
||||
self.config_svc = ConfigService(self.config)
|
||||
self.context = ContextService(self.config)
|
||||
self.sync = SyncService(self.config)
|
||||
self.users = None
|
||||
|
||||
if not self.remote_host:
|
||||
raise InvalidConfigurationError("Remote host must be specified in remote mode")
|
||||
|
||||
import grpc
|
||||
from ..grpc_layer.stubs import NodeStub, ProfileStub, PluginStub, AIStub, ExecutionStub, ImportExportStub, SystemStub
|
||||
import os
|
||||
from ..grpc_layer.stubs import (
|
||||
NodeStub, ProfileStub, PluginStub, AIStub,
|
||||
ExecutionStub, ImportExportStub, SystemStub,
|
||||
ConfigStub, AuthClientInterceptor, AuthStub
|
||||
)
|
||||
|
||||
def get_token():
|
||||
token_path = os.path.join(self.config.defaultdir, ".token")
|
||||
if os.path.exists(token_path):
|
||||
try:
|
||||
with open(token_path, "r") as f:
|
||||
return f.read().strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
channel = grpc.insecure_channel(self.remote_host)
|
||||
interceptor = AuthClientInterceptor(get_token)
|
||||
channel = grpc.intercept_channel(channel, interceptor)
|
||||
|
||||
# Surgical fix: Keep ConfigService local for mode/theme management,
|
||||
# but delegate encryption to the server stub.
|
||||
config_remote = ConfigStub(channel, remote_host=self.remote_host)
|
||||
self.config_svc.encrypt_password = config_remote.encrypt_password
|
||||
|
||||
self.nodes = NodeStub(channel, remote_host=self.remote_host, config=self.config)
|
||||
self.profiles = ProfileStub(channel, remote_host=self.remote_host, node_stub=self.nodes)
|
||||
@@ -133,7 +158,8 @@ el.replaceWith(d);
|
||||
self.ai = AIStub(channel, remote_host=self.remote_host)
|
||||
self.system = SystemStub(channel, remote_host=self.remote_host)
|
||||
self.execution = ExecutionStub(channel, remote_host=self.remote_host)
|
||||
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)</code></pre>
|
||||
self.import_export = ImportExportStub(channel, remote_host=self.remote_host)
|
||||
self.auth = AuthStub(channel, remote_host=self.remote_host)</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Dynamic service backend. Transparently provides local or remote services.</p></div>
|
||||
</dd>
|
||||
@@ -164,7 +190,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.sync_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>
|
||||
@@ -964,7 +964,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.services.system_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>
|
||||
@@ -325,7 +325,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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, "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</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):
|
||||
"""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)</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) -> 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"))</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):
|
||||
"""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)</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) -> 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"]
|
||||
}</code></pre>
|
||||
</details>
|
||||
<div class="desc"><p>Creates a new user with bcrypt-hashed credentials.</p>
|
||||
<p>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.</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):
|
||||
"""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)</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) -> 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</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) -> 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")
|
||||
}</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) -> 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()
|
||||
]</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) -> 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</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>
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.tunnels 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>
|
||||
@@ -545,7 +545,7 @@ Bridges the blocking gRPC iterators with the async _async_interact_loop.</p></di
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<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.6">
|
||||
<meta name="generator" content="pdoc3 0.11.5">
|
||||
<title>connpy.utils 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>
|
||||
@@ -147,7 +147,7 @@ el.replaceWith(d);
|
||||
</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.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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -20,3 +20,5 @@ httpx>=0.27.0
|
||||
requests>=2.31.0
|
||||
pytest>=8.0.0
|
||||
pytest-mock>=3.12.0
|
||||
bcrypt>=4.1.0
|
||||
PyJWT>=2.8.0
|
||||
|
||||
@@ -37,18 +37,20 @@ install_requires =
|
||||
pycryptodome>=3.18.0
|
||||
PyYAML>=6.0.1
|
||||
pyfzf>=0.3.1
|
||||
litellm>=1.40.0
|
||||
grpcio>=1.62.0
|
||||
grpcio-tools>=1.62.0
|
||||
litellm>=1.40.0,<2.0.0
|
||||
grpcio>=1.62.0,<2.0.0
|
||||
grpcio-tools>=1.62.0,<2.0.0
|
||||
protobuf>=6.31.1,<7.0.0
|
||||
google-api-python-client>=2.125.0
|
||||
google-auth-oauthlib>=1.2.0
|
||||
google-auth-httplib2>=0.2.0
|
||||
prompt-toolkit>=3.0.0
|
||||
mcp>=1.2.0
|
||||
aiohttp>=3.9.0
|
||||
httpx>=0.27.0
|
||||
mcp>=1.2.0,<2.0.0
|
||||
aiohttp>=3.9.0,<4.0.0
|
||||
httpx>=0.27.0,<1.0.0
|
||||
requests>=2.31.0
|
||||
bcrypt>=4.1.0
|
||||
PyJWT>=2.8.0
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
|
||||
Reference in New Issue
Block a user