text: import zlib
  import socket
  import struct
  from cryptography.fernet import Fernet
  
  from time import sleep
  from threading import Thread, ThreadError
  
  
  
  __version__ = '0.1.1'
  
  
  class SocketUtils:
      class PacketError(OSError): ...
  
  
      @classmethod
      def send_data(cls, _socket: socket.socket, data: bytes, compressed=True, key=None) -> None:
          if type(data) != bytes:
              raise TypeError('data argument must be bytes')
          
          if key != None:
              f = Fernet(key)
              data = f.encrypt(data)
  
          if compressed:
              data = zlib.compress(data)
  
          header = struct.pack('ibb', len(data), compressed, bool(key))
          packet = header + data
  
          _socket.sendall(packet)
  
  
      @classmethod
      def recv_data(cls, _socket: socket.socket, key=None) -> bytes:
          data_size, compressed, encrypted = struct.unpack('ibb', _socket.recv(6))
  
          if encrypted:
              if key != None:
                  f = Fernet(key)
              else:
                  raise cls.PacketError('missing key to decrypt data')
          
          data = _socket.recv(data_size)
  
          if compressed:
              data = zlib.decompress(data)
  
          if encrypted:
              data = f.decrypt(data)
  
          return data
  
  
  
  class Client:
      def __init__(self, address: tuple, _socket=None, socket_timeout=None):
          self.address = address
  
          if _socket != None:
              if isinstance(_socket, socket.socket):
                  self.socket = _socket
              else:
                  raise TypeError('_socket argument must be an instance of socket.socket')
          else:
              self.connect(address)
              
          # (optional) set a timeout for the socket
          if socket_timeout != None:
              self.socket.settimeout(socket_timeout)
          
  
      def connect(self, server_address: tuple) -> None:
          self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
          self.socket.connect(server_address)
  
  
      def close(self) -> None:
          self.socket.close()
  
  
      def send(self, **kwargs) -> None:
          # send data using the socket self.s
          SocketUtils.send_data(self.socket, **kwargs)
  
  
      def recv(self, **kwargs) -> bytes:
          # receieve data using the socket self.s
          return SocketUtils.recv_data(self.socket **kwargs)
  
  
  
  class Server:
      def __init__(self, server_address: tuple, max_clients: int, **kwargs):
          # setup the server socket
          self.s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
          self.s.bind(server_address)
          self.s.listen(kwargs.get('backlog', 1))
  
          # set max client connections and create clients pool and queue
          self.address = server_address
          self._max_clients = max_clients
          self._clients_pool = []
          self._clients_queue = []
  
          # create a list for banned ip addresses
          self.banned_clients = []
  
          # set settings from kwargs
          self.accepting_clients = kwargs.get('accepting_clients', True)
          self.disconnect_at_timeout = kwargs.get('disconnect_at_timeout', False)
  
          self.client_listener = None
  
      
      def listen(self, callback=None, *callback_args) -> None:
          # if client_listener is None create daemon thread to be run in background
          if self.client_listener is None:
              self.client_listener = Thread(
                  target=self._listenForClients,
                  args=(callback, *callback_args),
                  daemon=True
              )
              
          if self.client_listener.is_alive:
              raise ThreadError('client_listener thread is already running')
          else:
              self.client_listener.start()
  
  
      def _listenForClients(self, callback=None, *callback_args) -> None:
          while True:
              # if the flag accepting_clients is True then enter this loop
              while self.accepting_clients:
  
                  # accept a new client and create a client instance
                  clientsocket, address = self.s.accept()
                  if not address[0] in self.banned_clients:
                      client = Client(address, clientsocket)
  
                      if not self.accepting_clients:
                          break
  
                      # add new client to the queue
                      self._update_client_queue(client)
  
                      if callback != None:
                          if callable(callback):
                              callback(client=client, *callback_args)
                          else:
                              raise TypeError('%s type is not callable' % type(callback))
  
                      del(client)
  
                  else: clientsocket.close()
                  del(clientsocket, address)
              
              # while server is not accepting clients wait 0.01 seconds for each time it checks
              sleep(0.01)
  
  
      def _update_client_queue(self, client=None) -> None:
          if isinstance(client, Client):
              self._clients_queue.append(client)
  
          while len(self._clients_pool) < self._max_clients:
              if len(self._clients_queue) > 0:
                  self._clients_pool.append(self._clients_queue[0])
                  self._clients_queue.pop(0)
              else:
                  break
  
  
      def _get_clientIndex(self, client_address: tuple) -> int:
          # go through each client in the pool and return the index that matches the address 
          for index, client in enumerate(self._clients_pool):
              if client_address == client.address:
                  return index
          raise IndexError(f'{client_address} was not found in self.client_pool')
  
  
      def update_max_clients(self, max_clients: int) -> None:
          self._max_clients = max_clients
          self._update_client_queue()
  
  
      def get_client(self, client_address: tuple) -> Client:
          return self._clients_pool[self._get_clientIndex(client_address)]
  
  
      def get_all_clients(self) -> list:
          return self._clients_pool
  
  
      def valid_address(self, ipv4_address: str, port: int) -> bool:
          split_ip = ipv4_address.split('.')
          if len(split_ip) != 4:
              return False
          
          for i in split_ip:
              if not i.isdigit():
                  return False
              elif not 1 < i < 255:
                  return False
          
          if type(port) != int:
              return False
  
          if not 0 < port < 65535:
              return False
  
          return True
  
  
      def ban_ip_address(self, ipv4_address: str) -> None:
          if not self.valid_address(ipv4_address, None):
              raise TypeError('%s is not a valid address' % ipv4_address)
          
          if ipv4_address in self.banned_clients:
              raise IndexError('%s is already banned' % ipv4_address)
  
          for client in self.get_all_clients():
              if client.address[0] == ipv4_address:
                  self.remove_client(client.address)
          
          self.banned_clients.append(ipv4_address)
  
  
      def unban_ip_address(self, ipv4_address: str) -> None:
          if not self.valid_address(ipv4_address, None):
              raise TypeError('%s is not a valid address' % ipv4_address)
          
          if ipv4_address in self.banned_clients:
              raise IndexError('%s is not banned' % ipv4_address)
  
          self.banned_clients.remove(ipv4_address)
  
  
      def remove_client(self, client_address: tuple, ban_ip=False) -> None:
          # get client index that matches the client_address parameter from client pool
          client_index = self._get_clientIndex(client_address)
  
          # try to disconnect the client
          try:
              self._clients_pool[client_index].socket.close()
  
          except Exception:
              pass
  
          # remove client from the pool
          self._clients_pool.pop(client_index)
          
          # update the client queue so that more clients may connect
          self._update_client_queue()
  
          if ban_ip:
              self.ban_ip_address(client_address[0])
  
  
      def send_data(self, client_address: tuple, **kwargs) -> None:
          # get the socket of the client in self._clients_pool matching the address
          socket = self._clients_pool[
              self._get_clientIndex(client_address)
          ].socket
  
          # try to send the data to client
          try:
              SocketUtils.send_data(socket, **kwargs)
  
          # if the client connection is broken remove the client
          except ConnectionError:
              self.remove_client(client_address)
  
          # if the client times out check the internal flag for disconnecting on timeout
          except socket.timeout:
              if self.disconnect_at_timeout:
                  self.remove_client(client_address)
  
  
      def recv_data(self, client_address: tuple, **kwargs) -> any:
          # get the socket of the client in self._clients_pool matching the address
          socket = self._clients_pool[
              self._get_clientIndex(client_address)
          ].socket
  
          # try to recv data from client
          try:
              return SocketUtils.recv_data(socket, **kwargs)
  
          # if the client connection is broken remove the client
          except ConnectionError:
              self.remove_client(client_address)
              return False
          
          # if the client times out check the internal flag for disconnecting on timeout
          except socket.timeout:
              if self.disconnect_at_timeout:
                  self.remove_client(client_address)