From 43dd8fc304b46d07900a04fc5e2ebf3a631c2327 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 21 Aug 2025 16:52:11 -0600 Subject: [PATCH 1/3] CI/CD changes and move tests to new framework --- .github/workflows/ci.yml | 39 ++++ .pylintrc | 10 + Makefile | 4 + examples/example_servers.py | 105 +++++----- features/README.md | 20 +- features/environment.py | 18 ++ features/steps.py | 65 ------ features/steps/__init__.py | 2 + features/steps/steps.py | 71 +++++++ jsocket/__init__.py | 142 ++++++------- jsocket/jsocket_base.py | 379 +++++++++++++++++----------------- jsocket/tserver.py | 394 +++++++++++++++++++----------------- requirements-dev.txt | 3 + scripts/smoke_test.py | 55 +++++ tests/test_e2e.py | 59 ++++++ 15 files changed, 792 insertions(+), 574 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .pylintrc create mode 100644 Makefile create mode 100644 features/environment.py delete mode 100644 features/steps.py create mode 100644 features/steps/__init__.py create mode 100644 features/steps/steps.py create mode 100644 requirements-dev.txt create mode 100644 scripts/smoke_test.py create mode 100644 tests/test_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8472f1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install -r requirements-dev.txt + + - name: Run pytest + run: | + PYTHONPATH=. pytest -q + + - name: Run behave + run: | + PYTHONPATH=. behave -f progress2 + diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..76b1033 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,10 @@ +[MASTER] +# Ignore BDD test steps; they rely on runtime-provided globals +ignore=features + +[MESSAGES CONTROL] +# No global disables; prefer fixing code issues +disable= + +[FORMAT] +max-line-length=120 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ee3a35b --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: test-behave + +test-behave: + PYTHONPATH=. behave -f progress2 diff --git a/examples/example_servers.py b/examples/example_servers.py index 03c5223..b519983 100644 --- a/examples/example_servers.py +++ b/examples/example_servers.py @@ -13,63 +13,62 @@ logging.basicConfig(format=FORMAT) class MyServer(jsocket.ThreadedServer): - """ This is a basic example of a custom ThreadedServer. """ - def __init__(self): - super(MyServer, self).__init__() - self.timeout = 2.0 - logger.warning("MyServer class in customServer is for example purposes only.") - - def _process_message(self, obj): - """ virtual method """ - if obj != '': - if obj['message'] == "new connection": - logger.info("new connection.") - return {'message': 'welcome to jsocket server'} + """This is a basic example of a custom ThreadedServer.""" + def __init__(self): + super(MyServer, self).__init__() + self.timeout = 2.0 + logger.warning("MyServer class in customServer is for example purposes only.") + + def _process_message(self, obj): + """ virtual method """ + if obj != '': + if obj['message'] == "new connection": + logger.info("new connection.") + return {'message': 'welcome to jsocket server'} class MyFactoryThread(jsocket.ServerFactoryThread): - """ This is an example factory thread, which the server factory will - instantiate for each new connection. - """ - def __init__(self): - super(MyFactoryThread, self).__init__() - self.timeout = 2.0 - - def _process_message(self, obj): - """ virtual method - Implementer must define protocol """ - if obj != '': - if obj['message'] == "new connection": - logger.info("new connection...") - return {'message': 'welcome to jsocket server'} - else: - logger.info(obj) + """This is an example factory thread, which the server factory will + instantiate for each new connection. + """ + def __init__(self): + super(MyFactoryThread, self).__init__() + self.timeout = 2.0 + + def _process_message(self, obj): + """ virtual method - Implementer must define protocol """ + if obj != '': + if obj['message'] == "new connection": + logger.info("new connection...") + return {'message': 'welcome to jsocket server'} + else: + logger.info(obj) if __name__ == "__main__": - import time - import jsocket - - server = jsocket.ServerFactory(MyFactoryThread, address='127.0.0.1', port=5491) - server.timeout = 2.0 - server.start() - - time.sleep(1) - cPids = [] - for i in range(10): - client = jsocket.JsonClient(address='127.0.0.1', port=5491) - cPids.append(client) - client.connect() - client.send_obj({"message": "new connection", "test": i}) - logger.info(client.read_obj()) + import time + import jsocket + server = jsocket.ServerFactory(MyFactoryThread, address='127.0.0.1', port=5491) + server.timeout = 2.0 + server.start() - client.send_obj({"message": i, "key": 1 }) - - time.sleep(2) - - for c in cPids: - c.close() + time.sleep(1) + cPids = [] + for i in range(10): + client = jsocket.JsonClient(address='127.0.0.1', port=5491) + cPids.append(client) + client.connect() + client.send_obj({"message": "new connection", "test": i}) + logger.info(client.read_obj()) + + client.send_obj({"message": i, "key": 1 }) + + time.sleep(2) + + for c in cPids: + c.close() - time.sleep(5) - logger.warning("Stopping server") - server.stop() - server.join() - logger.warning("Example script exited") + time.sleep(5) + logger.warning("Stopping server") + server.stop() + server.join() + logger.warning("Example script exited") diff --git a/features/README.md b/features/README.md index d9d4751..3a1a86e 100644 --- a/features/README.md +++ b/features/README.md @@ -1,14 +1,12 @@ -Main lettuce repo still doesn't support python3 -Use sgpy fork at https://github.com/sgpy/lettuce instead +Python 3 BDD tests use Behave instead of Lettuce. -Remove any existing python2/3 packages -pip uninstall lettuce -pip3 uninstall lettuce +Setup +- Install dependencies: `python -m pip install behave` -Install fork version -git clone https://github.com/sgpy/lettuce -cd lettuce -python3 setup.py install +Run +- From repo root: `PYTHONPATH=. behave -f progress2` -cd python-json-socket -lettuce +Notes +- Feature files live under `features/*.feature` (unchanged from Lettuce). +- Step definitions are in `features/steps/steps.py`. +- Behave will launch a simple server and client to validate JSON message flow. diff --git a/features/environment.py b/features/environment.py new file mode 100644 index 0000000..f7f2bf5 --- /dev/null +++ b/features/environment.py @@ -0,0 +1,18 @@ +# Behave environment hooks for setup/teardown safety + +def after_all(context): + # Best-effort cleanup in case scenarios fail mid-run + server = getattr(context, 'jsonserver', None) + client = getattr(context, 'jsonclient', None) + try: + if client is not None: + client.close() + except Exception: # pragma: no cover - cleanup best-effort + pass + try: + if server is not None: + server.stop() + server.join(timeout=3) + except Exception: # pragma: no cover + pass + diff --git a/features/steps.py b/features/steps.py deleted file mode 100644 index eaeb8a5..0000000 --- a/features/steps.py +++ /dev/null @@ -1,65 +0,0 @@ -from lettuce import * -import json -import jsocket -import logging - -convert = lambda s : json.loads(s) - -logger = logging.getLogger("jsocket") -logger.setLevel(logging.CRITICAL) - -class MyServer(jsocket.ThreadedServer): - def __init__(self): - super(MyServer, self).__init__() - self.timeout = 2.0 - self.isConnected = False - - def _process_message(self, obj): - """ virtual method """ - if obj != '': - if obj['message'] == "new connection": - self.isConnected = True - - def isAlive(self): - return self._isAlive - -@step('I start the server') -def startTheServer(step): - world.jsonserver = MyServer() - world.jsonserver.start() - -@step('I stop the server') -def stopTheServer(step): - world.jsonserver.stop() - -@step('I close the client') -def stopTheCLient(step): - world.jsonclient.close() - -@step('I connect the client') -def startTheClient(step): - world.jsonclient = jsocket.JsonClient() - world.jsonclient.connect() - -@step('the client sends the object (\{.*\})') -def clientSendsObject(step, obj): - world.jsonclient.send_obj(convert(obj)) - -@step('the server sends the object (\{.*\})') -def serverSendsObject(step, obj): - world.jsonserver.send_obj(convert(obj)) - -@step('the client sees a message (\{.*\})') -def clientMessage(step, obj): - msg = world.jsonclient.read_obj() - assert msg == convert(obj), "%d" % convert(obj) - -@step('I see a connection') -def checkConnection(step): - expected = True - assert world.jsonserver.isConnected == expected, "%d" % False - -@step('I see a stopped server') -def checkServerStopped(step): - expected = False - assert world.jsonserver.isAlive() == expected, "%d" % False \ No newline at end of file diff --git a/features/steps/__init__.py b/features/steps/__init__.py new file mode 100644 index 0000000..fdfae28 --- /dev/null +++ b/features/steps/__init__.py @@ -0,0 +1,2 @@ +# Behave step package marker + diff --git a/features/steps/steps.py b/features/steps/steps.py new file mode 100644 index 0000000..ffd294d --- /dev/null +++ b/features/steps/steps.py @@ -0,0 +1,71 @@ +import json +import logging +from behave import given, when, then, use_step_matcher + +import jsocket + + +logger = logging.getLogger("jsocket.behave") +use_step_matcher("re") + + +class MyServer(jsocket.ThreadedServer): + def __init__(self): + super(MyServer, self).__init__() + self.timeout = 2.0 + self.isConnected = False + + def _process_message(self, obj): + if obj != '': + if obj.get('message') == "new connection": + self.isConnected = True + + +@given(r"I start the server") +def start_server(context): + context.jsonserver = MyServer() + context.jsonserver.start() + + +@given(r"I connect the client") +def connect_client(context): + context.jsonclient = jsocket.JsonClient() + context.jsonclient.connect() + + +@when(r"the client sends the object (\{.*\})") +def client_sends_object(context, obj): + context.jsonclient.send_obj(json.loads(obj)) + + +@when(r"the server sends the object (\{.*\})") +def server_sends_object(context, obj): + context.jsonserver.send_obj(json.loads(obj)) + + +@then(r"the client sees a message (\{.*\})") +def client_sees_message(context, obj): + expected = json.loads(obj) + msg = context.jsonclient.read_obj() + assert msg == expected, "%s" % expected + + +@then(r"I see a connection") +def see_connection(context): + assert context.jsonserver.isConnected is True, "%s" % False + + +@given(r"I stop the server") +def stop_server(context): + context.jsonserver.stop() + + +@then(r"I see a stopped server") +def see_stopped_server(context): + assert context.jsonserver._isAlive is False, "%s" % False + + +@then(r"I close the client") +def close_client(context): + context.jsonclient.close() + diff --git a/jsocket/__init__.py b/jsocket/__init__.py index 375481c..cc337a6 100644 --- a/jsocket/__init__.py +++ b/jsocket/__init__.py @@ -1,75 +1,75 @@ """ @package jsocket - @brief Main package importing two modules, jsocket_base and tserver into the scope of jsocket. - - @example example_servers.py - - @mainpage JSocket - Fast & Scalable JSON Server & Client - @section Installation - - The jsocket package should always be installed using the stable PyPi releases. - Either use "easy_install jsocket" or "pip install jsocket" to get the latest stable version. - - @section Usage - - The jsocket package is for use during the development of distributed systems. There are two ways to - use the package. The first and simplest is to create a custom single threaded server by overloading the - the jsocket.ThreadedServer class (see example one below). - - The second, is to use the server factory functionality by overloading the jsocket.ServerFactoryThread - class and passing the declaration to the jsocket.ServerFactory(FactoryThread) object. This creates a - multithreaded custom JSON server for any number of simultaneous clients (see example two below). - - @section Examples - @b 1: The following snippet simply creates a custom single threaded server by overloading jsocket.ThreadedServer - @code - class MyServer(jsocket.ThreadedServer): - # This is a basic example of a custom ThreadedServer. - def __init__(self): - super(MyServer, self).__init__() - self.timeout = 2.0 - logger.warning("MyServer class in customServer is for example purposes only.") - - def _process_message(self, obj): - # virtual method - if obj != '': - if obj['message'] == "new connection": - logger.info("new connection.") - @endcode - - @b 2: The following snippet creates a custom factory thread and starts a factory server. The factory server - will allocate and run a factory thread for each new client. - - @code - import jsocket - - class MyFactoryThread(jsocket.ServerFactoryThread): - # This is an example factory thread, which the server factory will - # instantiate for each new connection. - def __init__(self): - super(MyFactoryThread, self).__init__() - self.timeout = 2.0 - - def _process_message(self, obj): - # virtual method - Implementer must define protocol - if obj != '': - if obj['message'] == "new connection": - logger.info("new connection.") - else: - logger.info(obj) - - server = jsocket.ServerFactory(MyFactoryThread) - server.timeout = 2.0 - server.start() - - client = jsocket.JsonClient() - client.connect() - client.send_obj({"message": "new connection"}) - - client.close() - server.stop() - server.join() - @endcode - + @brief Main package importing two modules, jsocket_base and tserver into the scope of jsocket. + + @example example_servers.py + + @mainpage JSocket - Fast & Scalable JSON Server & Client + @section Installation + + The jsocket package should always be installed using the stable PyPi releases. + Either use "easy_install jsocket" or "pip install jsocket" to get the latest stable version. + + @section Usage + + The jsocket package is for use during the development of distributed systems. There are two ways to + use the package. The first and simplest is to create a custom single threaded server by overloading the + the jsocket.ThreadedServer class (see example one below). + + The second, is to use the server factory functionality by overloading the jsocket.ServerFactoryThread + class and passing the declaration to the jsocket.ServerFactory(FactoryThread) object. This creates a + multithreaded custom JSON server for any number of simultaneous clients (see example two below). + + @section Examples + @b 1: The following snippet simply creates a custom single threaded server by overloading jsocket.ThreadedServer + @code + class MyServer(jsocket.ThreadedServer): + # This is a basic example of a custom ThreadedServer. + def __init__(self): + super(MyServer, self).__init__() + self.timeout = 2.0 + logger.warning("MyServer class in customServer is for example purposes only.") + + def _process_message(self, obj): + # virtual method + if obj != '': + if obj['message'] == "new connection": + logger.info("new connection.") + @endcode + + @b 2: The following snippet creates a custom factory thread and starts a factory server. The factory server + will allocate and run a factory thread for each new client. + + @code + import jsocket + + class MyFactoryThread(jsocket.ServerFactoryThread): + # This is an example factory thread, which the server factory will + # instantiate for each new connection. + def __init__(self): + super(MyFactoryThread, self).__init__() + self.timeout = 2.0 + + def _process_message(self, obj): + # virtual method - Implementer must define protocol + if obj != '': + if obj['message'] == "new connection": + logger.info("new connection.") + else: + logger.info(obj) + + server = jsocket.ServerFactory(MyFactoryThread) + server.timeout = 2.0 + server.start() + + client = jsocket.JsonClient() + client.connect() + client.send_obj({"message": "new connection"}) + + client.close() + server.stop() + server.join() + @endcode + """ from jsocket.jsocket_base import * from jsocket.tserver import * diff --git a/jsocket/jsocket_base.py b/jsocket/jsocket_base.py index ec8fc47..3e7e3ef 100644 --- a/jsocket/jsocket_base.py +++ b/jsocket/jsocket_base.py @@ -1,26 +1,26 @@ """ @namespace jsocket_base - Contains JsonSocket, JsonServer and JsonClient implementations (json object message passing server and client). + Contains JsonSocket, JsonServer and JsonClient implementations (json object message passing server and client). """ -__author__ = "Christopher Piekarski" -__email__ = "chris@cpiekarski.com" +__author__ = "Christopher Piekarski" +__email__ = "chris@cpiekarski.com" __copyright__= """ - This file is part of the jsocket package. - Copyright (C) 2011 by - Christopher Piekarski + This file is part of the jsocket package. + Copyright (C) 2011 by + Christopher Piekarski - The jsocket_base module is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + The jsocket_base module is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - The jsocket package is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + The jsocket package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. - You should have received a copy of the GNU General Public License - along with jsocket_base module. If not, see .""" -__version__ = "1.0.2" + You should have received a copy of the GNU General Public License + along with jsocket_base module. If not, see .""" +__version__ = "1.0.2" import json import socket @@ -30,179 +30,184 @@ logger = logging.getLogger("jsocket") + class JsonSocket(object): - def __init__(self, address='127.0.0.1', port=5489, timeout=2.0): - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.conn = self.socket - self._timeout = timeout - self._address = address - self._port = port - - def send_obj(self, obj): - msg = json.dumps(obj) - if self.socket: - frmt = "=%ds" % len(msg) - packed_msg = struct.pack(frmt, bytes(msg,'ascii')) - packed_hdr = struct.pack('!I', len(packed_msg)) - self._send(packed_hdr) - self._send(packed_msg) - - def _send(self, msg): - sent = 0 - while sent < len(msg): - sent += self.conn.send(msg[sent:]) - - def _read(self, size): - data = b'' - while len(data) < size: - data_tmp = self.conn.recv(size-len(data)) - data += data_tmp - if data_tmp == b'': - raise RuntimeError("socket connection broken") - return data - - def _msg_length(self): - d = self._read(4) - s = struct.unpack('!I', d) - return s[0] - - def read_obj(self): - size = self._msg_length() - data = self._read(size) - frmt = "=%ds" % size - msg = struct.unpack(frmt, data) - return json.loads(str(msg[0],'ascii')) - - def close(self): - logger.debug("closing all connections") - self._close_connection() - self._close_socket() - - def _close_socket(self): - logger.debug("closing main socket") - if self.socket.fileno() != -1: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - - def _close_connection(self): - logger.debug("closing the connection socket") - if self.conn.fileno() != -1: - self.conn.shutdown(socket.SHUT_RDWR) - self.conn.close() - - def _get_timeout(self): - return self._timeout - - def _set_timeout(self, timeout): - self._timeout = timeout - self.socket.settimeout(timeout) - - def _get_address(self): - return self._address - - def _set_address(self, address): - pass - - def _get_port(self): - return self._port - - def _set_port(self, port): - pass - - timeout = property(_get_timeout, _set_timeout,doc='Get/set the socket timeout') - address = property(_get_address, _set_address,doc='read only property socket address') - port = property(_get_port, _set_port,doc='read only property socket port') - - + def __init__(self, address='127.0.0.1', port=5489, timeout=2.0): + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.conn = self.socket + self._timeout = timeout + self._address = address + self._port = port + + def send_obj(self, obj): + msg = json.dumps(obj, ensure_ascii=False) + if self.socket: + payload = msg.encode('utf-8') + frmt = "=%ds" % len(payload) + packed_msg = struct.pack(frmt, payload) + packed_hdr = struct.pack('!I', len(packed_msg)) + self._send(packed_hdr) + self._send(packed_msg) + + def _send(self, msg): + sent = 0 + while sent < len(msg): + sent += self.conn.send(msg[sent:]) + + def _read(self, size): + data = b'' + while len(data) < size: + data_tmp = self.conn.recv(size - len(data)) + data += data_tmp + if data_tmp == b'': + raise RuntimeError("socket connection broken") + return data + + def _msg_length(self): + d = self._read(4) + s = struct.unpack('!I', d) + return s[0] + + def read_obj(self): + size = self._msg_length() + data = self._read(size) + frmt = "=%ds" % size + msg = struct.unpack(frmt, data) + return json.loads(msg[0].decode('utf-8')) + + def close(self): + logger.debug("closing all connections") + self._close_connection() + self._close_socket() + + def _close_socket(self): + logger.debug("closing main socket") + if self.socket.fileno() != -1: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + + def _close_connection(self): + logger.debug("closing the connection socket") + if self.conn.fileno() != -1: + self.conn.shutdown(socket.SHUT_RDWR) + self.conn.close() + + def _get_timeout(self): + return self._timeout + + def _set_timeout(self, timeout): + self._timeout = timeout + self.socket.settimeout(timeout) + + def _get_address(self): + return self._address + + def _set_address(self, address): + pass + + def _get_port(self): + return self._port + + def _set_port(self, port): + pass + + timeout = property(_get_timeout, _set_timeout, doc='Get/set the socket timeout') + address = property(_get_address, _set_address, doc='read only property socket address') + port = property(_get_port, _set_port, doc='read only property socket port') + + class JsonServer(JsonSocket): - def __init__(self, address='127.0.0.1', port=5489): - super(JsonServer, self).__init__(address, port) - self._bind() - - def _bind(self): - self.socket.bind( (self.address,self.port) ) - - def _listen(self): - self.socket.listen(1) - - def _accept(self): - return self.socket.accept() - - def accept_connection(self): - self._listen() - self.conn, addr = self._accept() - self.conn.settimeout(self.timeout) - logger.debug("connection accepted, conn socket (%s,%d,%d)" % (addr[0],addr[1],self.conn.gettimeout())) - - def _is_connected(self): - return True if not self.conn else False - - connected = property(_is_connected, doc="True if server is connected") - - + def __init__(self, address='127.0.0.1', port=5489): + super(JsonServer, self).__init__(address, port) + self._bind() + + def _bind(self): + self.socket.bind((self.address, self.port)) + + def _listen(self): + self.socket.listen(1) + + def _accept(self): + return self.socket.accept() + + def accept_connection(self): + self._listen() + self.conn, addr = self._accept() + self.conn.settimeout(self.timeout) + logger.debug("connection accepted, conn socket (%s,%d,%d)" % (addr[0], addr[1], self.conn.gettimeout())) + + def _is_connected(self): + try: + return (self.conn is not None) and (self.conn is not self.socket) and (self.conn.fileno() != -1) + except Exception: + return False + + connected = property(_is_connected, doc="True if server has an active client connection") + + class JsonClient(JsonSocket): - def __init__(self, address='127.0.0.1', port=5489): - super(JsonClient, self).__init__(address, port) - - def connect(self): - for i in range(10): - try: - self.socket.connect( (self.address, self.port) ) - except socket.error as msg: - logger.error("SockThread Error: %s" % msg) - time.sleep(3) - continue - logger.info("...Socket Connected") - return True - return False + def __init__(self, address='127.0.0.1', port=5489): + super(JsonClient, self).__init__(address, port) + + def connect(self): + for i in range(10): + try: + self.socket.connect((self.address, self.port)) + except socket.error as msg: + logger.error("SockThread Error: %s" % msg) + time.sleep(3) + continue + logger.info("...Socket Connected") + return True + return False if __name__ == "__main__": - """ basic json echo server """ - import threading - logger.setLevel(logging.DEBUG) - FORMAT = '[%(asctime)-15s][%(levelname)s][%(module)s][%(funcName)s] %(message)s' - logging.basicConfig(format=FORMAT) - - def server_thread(): - logger.debug("starting JsonServer") - server = JsonServer() - server.accept_connection() - while 1: - try: - msg = server.read_obj() - logger.info("server received: %s" % msg) - server.send_obj(msg) - except socket.timeout as e: - logger.debug("server socket.timeout: %s" % e) - continue - except Exception as e: - logger.error("server: %s" % e) - break - - server.close() - - t = threading.Timer(1,server_thread) - t.start() - - time.sleep(2) - logger.debug("starting JsonClient") - - client = JsonClient() - client.connect() - - i = 0 - while i < 10: - client.send_obj({"i": i}) - try: - msg = client.read_obj() - logger.info("client received: %s" % msg) - except socket.timeout as e: - logger.debug("client socket.timeout: %s" % e) - continue - except Exception as e: - logger.error("client: %s" % e) - break - i = i + 1 - - client.close() + """ basic json echo server """ + import threading + logger.setLevel(logging.DEBUG) + FORMAT = '[%(asctime)-15s][%(levelname)s][%(module)s][%(funcName)s] %(message)s' + logging.basicConfig(format=FORMAT) + + def server_thread(): + logger.debug("starting JsonServer") + server = JsonServer() + server.accept_connection() + while 1: + try: + msg = server.read_obj() + logger.info("server received: %s" % msg) + server.send_obj(msg) + except socket.timeout as e: + logger.debug("server socket.timeout: %s" % e) + continue + except Exception as e: + logger.error("server: %s" % e) + break + + server.close() + + t = threading.Timer(1, server_thread) + t.start() + + time.sleep(2) + logger.debug("starting JsonClient") + + client = JsonClient() + client.connect() + + i = 0 + while i < 10: + client.send_obj({"i": i}) + try: + msg = client.read_obj() + logger.info("client received: %s" % msg) + except socket.timeout as e: + logger.debug("client socket.timeout: %s" % e) + continue + except Exception as e: + logger.error("client: %s" % e) + break + i = i + 1 + + client.close() diff --git a/jsocket/tserver.py b/jsocket/tserver.py index 2d7cc43..bef7250 100644 --- a/jsocket/tserver.py +++ b/jsocket/tserver.py @@ -1,204 +1,224 @@ """ @namespace tserver - Contains ThreadedServer, ServerFactoryThread and ServerFactory implementations. + Contains ThreadedServer, ServerFactoryThread and ServerFactory implementations. """ -__author__ = "Christopher Piekarski" -__email__ = "chris@cpiekarski.com" +__author__ = "Christopher Piekarski" +__email__ = "chris@cpiekarski.com" __copyright__= """ - This file is part of the jsocket package. - Copyright (C) 2011 by - Christopher Piekarski + This file is part of the jsocket package. + Copyright (C) 2011 by + Christopher Piekarski - The tserver module is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. + The tserver module is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - The jsocket package is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. + The jsocket package is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. - You should have received a copy of the GNU General Public License - along with tserver module. If not, see .""" -__version__ = "1.0.2" + You should have received a copy of the GNU General Public License + along with tserver module. If not, see .""" +__version__ = "1.0.2" import jsocket.jsocket_base as jsocket_base import threading import socket import time import logging +import abc +from typing import Optional logger = logging.getLogger("jsocket.tserver") -class ThreadedServer(threading.Thread, jsocket_base.JsonServer): - def __init__(self, **kwargs): - threading.Thread.__init__(self) - jsocket_base.JsonServer.__init__(self, **kwargs) - self._isAlive = False - - def _process_message(self, obj): - """ Pure Virtual Method - - This method is called every time a JSON object is received from a client - - @param obj JSON "key: value" object received from client - @retval None or a response object - """ - pass - - def run(self): - while self._isAlive: - try: - self.accept_connection() - except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) - continue - except Exception as e: - logger.exception(e) - continue - - while self._isAlive: - try: - obj = self.read_obj() - resp_obj = self._process_message(obj) - if resp_obj is not None: - logger.debug("message has a response") - self.send_obj(resp_obj) - except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) - continue - except Exception as e: - logger.exception(e) - self._close_connection() - break - self._close_socket() - - def start(self): - """ Starts the threaded server. - The newly living know nothing of the dead - - @retval None - """ - self._isAlive = True - super(ThreadedServer, self).start() - logger.debug("Threaded Server has been started.") - - def stop(self): - """ Stops the threaded server. - The life of the dead is in the memory of the living - - @retval None - """ - self._isAlive = False - logger.debug("Threaded Server has been stopped.") - -class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket): - def __init__(self, **kwargs): - threading.Thread.__init__(self, **kwargs) - jsocket_base.JsonSocket.__init__(self, **kwargs) - self._isAlive = False - - def swap_socket(self, new_sock): - """ Swaps the existing socket with a new one. Useful for setting socket after a new connection. - - @param new_sock socket to replace the existing default jsocket.JsonSocket object - @retval None - """ - del self.socket - self.socket = new_sock - self.conn = self.socket - - def run(self): - """ Should exit when client closes socket conn. - Can force an exit with force_stop. - """ - while self._isAlive: - try: - obj = self.read_obj() - resp_obj = self._process_message(obj) - if resp_obj is not None: - logger.debug("message has a response") - self.send_obj(resp_obj) - except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) - continue - except Exception as e: - logger.info("client connection broken, exit and close connection socket") - self._isAlive = False - break - self._close_connection() - - def start(self): - """ Starts the factory thread. - The newly living know nothing of the dead - - @retval None - """ - self._isAlive = True - super(ServerFactoryThread, self).start() - logger.debug("ServerFactoryThread has been started.") - - def force_stop(self): - """ Force stops the factory thread. - Should exit when client socket is closed under normal conditions. - The life of the dead is in the memory of the living. - - @retval None - """ - self._isAlive = False - logger.debug("ServerFactoryThread has been stopped.") - - + +class ThreadedServer(threading.Thread, jsocket_base.JsonServer, metaclass=abc.ABCMeta): + def __init__(self, **kwargs): + threading.Thread.__init__(self) + jsocket_base.JsonServer.__init__(self, **kwargs) + self._isAlive = False + + @abc.abstractmethod + def _process_message(self, obj) -> Optional[dict]: + """Pure Virtual Method + + This method is called every time a JSON object is received from a client + + @param obj JSON "key: value" object received from client + @retval None or a response object + """ + # Return None in the base class to satisfy linters; subclasses should override. + return None + + def run(self): + while self._isAlive: + try: + self.accept_connection() + except socket.timeout as e: + logger.debug("socket.timeout: %s" % e) + continue + except Exception as e: + logger.exception(e) + continue + + while self._isAlive: + try: + obj = self.read_obj() + resp_obj = self._process_message(obj) + if resp_obj is not None: + logger.debug("message has a response") + self.send_obj(resp_obj) + except socket.timeout as e: + logger.debug("socket.timeout: %s" % e) + continue + except Exception as e: + logger.exception(e) + self._close_connection() + break + self._close_socket() + + def start(self): + """ Starts the threaded server. + The newly living know nothing of the dead + + @retval None + """ + self._isAlive = True + super(ThreadedServer, self).start() + logger.debug("Threaded Server has been started.") + + def stop(self): + """ Stops the threaded server. + The life of the dead is in the memory of the living + + @retval None + """ + self._isAlive = False + logger.debug("Threaded Server has been stopped.") + + +class ServerFactoryThread(threading.Thread, jsocket_base.JsonSocket, metaclass=abc.ABCMeta): + def __init__(self, **kwargs): + threading.Thread.__init__(self, **kwargs) + jsocket_base.JsonSocket.__init__(self, **kwargs) + self._isAlive = False + + def swap_socket(self, new_sock): + """ Swaps the existing socket with a new one. Useful for setting socket after a new connection. + + @param new_sock socket to replace the existing default jsocket.JsonSocket object + @retval None + """ + del self.socket + self.socket = new_sock + self.conn = self.socket + + def run(self): + """ Should exit when client closes socket conn. + Can force an exit with force_stop. + """ + while self._isAlive: + try: + obj = self.read_obj() + resp_obj = self._process_message(obj) + if resp_obj is not None: + logger.debug("message has a response") + self.send_obj(resp_obj) + except socket.timeout as e: + logger.debug("socket.timeout: %s" % e) + continue + except Exception as e: + logger.info("client connection broken, exit and close connection socket") + self._isAlive = False + break + self._close_connection() + + @abc.abstractmethod + def _process_message(self, obj) -> Optional[dict]: + """Pure Virtual Method - Implementer must define protocol + + @param obj JSON "key: value" object received from client + @retval None or a response object + """ + # Return None in the base class to satisfy linters; subclasses should override. + return None + + def start(self): + """ Starts the factory thread. + The newly living know nothing of the dead + + @retval None + """ + self._isAlive = True + super(ServerFactoryThread, self).start() + logger.debug("ServerFactoryThread has been started.") + + def force_stop(self): + """ Force stops the factory thread. + Should exit when client socket is closed under normal conditions. + The life of the dead is in the memory of the living. + + @retval None + """ + self._isAlive = False + logger.debug("ServerFactoryThread has been stopped.") + + class ServerFactory(ThreadedServer): - def __init__(self, server_thread, **kwargs): - ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port']) - if not issubclass(server_thread, ServerFactoryThread): - raise TypeError("serverThread not of type", ServerFactoryThread) - self._thread_type = server_thread - self._threads = [] - self._thread_args = kwargs - self._thread_args.pop('address', None) - self._thread_args.pop('port', None) - - def run(self): - while self._isAlive: - tmp = self._thread_type(**self._thread_args) - self._purge_threads() - while not self.connected and self._isAlive: - try: - self.accept_connection() - except socket.timeout as e: - logger.debug("socket.timeout: %s" % e) - continue - except Exception as e: - logger.exception(e) - continue - else: - tmp.swap_socket(self.conn) - tmp.start() - self._threads.append(tmp) - break - - self._wait_to_exit() - self.close() - - def stop_all(self): - for t in self._threads: - if t.is_alive(): - t.force_stop() - t.join() - - def _purge_threads(self): - for t in self._threads: - if not t.is_alive(): - self._threads.remove(t) - - def _wait_to_exit(self): - while self._get_num_of_active_threads(): - time.sleep(0.2) - - def _get_num_of_active_threads(self): - return len([True for x in self._threads if x.is_alive()]) - - active = property(_get_num_of_active_threads, doc="number of active threads") + def __init__(self, server_thread, **kwargs): + ThreadedServer.__init__(self, address=kwargs['address'], port=kwargs['port']) + if not issubclass(server_thread, ServerFactoryThread): + raise TypeError("serverThread not of type", ServerFactoryThread) + self._thread_type = server_thread + self._threads = [] + self._thread_args = kwargs + self._thread_args.pop('address', None) + self._thread_args.pop('port', None) + + def _process_message(self, obj) -> Optional[dict]: + """ServerFactory does not process messages itself.""" + return None + + def run(self): + while self._isAlive: + tmp = self._thread_type(**self._thread_args) + self._purge_threads() + while not self.connected and self._isAlive: + try: + self.accept_connection() + except socket.timeout as e: + logger.debug("socket.timeout: %s" % e) + continue + except Exception as e: + logger.exception(e) + continue + else: + tmp.swap_socket(self.conn) + tmp.start() + self._threads.append(tmp) + break + + self._wait_to_exit() + self.close() + + def stop_all(self): + for t in self._threads: + if t.is_alive(): + t.force_stop() + t.join() + + def _purge_threads(self): + for t in self._threads: + if not t.is_alive(): + self._threads.remove(t) + + def _wait_to_exit(self): + while self._get_num_of_active_threads(): + time.sleep(0.2) + + def _get_num_of_active_threads(self): + return len([True for x in self._threads if x.is_alive()]) + + active = property(_get_num_of_active_threads, doc="number of active threads") diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..09e1af7 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +behave>=1.2.6 +pytest>=7.0 +pytest-timeout>=2.1 diff --git a/scripts/smoke_test.py b/scripts/smoke_test.py new file mode 100644 index 0000000..40d8176 --- /dev/null +++ b/scripts/smoke_test.py @@ -0,0 +1,55 @@ +import logging +import time +import threading + +import jsocket + + +logger = logging.getLogger("smoke") +logger.setLevel(logging.DEBUG) +logging.basicConfig(level=logging.DEBUG, format='[%(levelname)s] %(message)s') + + +class MyServer(jsocket.ThreadedServer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.timeout = 2.0 + self.isConnected = False + + def _process_message(self, obj): + if obj != '': + if obj.get('message') == 'new connection': + logger.info("server: new connection message") + self.isConnected = True + + +def main(): + server = MyServer() + server.start() + time.sleep(0.5) + + client = jsocket.JsonClient() + assert client.connect(), "client could not connect" + + # Scenario: Start Server + client.send_obj({"message": "new connection"}) + # Give server time to process + time.sleep(0.2) + assert server.isConnected is True, "server did not observe connection" + + # Scenario: Server Response + server.send_obj({"message": "welcome"}) + msg = client.read_obj() + assert msg == {"message": "welcome"}, f"unexpected message: {msg}" + + # Scenario: Stop Server + server.stop() + server.join(timeout=3) + assert not server._isAlive, "server thread still alive" + client.close() + logger.info("smoke test OK") + + +if __name__ == "__main__": + main() + diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..d4cd431 --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,59 @@ +import time +import socket +import pytest + +import jsocket + + +class EchoServer(jsocket.ThreadedServer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.timeout = 2.0 + self.isConnected = False + + def _process_message(self, obj): + if obj != '': + if obj.get('message') == 'new connection': + self.isConnected = True + # echo back if present + if 'echo' in obj: + return obj + return None + + +@pytest.mark.timeout(10) +def test_end_to_end_echo_and_connection(): + try: + server = EchoServer(address='127.0.0.1', port=0) + except PermissionError as e: + pytest.skip(f"Socket creation blocked: {e}") + + # Discover the ephemeral port chosen by the OS + _, port = server.socket.getsockname() + server.start() + + try: + client = jsocket.JsonClient(address='127.0.0.1', port=port) + assert client.connect() is True + + # Signal connection and wait briefly for server to process + client.send_obj({"message": "new connection"}) + time.sleep(0.2) + assert server.isConnected is True + + # Echo round-trip + payload = {"echo": "hello", "i": 1} + client.send_obj(payload) + + # Server only echoes when _process_message returns; give it a moment + # and then read the response + echoed = client.read_obj() + assert echoed == payload + finally: + # Cleanup + try: + client.close() + except Exception: + pass + server.stop() + server.join(timeout=3) From b6d07ccee5a42c800cf92fd6e70e91edd8a2b552 Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 21 Aug 2025 18:48:37 -0600 Subject: [PATCH 2/3] WIP adding CI/CD to repo --- jsocket/jsocket_base.py | 2 +- jsocket/tserver.py | 8 +++-- setup.py | 2 +- tests/test_listener_persistence.py | 56 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 tests/test_listener_persistence.py diff --git a/jsocket/jsocket_base.py b/jsocket/jsocket_base.py index 3e7e3ef..87cf47b 100644 --- a/jsocket/jsocket_base.py +++ b/jsocket/jsocket_base.py @@ -20,7 +20,7 @@ You should have received a copy of the GNU General Public License along with jsocket_base module. If not, see .""" -__version__ = "1.0.2" +__version__ = "1.0.3" import json import socket diff --git a/jsocket/tserver.py b/jsocket/tserver.py index bef7250..626c180 100644 --- a/jsocket/tserver.py +++ b/jsocket/tserver.py @@ -21,7 +21,7 @@ You should have received a copy of the GNU General Public License along with tserver module. If not, see .""" -__version__ = "1.0.2" +__version__ = "1.0.3" import jsocket.jsocket_base as jsocket_base import threading @@ -77,7 +77,11 @@ def run(self): logger.exception(e) self._close_connection() break - self._close_socket() + # Ensure sockets are cleaned up when the server stops + try: + self.close() + except Exception: + pass def start(self): """ Starts the threaded server. diff --git a/setup.py b/setup.py index 89f1f91..393adf2 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup, Extension setup(name='jsocket', - version='1.9.3', + version='1.9.4', description='Python JSON Server & Client', author='Christopher Piekarski', author_email='chris@cpiekarski.com', diff --git a/tests/test_listener_persistence.py b/tests/test_listener_persistence.py new file mode 100644 index 0000000..c5ec80f --- /dev/null +++ b/tests/test_listener_persistence.py @@ -0,0 +1,56 @@ +import time +import pytest +import socket + +import jsocket + + +class EchoServer(jsocket.ThreadedServer): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.timeout = 1.0 + + def _process_message(self, obj): + # Echo round-trip for verification + if isinstance(obj, dict) and 'echo' in obj: + return obj + return None + + +@pytest.mark.timeout(10) +def test_server_accepts_multiple_clients_sequentially(): + """Regression test: listener remains open after a client disconnects.""" + try: + server = EchoServer(address='127.0.0.1', port=0) + except PermissionError as e: + pytest.skip(f"Socket creation blocked: {e}") + + # OS picks an ephemeral port + _, port = server.socket.getsockname() + server.start() + + try: + # First client lifecycle + c1 = jsocket.JsonClient(address='127.0.0.1', port=port) + assert c1.connect() is True + payload1 = {"echo": "one"} + c1.send_obj(payload1) + echoed1 = c1.read_obj() + assert echoed1 == payload1 + c1.close() + + # Give the server a brief moment to recycle the connection + time.sleep(0.2) + + # Second client should connect and communicate without server restart + c2 = jsocket.JsonClient(address='127.0.0.1', port=port) + assert c2.connect() is True + payload2 = {"echo": "two"} + c2.send_obj(payload2) + echoed2 = c2.read_obj() + assert echoed2 == payload2 + c2.close() + finally: + server.stop() + server.join(timeout=3) + From 680e178c9ed5ff6d4d6e3914fb118458c8114eaf Mon Sep 17 00:00:00 2001 From: Chris Piekarski Date: Thu, 21 Aug 2025 18:55:31 -0600 Subject: [PATCH 3/3] error fix --- features/steps/steps.py | 73 +++++++++++++++++++++++++++++++++++------ jsocket/jsocket_base.py | 2 +- jsocket/tserver.py | 16 +++++++-- setup.py | 2 +- 4 files changed, 78 insertions(+), 15 deletions(-) diff --git a/features/steps/steps.py b/features/steps/steps.py index ffd294d..e32749c 100644 --- a/features/steps/steps.py +++ b/features/steps/steps.py @@ -1,5 +1,6 @@ import json import logging +import time from behave import given, when, then, use_step_matcher import jsocket @@ -9,9 +10,15 @@ use_step_matcher("re") +# Persist server/client across scenarios for this feature file +SERVER = None +CLIENT = None +SERVER_PORT = None + + class MyServer(jsocket.ThreadedServer): - def __init__(self): - super(MyServer, self).__init__() + def __init__(self, **kwargs): + super(MyServer, self).__init__(**kwargs) self.timeout = 2.0 self.isConnected = False @@ -23,14 +30,30 @@ def _process_message(self, obj): @given(r"I start the server") def start_server(context): - context.jsonserver = MyServer() - context.jsonserver.start() + global SERVER + global SERVER_PORT + # Start a fresh server if one is not present or not alive + if SERVER is None or not getattr(SERVER, '_isAlive', False): + # Bind to an ephemeral port to avoid port conflicts + SERVER = MyServer(address='127.0.0.1', port=0) + SERVER.start() + # Discover the chosen port + _, SERVER_PORT = SERVER.socket.getsockname() + context.jsonserver = SERVER @given(r"I connect the client") def connect_client(context): - context.jsonclient = jsocket.JsonClient() - context.jsonclient.connect() + global CLIENT + global SERVER_PORT + if CLIENT is None: + # Ensure we connect to the server's actual port + if SERVER_PORT is None: + start_server(context) + CLIENT = jsocket.JsonClient(address='127.0.0.1', port=SERVER_PORT) + if not CLIENT.connect(): + raise AssertionError("client could not connect to server") + context.jsonclient = CLIENT @when(r"the client sends the object (\{.*\})") @@ -40,6 +63,13 @@ def client_sends_object(context, obj): @when(r"the server sends the object (\{.*\})") def server_sends_object(context, obj): + # Ensure a running server and a connected client exist + start_server(context) + connect_client(context) + # Wait briefly until the server has an accepted connection + t0 = time.time() + while not context.jsonserver.connected and (time.time() - t0) < 2.0: + time.sleep(0.05) context.jsonserver.send_obj(json.loads(obj)) @@ -52,20 +82,43 @@ def client_sees_message(context, obj): @then(r"I see a connection") def see_connection(context): + # Give the server a short time to process the prior message + t0 = time.time() + while not context.jsonserver.isConnected and (time.time() - t0) < 2.0: + time.sleep(0.05) assert context.jsonserver.isConnected is True, "%s" % False @given(r"I stop the server") def stop_server(context): - context.jsonserver.stop() + global SERVER + server = SERVER if SERVER is not None else getattr(context, 'jsonserver', None) + if server is not None: + server.stop() + # Give the thread a moment to terminate cleanly + try: + server.join(timeout=2.0) + except Exception: + pass + SERVER = server @then(r"I see a stopped server") def see_stopped_server(context): - assert context.jsonserver._isAlive is False, "%s" % False + # Prefer context server, fall back to global + server = getattr(context, 'jsonserver', None) + if server is None: + server = SERVER + assert server is not None and server._isAlive is False, "%s" % False @then(r"I close the client") def close_client(context): - context.jsonclient.close() - + global CLIENT + client = CLIENT if CLIENT is not None else getattr(context, 'jsonclient', None) + if client is not None: + try: + client.close() + except Exception: + pass + CLIENT = client diff --git a/jsocket/jsocket_base.py b/jsocket/jsocket_base.py index 87cf47b..3e7e3ef 100644 --- a/jsocket/jsocket_base.py +++ b/jsocket/jsocket_base.py @@ -20,7 +20,7 @@ You should have received a copy of the GNU General Public License along with jsocket_base module. If not, see .""" -__version__ = "1.0.3" +__version__ = "1.0.2" import json import socket diff --git a/jsocket/tserver.py b/jsocket/tserver.py index 626c180..f22918a 100644 --- a/jsocket/tserver.py +++ b/jsocket/tserver.py @@ -21,7 +21,7 @@ You should have received a copy of the GNU General Public License along with tserver module. If not, see .""" -__version__ = "1.0.3" +__version__ = "1.0.2" import jsocket.jsocket_base as jsocket_base import threading @@ -60,7 +60,12 @@ def run(self): logger.debug("socket.timeout: %s" % e) continue except Exception as e: - logger.exception(e) + # Avoid noisy error logs during normal shutdown/sequencing + if self._isAlive: + logger.debug("accept_connection error: %s", e) + else: + logger.debug("server stopping; accept loop exiting") + break continue while self._isAlive: @@ -74,7 +79,12 @@ def run(self): logger.debug("socket.timeout: %s" % e) continue except Exception as e: - logger.exception(e) + # Treat client disconnects as normal; keep logs at info/debug + msg = str(e) + if isinstance(e, RuntimeError) and 'socket connection broken' in msg: + logger.info("client connection broken, closing connection") + else: + logger.debug("handler error: %s", e) self._close_connection() break # Ensure sockets are cleaned up when the server stops diff --git a/setup.py b/setup.py index 393adf2..89f1f91 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ from setuptools import setup, Extension setup(name='jsocket', - version='1.9.4', + version='1.9.3', description='Python JSON Server & Client', author='Christopher Piekarski', author_email='chris@cpiekarski.com',