Some client improvements and some improvements to testing classes for non-rf testing.

This commit is contained in:
Michael Woods
2025-03-19 20:34:02 -04:00
parent b1c9e7e760
commit 53cdfaf312
12 changed files with 153 additions and 16 deletions

View File

@@ -0,0 +1,5 @@
export PSCLIENT_SERVER=KQ4PEC
export PSCLIENT_AGWPE=localhost
export PSCLIENT_PORT=1
export PSCLIENT_CALLSIGN=KQ4PEC-7
#export TEST_SERVER_DIR=/tmp/ts_conn_dir

View File

@@ -16,6 +16,10 @@ from os import linesep
from shutil import rmtree from shutil import rmtree
from threading import Thread from threading import Thread
class ConnectionClosedError(Exception):
"""Raised when a connection closes unexpectedly."""
pass
class Client: class Client:
def __init__(self, pe_server: str, port: int, client_callsign: str, keep_log=False): def __init__(self, pe_server: str, port: int, client_callsign: str, keep_log=False):
if not ax25.Address.valid_call(client_callsign): if not ax25.Address.valid_call(client_callsign):
@@ -106,7 +110,7 @@ class Client:
conn = self.app.open_connection(0, self.callsign, dest.upper()) conn = self.app.open_connection(0, self.callsign, dest.upper())
while conn.state.name != "CONNECTED": while conn.state.name != "CONNECTED":
if conn.state.name in ['DISCONNECTED', 'DISCONNECTING']: if conn.state.name in ['DISCONNECTED', 'DISCONNECTING']:
raise RuntimeError("Connection disconnected unexpectedly.") raise ConnectionClosedError(f"Connection to {conn.remote_callsign} closed unexpectedly.")
time.sleep(.1) time.sleep(.1)
logging.debug(f"Connection to {dest} ready.") logging.debug(f"Connection to {dest} ready.")
logging.debug("Allowing connection to stabilize for 8 seconds") logging.debug("Allowing connection to stabilize for 8 seconds")
@@ -121,7 +125,7 @@ class Client:
logging.error(f"Connection {conn} disconnected.") logging.error(f"Connection {conn} disconnected.")
if self.keep_log: if self.keep_log:
self.request_log.append((req, None)) self.request_log.append((req, None))
return None raise ConnectionClosedError(f"Connection to {conn.remote_callsign} closed unexpectedly.")
try: try:
unpacked = conn.data.unpack() unpacked = conn.data.unpack()
except: except:
@@ -136,7 +140,7 @@ class Client:
def send_and_receive(self, req: Request, conn: Union[PacketServerConnection,SimpleDirectoryConnection], def send_and_receive(self, req: Request, conn: Union[PacketServerConnection,SimpleDirectoryConnection],
timeout: int = 300) -> Optional[Response]: timeout: int = 300) -> Optional[Response]:
if conn.state.name != "CONNECTED": if conn.state.name != "CONNECTED":
raise RuntimeError("Connection is not connected.") raise ConnectionClosedError(f"Connection to {conn.remote_callsign} closed unexpectedly.")
logging.debug(f"Sending request {req}") logging.debug(f"Sending request {req}")
dest = conn.remote_callsign.upper() dest = conn.remote_callsign.upper()
with self.lock_locker: with self.lock_locker:
@@ -160,7 +164,7 @@ class Client:
while (datetime.datetime.now() < cutoff_date) and (conn.state.name != "CONNECTED"): while (datetime.datetime.now() < cutoff_date) and (conn.state.name != "CONNECTED"):
if conn.state.name in ["DISCONNECTED", "DISCONNECTING"]: if conn.state.name in ["DISCONNECTED", "DISCONNECTING"]:
logging.error(f"Connection {conn} disconnected.") logging.error(f"Connection {conn} disconnected.")
return None raise ConnectionClosedError(f"Connection to {conn.remote_callsign} closed unexpectedly.")
remaining_time = int((cutoff_date - datetime.datetime.now()).total_seconds()) + 1 remaining_time = int((cutoff_date - datetime.datetime.now()).total_seconds()) + 1
if remaining_time <= 0: if remaining_time <= 0:

View File

@@ -1,7 +1,7 @@
import click import click
from packetserver.client.cli.config import get_config, default_app_dir, config_path from packetserver.client.cli.config import get_config, default_app_dir, config_path
from packetserver.client.cli.constants import DEFAULT_DB_FILE from packetserver.client.cli.constants import DEFAULT_DB_FILE
from packetserver.client import Client from packetserver.client import Client, ConnectionClosedError
from packetserver.common.constants import yes_values from packetserver.common.constants import yes_values
from packetserver.common import Request, Response from packetserver.common import Request, Response
from packetserver.client.cli.util import format_list_dicts, exit_client from packetserver.client.cli.util import format_list_dicts, exit_client
@@ -115,7 +115,10 @@ def query_server(ctx):
req = Request.blank() req = Request.blank()
req.path = "" req.path = ""
req.method = Request.Method.GET req.method = Request.Method.GET
try:
resp = client.send_receive_callsign(req, ctx.obj['bbs']) resp = client.send_receive_callsign(req, ctx.obj['bbs'])
except Exception as e:
exit_client(ctx.obj, 2, str(e))
if resp is None: if resp is None:
click.echo(f"No response from {ctx.obj['bbs']}") click.echo(f"No response from {ctx.obj['bbs']}")
exit_client(ctx.obj, 1) exit_client(ctx.obj, 1)

View File

@@ -74,7 +74,14 @@ def quick_session(ctx, transcript):
db_enabled = False db_enabled = False
if cmd == "": if cmd == "":
continue continue
if cmd == "/exit": elif cmd in ["/h", "/?", '/H', "/help", "/HELP"]:
click.echo("""Enter a command to run in a container, or enter one of the following special commands:
'/h' | '/?' to get this help message
'/exit' | '/q' to exit
'/db' to have the remote job put a copy of your user's server db (messages/objects/etc) in a json file
in the remote container in the working directory.
""")
elif cmd in ["/exit", '/q', '/quit']:
break break
elif cmd == "/db": elif cmd == "/db":
click.echo("DB requested for next command.") click.echo("DB requested for next command.")

View File

@@ -101,9 +101,11 @@ def list_objects(ctx, number, search, reverse, sort_by, output_format):
sort_name = True sort_name = True
else: else:
sort_date = True sort_date = True
try:
object_list = get_user_objects(client, ctx.obj['bbs'], limit=number, include_data=False, search=search, object_list = get_user_objects(client, ctx.obj['bbs'], limit=number, include_data=False, search=search,
reverse=reverse, sort_date=sort_date, sort_name=sort_name, sort_size=sort_size) reverse=reverse, sort_date=sort_date, sort_name=sort_name, sort_size=sort_size)
except Exception as e:
exit_client(ctx.obj, 19, message=str(e))
obj_dicts = [] obj_dicts = []
for x in object_list: for x in object_list:

View File

@@ -4,7 +4,8 @@ from typing import Union
from packetserver.common import Request, PacketServerConnection from packetserver.common import Request, PacketServerConnection
from packetserver.common.testing import SimpleDirectoryConnection from packetserver.common.testing import SimpleDirectoryConnection
from packetserver.client import Client from packetserver.client import Client, ConnectionClosedError
from pe.connect import ConnectionState
import ax25 import ax25
from threading import Lock from threading import Lock
import logging import logging
@@ -59,6 +60,8 @@ class TestClient(Client):
time.sleep(1) time.sleep(1)
cutoff_date = datetime.datetime.now() + datetime.timedelta(seconds=timeout) cutoff_date = datetime.datetime.now() + datetime.timedelta(seconds=timeout)
while datetime.datetime.now() < cutoff_date: while datetime.datetime.now() < cutoff_date:
if conn.state != ConnectionState.CONNECTED:
raise ConnectionClosedError(f"Connection to {conn.remote_callsign} closed unexpectedly.")
logging.debug(f"Client {self.callsign} checking for connection conn {conn}") logging.debug(f"Client {self.callsign} checking for connection conn {conn}")
if conn.check_for_data(): if conn.check_for_data():
break break

View File

@@ -7,6 +7,7 @@ from typing import Union, Self, Optional
import os.path import os.path
import logging import logging
import ax25 import ax25
from shutil import rmtree
class DummyPacketServerConnection(PacketServerConnection): class DummyPacketServerConnection(PacketServerConnection):
@@ -90,9 +91,17 @@ class DirectoryTestServerConnection(PacketServerConnection):
file_path = os.path.join(self._directory, file_name) file_path = os.path.join(self._directory, file_name)
return file_path return file_path
def close(self):
self.closing = True
self._state = ConnectionState.DISCONNECTED
if os.path.exists(self._directory):
rmtree(self._directory)
def check_closed(self): def check_closed(self):
if self.closing: if self.closing:
self._state = ConnectionState.DISCONNECTED self._state = ConnectionState.DISCONNECTED
if os.path.exists(self._directory):
rmtree(self._directory)
if self._state is not ConnectionState.CONNECTED: if self._state is not ConnectionState.CONNECTED:
return True return True
if not os.path.isdir(self._directory): if not os.path.isdir(self._directory):
@@ -256,6 +265,7 @@ class SimpleDirectoryConnection:
"""Monitors connection directory for data.""" """Monitors connection directory for data."""
if self.closing: if self.closing:
self._state = ConnectionState.DISCONNECTED self._state = ConnectionState.DISCONNECTED
logging.debug(f"Connection {self} closed.")
if self.check_closed(): if self.check_closed():
return False return False
if os.path.isfile(self.remote_file_path): if os.path.isfile(self.remote_file_path):

View File

@@ -7,6 +7,8 @@ import os.path
from io import BytesIO, BufferedReader from io import BytesIO, BufferedReader
import random import random
import string import string
from persistent.mapping import PersistentMapping
from persistent.list import PersistentList
def email_valid(email: str) -> bool: def email_valid(email: str) -> bool:
"""Taken from https://www.geeksforgeeks.org/check-if-email-address-valid-or-not-in-python/""" """Taken from https://www.geeksforgeeks.org/check-if-email-address-valid-or-not-in-python/"""
@@ -149,3 +151,25 @@ class TarFileExtractor(object):
name = str(name) name = str(name)
self._count = self._count + 1 self._count = self._count + 1
return os.path.basename(name), self.tar_file.extractfile(member) return os.path.basename(name), self.tar_file.extractfile(member)
def convert_to_persistent(data: Union[list,dict]):
if isinstance(data, dict):
persistent_dict = PersistentMapping()
for key, value in data.items():
persistent_dict[key] = convert_to_persistent(value)
return persistent_dict
elif isinstance(data, list):
return PersistentList([convert_to_persistent(item) for item in data])
else:
return data
def convert_from_persistent(data):
if isinstance(data, PersistentMapping):
nonpersistent_dict = {}
for key, value in data.items():
nonpersistent_dict[key] = convert_from_persistent(value)
return nonpersistent_dict
elif isinstance(data, PersistentList):
return [convert_from_persistent(item) for item in data]
else:
return data

View File

@@ -90,7 +90,7 @@ class Server:
logging.debug("objects bucket missing, creating") logging.debug("objects bucket missing, creating")
conn.root.objects = OOBTree() conn.root.objects = OOBTree()
if 'jobs' not in conn.root(): if 'jobs' not in conn.root():
logging.debug("jobss bucket missing, creating") logging.debug("jobs bucket missing, creating")
conn.root.jobs = OOBTree() conn.root.jobs = OOBTree()
if 'job_queue' not in conn.root(): if 'job_queue' not in conn.root():
conn.root.job_queue = PersistentList() conn.root.job_queue = PersistentList()

View File

@@ -0,0 +1,72 @@
import click
import ZODB, ZODB.FileStorage
import ZEO
import json
import os
import os.path
import sys
from persistent.mapping import PersistentMapping
from persistent.list import PersistentList
from pathlib import Path
from packetserver.common.util import convert_from_persistent, convert_to_persistent
@click.group()
@click.option("--database", "-d", type=str, default="",
help="DATABASE is either the path to the database file, or a tcp host:port string to a zeo server that is running."
)
@click.option("--zeo", "-z", is_flag=True, default=False, help="<database> is a zeo address:port string.")
@click.pass_context
def config(ctx, database, zeo):
"""Dump or set the packetserver configuration."""
ctx.ensure_object(dict)
if zeo:
if database is None:
raise ValueError("Database must be at least a port to a zeo server, or an address:port string.")
spl = database.split(":")
if len(spl) == 1:
host = 'localhost'
port = int(spl[0])
else:
host = spl[0]
port = int(spl[1])
db = ZEO.DB((host, port))
else:
if type(database) is str and (database != "") :
data_file = Path(database)
else:
data_file = Path.home().joinpath(".packetserver").joinpath("data.zopedb")
if not data_file.is_file():
raise FileExistsError(f"Database file {str(data_file)} is not a file, or it doesn't exit.")
storage = ZODB.FileStorage.FileStorage(str(data_file))
db = ZODB.DB(storage)
ctx.obj['db'] = db
@click.command()
@click.pass_context
def dump(ctx):
with ctx.obj['db'].transaction() as conn:
click.echo(json.dumps(convert_from_persistent(conn.root.config), indent=2))
@click.command()
@click.option("--json-data", "-j", type=str, required=True, help="Filename to json or '-' for stdin.")
@click.pass_context
def load(ctx, json_data):
if json_data == "-":
data = json.load(sys.stdin)
else:
data = json.load(open(json_data, 'r'))
with ctx.obj['db'].transaction() as conn:
conn.root.config = convert_to_persistent(data)
config.add_command(dump)
config.add_command(load)
if __name__ == '__main__':
config()

View File

@@ -3,7 +3,7 @@ from packetserver.common import Response, Message, Request, send_response, send_
from packetserver.common.testing import DirectoryTestServerConnection, DummyPacketServerConnection from packetserver.common.testing import DirectoryTestServerConnection, DummyPacketServerConnection
from pe.connect import ConnectionState from pe.connect import ConnectionState
from shutil import rmtree from shutil import rmtree
from threading import Thread from threading import Thread, Lock
from . import Server from . import Server
import os import os
import os.path import os.path
@@ -50,10 +50,13 @@ class DirectoryTestServer(Server):
raise NotADirectoryError(f"{connection_directory} is not a directory or doesn't exist.") raise NotADirectoryError(f"{connection_directory} is not a directory or doesn't exist.")
self._file_traffic_dir = os.path.abspath(connection_directory) self._file_traffic_dir = os.path.abspath(connection_directory)
self._dir_connections = [] self._dir_connections = []
self._conn_lock = Lock()
self._conn_thread_running = False
def check_connection_directories(self): def check_connection_directories(self):
logging.debug(f"Server checking connection directory {self._file_traffic_dir}") logging.debug(f"Server checking connection directory {self._file_traffic_dir}")
if not os.path.isdir(self._file_traffic_dir): if not os.path.isdir(self._file_traffic_dir):
self._conn_thread_running = False
raise NotADirectoryError(f"{self._file_traffic_dir} is not a directory or doesn't exist.") raise NotADirectoryError(f"{self._file_traffic_dir} is not a directory or doesn't exist.")
for path in os.listdir(self._file_traffic_dir): for path in os.listdir(self._file_traffic_dir):
@@ -91,13 +94,18 @@ class DirectoryTestServer(Server):
for conn in closed: for conn in closed:
if conn in self._dir_connections: if conn in self._dir_connections:
self._dir_connections.remove(conn) self._dir_connections.remove(conn)
self._conn_thread_running = False
def dir_worker(self): def dir_worker(self):
"""Intended to be running as a thread.""" """Intended to be running as a thread."""
logging.info("Starting worker thread.") logging.info("Starting worker thread.")
while self.started: while self.started:
self.server_worker() self.server_worker()
self.check_connection_directories() with self._conn_lock:
if not self._conn_thread_running:
self._conn_thread_running = True
conn_thread = Thread(target=self.check_connection_directories)
conn_thread.start()
time.sleep(.5) time.sleep(.5)
def start(self): def start(self):

View File

@@ -3,9 +3,7 @@ from setuptools import setup, find_packages
setup( setup(
name='packetserver', name='packetserver',
version='0.4.1', version='0.4.1',
packages=[ packages=find_packages(),
'packetserver',
],
include_package_data=True, include_package_data=True,
install_requires=[ install_requires=[
'click', 'click',
@@ -20,6 +18,7 @@ setup(
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [
'packcli = packetserver.client.cli:cli', 'packcli = packetserver.client.cli:cli',
'packcfg = packetserver.server.cli:config',
], ],
}, },
) )