{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<p>\n",
    "<font size='5' face='Georgia, Arial'>IIC-2233 Apunte Programación Avanzada</font><br>\n",
    "<font size='1'> Modificado en 2019-1 al 2026-1 por Equipo Docente IIC2233. </font>\n",
    "</p>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Tabla de contenidos\n",
    "\n",
    "1. [Envío de datos](#Envío-de-datos)\n",
    "   1. [Envío de datos con `JSON`](#Envío-de-datos-con-JSON)\n",
    "   2. [Envío de datos con `pickle`](#Envío-de-datos-con-pickle)\n",
    "   3. [Envío de muchos datos](#Envío-de-muchos-datos)\n",
    "2. [Ejemplo completo: servidor con manejo de múltiples clientes en forma concurrente](#Servidor-con-manejo-de-múltiples-clientes-en-forma-concurrente)\n",
    "3. [Ejemplo completo: cliente conectado a más de un servidor](#Cliente-conectado-a-más-de-un-servidor)\n",
    "4. [Ejemplo completo: Interacción entre clientes P2P](#Interacción-entre-clientes-_Peer-2-Peer_-(P2P))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Ejemplos de _networking_ con Python\n",
    "\n",
    "**IMPORTANTE: El siguiente material incluye ejemplos que deberían ser ejecutados en programas separados, donde uno actúa como el cliente, y otro actúa como el servidor. Para ello, copie los códigos correspondientes en archivos .py en su computador y ejecútelo en consolas separadas. Si ejecuta los códigos directamente en este jupyter-notebook los programas se pueden quedar pegados esperando una conexión.**\n",
    "\n",
    "En este _notebook_, veremos ejemplos de distintas aplicaciones de Networking con Python."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Envío de datos\n",
    "\n",
    "En el siguiente ejemplo, veremos algunas variaciones de envíos de datos.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Envío de datos con JSON\n",
    "\n",
    "**Intenta hacer la prueba con dos terminales distintas: en uno ejecuta el código de la siguiente celda con la definición del servidor, y en el otro la celda subsiguiente con la definición del cliente que envía los datos JSON**.\n",
    "\n",
    "El primer ejemplo corresponde a cómo programar un servidor que reciba datos y los envíe de vuelta al cliente, lo que se conoce como un _echo server_.\n",
    "```python\n",
    "# Implementación del servidor que recibe datos y los envía de vuelta.\n",
    "# Esto comúnmente se denomina como 'echo server'.\n",
    "\n",
    "import socket\n",
    "\n",
    "\n",
    "host = 'localhost'   # Usar nuestro computador como host\n",
    "port = 12345\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "sock.bind((host, port))\n",
    "print(\"Listo para recibir. Ahora voy a esperar que me hablen...\")\n",
    "sock.listen()\n",
    "\n",
    "sock_cliente, (host_cliente, puerto_cliente) = sock.accept()\n",
    "print(\"Conexión desde\", host_cliente, puerto_cliente)\n",
    "\n",
    "while True:\n",
    "    data = sock_cliente.recv(4096)\n",
    "    print(f\"Recibí estos bytes: {data}\")\n",
    "    if not data:\n",
    "        break\n",
    "    print(\"Los enviaré de vuelta\")\n",
    "    sock_cliente.sendall(data)\n",
    "\n",
    "sock_cliente.close()\n",
    "sock.close()\n",
    "```\n",
    "\n",
    "El cliente envía datos JSON y los imprime una vez que el servidor se los envía de vuelta. Luego cierra la conexión.\n",
    "```python\n",
    "# Implementación del cliente que envía los datos en formato JSON.\n",
    "# Pon atención en la serialización y transformación a bytes.\n",
    "\n",
    "import json\n",
    "import socket\n",
    "\n",
    "\n",
    "server_host = ''  # Debemos poner aquí la dirección IP del servidor.\n",
    "# Si no ponemos nada, supone que estamos hablando con un programa en el mismo host.\n",
    "server_port = 12345\n",
    "\n",
    "# Generamos la información que enviaremos; en este caso, es un simple diccionario.\n",
    "data = {1: \"Hola\", 2: \"Chao\"}\n",
    "mensaje = json.dumps(data)\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "sock.connect((server_host, server_port))\n",
    "\n",
    "# Debemos enviar bytes, por lo que codificamos el mensaje.\n",
    "sock.sendall(mensaje.encode('utf-8'))\n",
    "\n",
    "# Decodificamos los bytes de la respuesta y luego los deserializamos con JSON.\n",
    "data = json.loads(sock.recv(4096).decode('utf-8'))\n",
    "print(data)\n",
    "input(\"ENTER para terminar.\")\n",
    "sock.close()\n",
    "```\n",
    "\n",
    "Notemos que cuando el cliente termina, la conexión con el servidor se cierra. Esto se traduce en que el servidor dejará de recibir datos y, de acuerdo a cómo está construido, también termina. Si queremos que el servidor quede disponible para recibir otras conexiones, debemos volver a ejecutar `listen`.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Envío de datos con `pickle`\n",
    "\n",
    "Al igual que el ejemplo anterior, podemos enviar cualquier objeto de Python serializado con `pickle`. El siguiente código muestra un ejemplo de cómo conectarse al servidor anterior y enviarle datos serializados con `pickle`. Cuando los _bytes_ vienen de vuelta desde el servidor lo deserializamos y tenemos nuevamente la instancia de la clase `Persona` que habíamos enviado.\n",
    "\n",
    "Puedes usar este cliente con **el mismo servidor (_echo server_) del ejemplo anterior**. El _echo server_ no se preocupa de qué tipo de datos recibe. Sólo recibe bytes y los manda de vuelta.\n",
    "\n",
    "```python\n",
    "# Implementación del cliente que envía los datos en formato Pickle.\n",
    "# Pon atención en la serialización y transformación a bytes.\n",
    "\n",
    "import pickle\n",
    "import socket\n",
    "\n",
    "\n",
    "server_host = ''  # Debemos poner aquí la dirección IP del servidor.\n",
    "# Si no ponemos nada, supone que estamos hablando con un programa en el mismo host.\n",
    "port = 12345\n",
    "\n",
    "\n",
    "class Persona:\n",
    "    def __init__(self, nombre, correo):\n",
    "        self.nombre = nombre\n",
    "        self.correo = correo\n",
    "\n",
    "\n",
    "# Enviaremos esta instancia de la clase Persona.\n",
    "persona = Persona(\"Juan Pérez\", \"jp@ejemplo.com\")\n",
    "mensaje = pickle.dumps(persona)\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "sock.connect((server_host, port))\n",
    "sock.sendall(mensaje)\n",
    "\n",
    "data = pickle.loads(sock.recv(4096))\n",
    "print(f\"He recibido bytes de vuelta y los deserialicé a {type(data)}\")\n",
    "print(data.nombre)\n",
    "sock.close()\n",
    "```\n",
    "\n",
    "Además, podríamos modificar el código del servidor para que haga alguna acción requerida con los datos que recibe y envíe el resultado de esta acción de vuelta. Por ejemplo, podríamos verificar en el servidor los datos de un cliente para iniciar sesión.\n",
    "\n",
    "**Recomendamos fuertemente, dentro de lo posible, conectar dos computadores y probar enviando hacia y desde ambas partes**, de tal forma de familiarizarse lo más posible con los _sockets_ y disfrutar de los beneficios que nos ofrece la red. Para ello, debes ingresar en la variable _host_, la **dirección IP del servidor** en el caso de los clientes. Los servidores no necesitan modificación.\n",
    "\n",
    "**¿Cómo conseguir la dirección IP de un computador?** El método varía dependiendo de cada sistema operativo, pero usualmente hay interfaces de configuración de la red en las cuales se puede averiguar ese dato. Nada que una búsqueda específica en Internet para tu sistema operativo no pueda resolver. Atención que si preguntas en sitios como [https://whatismyipaddress.com/](whatismyipaddress.com) encontrarás la dirección pública que te asigna tu proveedor de Internet, pero si quieres conectarte con un computador que está en tu misma casa (i.e. en tu misma subred) seguramente la dirección IP será distinta pues se trata de una dirección \"interna\" a tu subred.\n",
    "\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Envío de muchos datos\n",
    "\n",
    "Supongamos que queremos transmitir un archivo relativamente grande entre dos computadores. Lo único que tenemos que hacer es leer el archivo en _bytes_, y transmitirlo con un _socket_. Implementemos el código del emisor y del receptor del archivo.\n",
    "\n",
    "```python\n",
    "# Este es el receptor del archivo.\n",
    "import socket\n",
    "\n",
    "\n",
    "host = 'localhost'\n",
    "port = 12345\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "sock.bind((host, port))\n",
    "sock.listen()\n",
    "print(\"Escuchando...\")\n",
    "\n",
    "# Aceptamos un cliente.\n",
    "sock_cliente, (host_cliente, port_cliente) = sock.accept()\n",
    "print(\"Conexión entrante aceptada.\")\n",
    "\n",
    "# Leemos la información y la guardamos en un archivo.\n",
    "datos = sock_cliente.recv(4096)\n",
    "with open('files/recibido.bin', 'wb') as binfile:\n",
    "    binfile.write(datos)\n",
    "\n",
    "print(\"¡Archivo recibido!\")\n",
    "# Le enviamos una respuesta a la contraparte.\n",
    "sock_cliente.sendall(\"Gracias.\".encode('utf-8'))\n",
    "\n",
    "# Cerramos los sockets.\n",
    "sock_cliente.close()\n",
    "sock.close()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Este es el emisor del archivo.\n",
    "import socket\n",
    "\n",
    "\n",
    "host_receptor = 'localhost'  # Si lo ejecutas en otro computador, aquí debes poner\n",
    "# debes poner su dirección IP\n",
    "puerto_receptor = 12345\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "\n",
    "# Nos conectamos al receptor del archivo, que ya debería estar escuchando.\n",
    "sock.connect((host_receptor, puerto_receptor))\n",
    "print(\"Conexión establecida.\")\n",
    "\n",
    "# Leemos el archivo y lo enviamos.\n",
    "with open('files/enviar.bin', 'rb') as binfile:\n",
    "    datos = binfile.read()\n",
    "    sock.sendall(datos)\n",
    "\n",
    "print(\"¡Archivo enviado!\")\n",
    "\n",
    "# Imprimimos lo que nos responda la contraparte.\n",
    "print(\"Respuesta:\", sock.recv(4096).decode('utf-8'))\n",
    "\n",
    "# Cerramos el socket.\n",
    "sock.close()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Veamos lo que sucede al tener cada celda en un programa distinto.\n",
    "\n",
    "![](imgs/send-large-file.png)\n",
    "\n",
    "Todo parece ir bien pero, si comparamos los archivos, vemos diferencias significativas de tamaño.\n",
    "\n",
    "![](imgs/compare-large-files.png)\n",
    "\n",
    "¡Leímos muy pocos _bytes_! Podríamos cambiar el valor en `recv(buffer)`, pero si hacemos esto seguimos teniendo problemas:\n",
    "\n",
    "- ¿Cómo saber de antemano el tamaño de lo que voy a recibir?\n",
    "- E incluso sabiendo el tamaño del mensaje que voy a recibir, ¿qué ocurre si tengo que recibir _megabytes_ o _gigabytes_ de datos?\n",
    "\n",
    "La solución a este problema tiene dos partes. Primero, tener un **protocolo** o esquema de comunicación para nuestra aplicación de transferencia de archivos. Por ahora, sólo necesitamos una forma de indicar el tamaño del archivo que queremos mandar. Una forma simple es enviar un número que ocupe cuatro _bytes_ para indicar el tamaño del archivo, antes de enviar el archivo en sí. Si usamos 4 _bytes_ como encabezado de todos los mensajes para indicar el largo de estos, podremos enviar mensajes de hasta $2^{32}$ _bytes_ (4 GiB). Esta es una cantidad razonable para la mayoría de los casos.\n",
    "\n",
    "Ya sabiendo el número de _bytes_ que tenemos que leer, tenemos que encargarnos de leer el archivo por _chunks_, hasta completarlo. Tomando eso en cuenta, volvamos a implementar nuestro emisor y receptor de archivos.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Receptor mejorado del archivo.\n",
    "import socket\n",
    "\n",
    "\n",
    "host = 'localhost'\n",
    "puerto = 12345\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "sock.bind((host, puerto))\n",
    "sock.listen()\n",
    "print('Escuchando...')\n",
    "\n",
    "# Aceptamos un cliente.\n",
    "sock_cliente, (host_cliente, puerto_cliente) = sock.accept()\n",
    "print(\"Conexión entrante aceptada.\")\n",
    "\n",
    "# Leemos primero los 4 bytes del tamaño del archivo.\n",
    "# Con esto transformamos una serie de bytes en un int.\n",
    "largo_archivo = int.from_bytes(sock_cliente.recv(4), byteorder='big')\n",
    "datos = bytearray()\n",
    "bytes_leidos = 0\n",
    "print(f\"OK. Ahora sé que debo recibir {largo_archivo} bytes\")\n",
    "\n",
    "# Ahora leemos el archivo por chunks, de máximo 4096 bytes.\n",
    "while len(datos) < largo_archivo:\n",
    "    # El último recv será probablemente más chico que 4096\n",
    "    bytes_leer = min(4096, largo_archivo - len(datos))\n",
    "    datos_recibidos = sock_cliente.recv(bytes_leer)\n",
    "    # Recordemos que el método recv, entrega una cantidad máxima, pero no nos asegura que nos\n",
    "    # entregue los 4096 bytes. Es por esto, que la cantidad de bytes que hemos recibido en\n",
    "    # total, se deben ver siempre en función de lo que retornó el método recv, y no lo que\n",
    "    # le entregamos como parámetro\n",
    "    bytes_leidos += len(datos_recibidos)\n",
    "    print(\n",
    "        f\"He recibido {len(datos_recibidos)} bytes en el último recv. Van {bytes_leidos} en total.\")\n",
    "    datos.extend(datos_recibidos)\n",
    "\n",
    "print(f\"¡Listo! He recibido {len(datos)} bytes\")\n",
    "\n",
    "# Guardamos la información en un archivo.\n",
    "with open('files/recibido.bin', 'wb') as binfile:\n",
    "    binfile.write(datos)\n",
    "\n",
    "print(\"¡Archivo guardado!\")\n",
    "# Le enviamos una respuesta a la contraparte.\n",
    "sock_cliente.sendall(\"Gracias.\".encode('utf-8'))\n",
    "\n",
    "# Cerramos los sockets.\n",
    "sock_cliente.close()\n",
    "sock.close()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Emisor mejorado del archivo.\n",
    "import socket\n",
    "\n",
    "\n",
    "host_receptor = 'localhost'\n",
    "puerto_receptor = 12345\n",
    "\n",
    "sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "\n",
    "# Nos conectamos al receptor del archivo, que ya debería estar escuchando.\n",
    "sock.connect((host_receptor, puerto_receptor))\n",
    "print(\"Conexión establecida.\")\n",
    "\n",
    "# Leemos el archivo y lo enviamos.\n",
    "with open('files/enviar.bin', 'rb') as binfile:\n",
    "    datos = binfile.read()\n",
    "    largo_archivo = len(datos)\n",
    "    print(f\"Voy a enviar {largo_archivo} bytes\")\n",
    "    # 1. Enviar el tamaño del archivo.\n",
    "    # (int.to_bytes transforma un entero en una cantidad de bytes por el primer parámetro)\n",
    "    sock.sendall(largo_archivo.to_bytes(4, byteorder='big'))\n",
    "\n",
    "    # 2. Enviar el archivo. Para esto haremos uso del método send en vez de sendall, para asegurarnos\n",
    "    # que todo se envíe correctamente\n",
    "    bytes_enviados = 0\n",
    "    while bytes_enviados < largo_archivo:\n",
    "        # Enviaremos el mensaje cortado desde el último punto efectivamente enviado.\n",
    "        bytes_enviados += sock.send(datos[bytes_enviados:])\n",
    "\n",
    "\n",
    "print(\"¡Archivo enviado!\")\n",
    "\n",
    "# Imprimimos lo que nos responda la contraparte.\n",
    "print(\"Respuesta:\", sock.recv(4096).decode('utf-8'))\n",
    "\n",
    "# Cerramos el socket.\n",
    "sock.close()\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora podemos ver que funciona correctamente.\n",
    "\n",
    "![](imgs/send-large-file-improved.png)\n",
    "![](imgs/compare-large-files-improved.png)\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Servidor con manejo de múltiples clientes en forma concurrente\n",
    "\n",
    "En la práctica, lo más probable es que tengamos que manejar múltiples usuarios de forma concurrente en el servidor, y además enviar paquetes de datos de tamaños arbitrarios. A continuación, describiremos cómo incorporaremos estas modificaciones.\n",
    "\n",
    "El manejo de varios usuarios simultáneamente se logra mediante el uso de _threads_. Cada vez que se ejecutan los métodos `socket.accept()` y `socket.recv()` los _threads_ donde ellos son invocados se bloquean hasta aceptar una nueva conexión (en el caso del primero), y recibir datos (en el caso del segundo). Para evitar este comportamiento tendremos que crear un _thread_ encargado de:\n",
    "\n",
    "- Aceptar nuevos clientes;\n",
    "- Y, cada vez que se acepte un cliente, crear un _thread_ nuevo que se ocupe de escuchar y enviar información al nuevo cliente conectado.\n",
    "\n",
    "Además, implementaremos lo aprendido sobre cómo enviar o recibir mensajes de tamaño arbitrario. Definamos a continuación una clase `Client` donde ejemplificaremos las modificaciones mencionadas.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "import socket\n",
    "import threading\n",
    "\n",
    "\n",
    "class Client:\n",
    "    \"\"\"\n",
    "    Maneja toda la comunicación desde el lado del cliente.\n",
    "\n",
    "    Implementa el esquema de comunicación donde los primeros 4 bytes de cada\n",
    "    mensaje indicarán el largo del mensaje enviado.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, port: int, host: str) -> None:\n",
    "        print(\"Inicializando cliente...\")\n",
    "\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.socket_client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "\n",
    "        try:\n",
    "            self.connect_to_server()\n",
    "            self.listen()\n",
    "            self.repl()\n",
    "        except ConnectionError:\n",
    "            print(\"Conexión terminada.\")\n",
    "            self.socket_client.close()\n",
    "            exit()\n",
    "\n",
    "    def connect_to_server(self) -> None:\n",
    "        \"\"\"Crea la conexión al servidor.\"\"\"\n",
    "\n",
    "        self.socket_client.connect((self.host, self.port))\n",
    "        print(\"Cliente conectado exitosamente al servidor.\")\n",
    "\n",
    "    def listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Inicializa el thread que escuchará los mensajes del servidor.\n",
    "\n",
    "        Es útil hacer un thread diferente para escuchar al servidor,\n",
    "        ya que de esa forma podremos tener comunicación asíncrona con este.\n",
    "        Luego, el servidor nos podrá enviar mensajes sin necesidad de\n",
    "        iniciar una solicitud desde el lado del cliente.\n",
    "        \"\"\"\n",
    "\n",
    "        thread = threading.Thread(target=self.listen_thread, daemon=True)\n",
    "        thread.start()\n",
    "\n",
    "    def send(self, msg: str) -> None:\n",
    "        \"\"\"\n",
    "        Envía mensajes al servidor.\n",
    "\n",
    "        Implementa el mismo protocolo de comunicación que mencionamos;\n",
    "        es decir, agregar 4 bytes al principio de cada mensaje\n",
    "        indicando el largo del mensaje enviado. \n",
    "        Luego, puedes elegir entre usar send() en un ciclo, o un solo sendall().\n",
    "        En este ejemplo, solo usamos un sendall(), pero puedes cambiarlo por \n",
    "        el ciclo del ejemplo anterior para asegurar que toda la información\n",
    "        se enviará correctamente.\n",
    "        \"\"\"\n",
    "\n",
    "        msg_bytes = msg.encode()\n",
    "        msg_length = len(msg_bytes).to_bytes(4, byteorder='big')\n",
    "        self.socket_client.sendall(msg_length + msg_bytes)\n",
    "\n",
    "    def listen_thread(self) -> None:\n",
    "        while True:\n",
    "            response_bytes_length = self.socket_client.recv(4)\n",
    "            response_length = int.from_bytes(\n",
    "                response_bytes_length, byteorder='big')\n",
    "            response = bytearray()\n",
    "\n",
    "            # Recibimos datos hasta que alcancemos la totalidad de los datos\n",
    "            # indicados en los primeros 4 bytes recibidos.\n",
    "            while len(response) < response_length:\n",
    "                read_length = min(4096, response_length - len(response))\n",
    "                response.extend(self.socket_client.recv(read_length))\n",
    "\n",
    "            print(f\"{response.decode()}\\n>>> \", end='')\n",
    "\n",
    "    def repl(self) -> None:\n",
    "        \"\"\"\n",
    "        Captura el input del usuario.\n",
    "\n",
    "        Lee mensajes desde el terminal y después se pasan a `self.send()`.\n",
    "        \"\"\"\n",
    "\n",
    "        print(\"------ Consola ------\\n>>> \", end='')\n",
    "        while True:\n",
    "            msg = input()\n",
    "            self.send(msg)\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    port = 8080\n",
    "    host = 'localhost'\n",
    "\n",
    "    client = Client(port, host)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora implementaremos la clase `Server` que se encargará de controlar toda la lógica en el lado del servidor:\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "import socket\n",
    "import threading\n",
    "\n",
    "\n",
    "class Server:\n",
    "    def __init__(self, port: int, host: str) -> None:\n",
    "        print(\"Inicializando servidor...\")\n",
    "\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "        self.bind_and_listen()\n",
    "        self.accept_connections()\n",
    "\n",
    "    def bind_and_listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Enlaza el socket creado con el host y puerto indicado.\n",
    "\n",
    "        Primero, se enlaza el socket y luego queda esperando\n",
    "        por conexiones entrantes.\n",
    "        \"\"\"\n",
    "        self.socket_server.bind((self.host, self.port))\n",
    "        self.socket_server.listen()\n",
    "        print(f\"Servidor escuchando en {self.host}:{self.port}...\")\n",
    "\n",
    "    def accept_connections(self) -> None:\n",
    "        \"\"\"\n",
    "        Inicia el thread que aceptará clientes.\n",
    "\n",
    "        Aunque podríamos aceptar clientes en el thread principal de la\n",
    "        instancia, es útil hacerlo en un thread aparte. Esto nos\n",
    "        permitirá realizar la lógica en la parte del servidor sin dejar\n",
    "        de aceptar clientes. Por ejemplo, seguir procesando archivos.\n",
    "        \"\"\"\n",
    "        thread = threading.Thread(target=self.accept_connections_thread)\n",
    "        thread.start()\n",
    "\n",
    "    def accept_connections_thread(self) -> None:\n",
    "        \"\"\"\n",
    "        Es arrancado como thread para aceptar clientes.\n",
    "\n",
    "        Cada vez que aceptamos un nuevo cliente, iniciamos un\n",
    "        thread nuevo encargado de manejar el socket para ese cliente.\n",
    "        \"\"\"\n",
    "        print(\"Servidor aceptando conexiones...\")\n",
    "\n",
    "        while True:\n",
    "            client_socket, _ = self.socket_server.accept()\n",
    "            listening_client_thread = threading.Thread(\n",
    "                target=self.listen_client_thread,\n",
    "                args=(client_socket, ),\n",
    "                daemon=True)\n",
    "            listening_client_thread.start()\n",
    "\n",
    "    @staticmethod\n",
    "    def send(value: any, sock: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Envía mensajes hacia algún socket cliente.\n",
    "\n",
    "        Debemos implementar en este método el protocolo de comunicación\n",
    "        donde los primeros 4 bytes indicarán el largo del mensaje.\n",
    "        Luego, puedes elegir entre usar send() en un ciclo, o un solo sendall().\n",
    "        En este ejemplo, solo usamos un sendall(), pero puedes cambiarlo por \n",
    "        el ciclo del ejemplo anterior para asegurar que toda la información\n",
    "        se enviará correctamente.\n",
    "        \"\"\"\n",
    "        stringified_value = str(value)\n",
    "        msg_bytes = stringified_value.encode()\n",
    "        msg_length = len(msg_bytes).to_bytes(4, byteorder='big')\n",
    "        sock.sendall(msg_length + msg_bytes)\n",
    "\n",
    "    def listen_client_thread(self, client_socket: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Es ejecutado como thread que escuchará a un cliente en particular.\n",
    "\n",
    "        Implementa las funcionalidades del protocolo de comunicación\n",
    "        que permiten recuperar la informacion enviada.\n",
    "        \"\"\"\n",
    "        print(f\"{client_socket.getpeername()} Nuevo cliente conectado al servidor\")\n",
    "\n",
    "        while True:\n",
    "            response_bytes_length = client_socket.recv(4)\n",
    "            response_length = int.from_bytes(\n",
    "                response_bytes_length, byteorder='big')\n",
    "            response = bytearray()\n",
    "\n",
    "            while len(response) < response_length:\n",
    "                read_length = min(4096, response_length - len(response))\n",
    "                response.extend(client_socket.recv(read_length))\n",
    "\n",
    "            received = response.decode()\n",
    "\n",
    "            if received != \"\":\n",
    "                # El método `self.handle_command()` debe ser definido.\n",
    "                # Este realizará toda la lógica asociado a los mensajes\n",
    "                # que llegan al servidor desde un cliente en particular.\n",
    "                # Se espera que retorne la respuesta que el servidor\n",
    "                # debe enviar hacia el cliente.\n",
    "                response = self.handle_command(received, client_socket)\n",
    "                self.send(response, client_socket)\n",
    "\n",
    "    def handle_command(self, received: str, client_socket: socket.socket) -> str:\n",
    "        print(f\"{client_socket.getpeername()} Comando recibido:\", received)\n",
    "        # Este método debería ejecutar la acción y enviar una respuesta.\n",
    "        return \"Acción asociada a \" + received\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    port = 8080\n",
    "    host = 'localhost'\n",
    "\n",
    "    server = Server(port, host)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Las clases descritas en los códigos anteriores definen entidades que podrán ser usadas eficientemente a través de _threads_ para comunicación asíncrona. Es recomendable mantener las funcionalidades de estas clases sólo destinadas a funciones de _networking_, i.e., solo para efectuar la comunicación entre el servidor y los clientes. Será natural que intentemos agregar código que permita manejar la lógica en el cliente o el servidor de acuerdo a los requerimientos de nuestro problema, lo que tendrá como consecuencia la generación de código muy **difícil de leer y mantener**.\n",
    "\n",
    "Por lo tanto, recomendamos fuertemente efectuar la implementación de la lógica en otras clases (_backend_). Y destinar las clases Cliente y Servidor a solo funciones encargadas de la comunicación.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "<font size='1' face='Arial'><sup>1</sup>Agradecemos al ex-ayudante del curso, Rodolfo Palma, por su colaboración para la elaboración del ejemplo práctico de este material.</font>\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Cliente conectado a más de un servidor\n",
    "\n",
    "Aunque no es tan común como la versión inversa (un servidor con múltiples clientes), puede ocurrir que nuestro cliente necesite conectarse a más de un servidor al mismo tiempo. Esto podría deberse a múltiples razones, como que cada servidor pertenece a un servicio diferente o a pesar de ser del mismo servicio, se encargue de diferentes funcionalidades de un producto.\n",
    "\n",
    "La forma de trabajar con más de un servidor es similar a la del ejemplo anterior en el sentido de que necesitaremos incorporar un thread por conexión, sin embargo la diferencia recae en que esta vez no podemos usar el mismo socket para ambas conexiones, ya que el socket es creado por los servidores y no los clientes. Para este ejemplo deberemos entablar una conexión individual a cada servidor, y nuestro programa deberá manejar a qué servidor enviar la información según corresponda, y cómo procesar la información recibida dependiendo de qué servidor la envió.\n",
    "\n",
    "Nuevamente, para ejecutar este ejemplo se recomienda crear los archivos `servidor_1.py`, `servidor_2.py` y `cliente.py`, en los cuales se debe pegar el código presentado en estas celdas.\n",
    "\n",
    "Partiremos instanciando dos servidores tal como se ha hecho anteriormente. El código de ambos servidores es el mismo, solo usando prints para diferenciarlos. Otro detalle a notar es que cada uno de los servidores tiene su propio puerto, ya que esta es la forma con la que el cliente sabrá cómo diferenciar a cada uno, especialmente cuando tienen la misma IP (al ser ejecutados desde el mismo computador).\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Crea un archivo servidor_1.py con el código de esta celda y ejecútalo.\n",
    "import socket\n",
    "import threading\n",
    "\n",
    "\n",
    "class Server:\n",
    "    def __init__(self, port: int, host: str) -> None:\n",
    "        print(\"Inicializando servidor 1...\")\n",
    "\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "        self.bind_and_listen()\n",
    "        self.accept_connections()\n",
    "\n",
    "    def bind_and_listen(self) -> None:\n",
    "        self.socket_server.bind((self.host, self.port))\n",
    "        self.socket_server.listen()\n",
    "        print(f\"Servidor 1 escuchando en {self.host}:{self.port}...\")\n",
    "\n",
    "    def accept_connections(self) -> None:\n",
    "        thread = threading.Thread(target=self.accept_connections_thread)\n",
    "        thread.start()\n",
    "\n",
    "    def accept_connections_thread(self) -> None:\n",
    "        print(\"Servidor 1 aceptando conexiones...\")\n",
    "\n",
    "        while True:\n",
    "            client_socket, _ = self.socket_server.accept()\n",
    "            listening_client_thread = threading.Thread(\n",
    "                target=self.listen_client_thread,\n",
    "                args=(client_socket, ),\n",
    "                daemon=True)\n",
    "            listening_client_thread.start()\n",
    "\n",
    "    @staticmethod\n",
    "    def send(value: any, sock: socket.socket) -> None:\n",
    "        stringified_value = str(value)\n",
    "        msg_bytes = stringified_value.encode()\n",
    "        sock.sendall(msg_bytes)\n",
    "\n",
    "    def listen_client_thread(self, client_socket: socket.socket) -> None:\n",
    "        print(\"Servidor 1 conectado a un nuevo cliente...\")\n",
    "\n",
    "        while True:\n",
    "            response = client_socket.recv(4096)\n",
    "\n",
    "            received = response.decode()\n",
    "\n",
    "            if received != \"\":\n",
    "                response = self.handle_command(received, client_socket)\n",
    "                self.send(response, client_socket)\n",
    "\n",
    "    def handle_command(self, received: str, client_socket: socket.socket) -> str:\n",
    "        print(\"Comando recibido por el servidor 1:\", received)\n",
    "        # Este método debería ejecutar la acción y enviar una respuesta.\n",
    "        return \"Acción del servidor 1 asociada a \" + received\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    # Nótese que cada servidor debe definir su propio puerto distinto.\n",
    "    port = 8080\n",
    "    host = 'localhost'\n",
    "\n",
    "    server = Server(port, host)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Crea un archivo servidor_2.py con el código de esta celda y ejecútalo.\n",
    "import socket\n",
    "import threading\n",
    "\n",
    "\n",
    "class Server:\n",
    "    def __init__(self, port: int, host: str) -> None:\n",
    "        print(\"Inicializando servidor 2...\")\n",
    "\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.socket_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n",
    "        self.bind_and_listen()\n",
    "        self.accept_connections()\n",
    "\n",
    "    def bind_and_listen(self) -> None:\n",
    "        self.socket_server.bind((self.host, self.port))\n",
    "        self.socket_server.listen()\n",
    "        print(f\"Servidor 2 escuchando en {self.host}:{self.port}...\")\n",
    "\n",
    "    def accept_connections(self) -> None:\n",
    "        thread = threading.Thread(target=self.accept_connections_thread)\n",
    "        thread.start()\n",
    "\n",
    "    def accept_connections_thread(self) -> None:\n",
    "        print(\"Servidor 2 aceptando conexiones...\")\n",
    "\n",
    "        while True:\n",
    "            client_socket, _ = self.socket_server.accept()\n",
    "            listening_client_thread = threading.Thread(\n",
    "                target=self.listen_client_thread,\n",
    "                args=(client_socket, ),\n",
    "                daemon=True)\n",
    "            listening_client_thread.start()\n",
    "\n",
    "    @staticmethod\n",
    "    def send(value: any, sock: socket.socket) -> None:\n",
    "        stringified_value = str(value)\n",
    "        msg_bytes = stringified_value.encode()\n",
    "        sock.sendall(msg_bytes)\n",
    "\n",
    "    def listen_client_thread(self, client_socket: socket.socket) -> None:\n",
    "        print(\"Servidor 2 conectado a un nuevo cliente...\")\n",
    "\n",
    "        while True:\n",
    "            response = client_socket.recv(4096)\n",
    "\n",
    "            received = response.decode()\n",
    "\n",
    "            if received != \"\":\n",
    "                response = self.handle_command(received, client_socket)\n",
    "                self.send(response, client_socket)\n",
    "\n",
    "    def handle_command(self, received: str, client_socket: socket.socket) -> str:\n",
    "        print(\"Comando recibido por el servidor 2:\", received)\n",
    "        # Este método debería ejecutar la acción y enviar una respuesta.\n",
    "        return \"Acción del servidor 2 asociada a \" + received\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    # Nótese que cada servidor debe definir su propio puerto distinto.\n",
    "    port = 8081\n",
    "    host = 'localhost'\n",
    "\n",
    "    server = Server(port, host)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Ahora debemos instanciar al cliente.\n",
    "\n",
    "En este caso, notaremos que a diferencia de los ejemplos anteriores, deberemos generar dos hilos independientes entre sí, donde cada uno entabla y almacena una conexión con uno de los servidores. Entonces, cada vez que queramos interactuar con uno de los servidores, deberemos usar el _socket_ relacionado a dicho servidor para enviar el mensaje.\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Crea un archivo cliente.py con el código de esta celda y ejecútalo\n",
    "# (una vez ya hayas ejecutado los dos servidores anteriores).\n",
    "import socket\n",
    "import threading\n",
    "import time\n",
    "\n",
    "\n",
    "class Client:\n",
    "    \"\"\"\n",
    "    Maneja toda la comunicación desde el lado del cliente.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, datos_servidor_1: dict, datos_servidor_2: dict) -> None:\n",
    "        print(\"Inicializando cliente...\")\n",
    "\n",
    "        self.datos_servidor_1 = datos_servidor_1\n",
    "        self.datos_servidor_2 = datos_servidor_2\n",
    "        # Tenemos que crear un socket para cada servidor, ya que necesitarán su propia conexión.\n",
    "        # Usamos un dict para mejor escalabilidad (tener todos los servidores en 1 variable)\n",
    "        self.sockets = {\n",
    "            1: socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM),\n",
    "            2: socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM)\n",
    "        }\n",
    "\n",
    "        try:\n",
    "            self.connect_to_server()\n",
    "            self.listen()\n",
    "            self.repl()\n",
    "        except ConnectionError:\n",
    "            print(\"Conexión terminada.\")\n",
    "            self.disconnect_servers()\n",
    "            exit()\n",
    "\n",
    "    def connect_to_server(self) -> None:\n",
    "        \"\"\"\n",
    "        Crea la conexión al servidor.\n",
    "        Con más de un servidor, debemos repetir los comandos de conexión para\n",
    "        cada servidor.\n",
    "        \"\"\"\n",
    "\n",
    "        self.sockets[1].connect(\n",
    "            (self.datos_servidor_1['host'], self.datos_servidor_1['port']))\n",
    "\n",
    "        self.sockets[2].connect(\n",
    "            (self.datos_servidor_2['host'], self.datos_servidor_2['port']))\n",
    "\n",
    "        print(\"Cliente conectado exitosamente a ambos servidores.\")\n",
    "\n",
    "    def disconnect_servers(self) -> None:\n",
    "        for socket in self.sockets.values():\n",
    "            socket.close()\n",
    "\n",
    "    def listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Inicializa los threads que escucharán los mensajes de los servidores.\n",
    "\n",
    "        Para que los servidores no se bloqueen entre sí, debemos tener un thread por conexión\n",
    "        que se encargue de escuchar exclusivamente a ese servidor.\n",
    "        \"\"\"\n",
    "\n",
    "        thread_1 = threading.Thread(\n",
    "            target=self.listen_thread, args=(1,), daemon=True)\n",
    "        thread_2 = threading.Thread(\n",
    "            target=self.listen_thread, args=(2,), daemon=True)\n",
    "        thread_1.start()\n",
    "        thread_2.start()\n",
    "\n",
    "    def send(self, server: int, msg: str) -> None:\n",
    "        \"\"\"\n",
    "        Envía mensajes al servidor.\n",
    "\n",
    "        Debemos escoger qué mensaje mandar a qué servidor y usar su socket correspondiente.\n",
    "        \"\"\"\n",
    "\n",
    "        msg_bytes = msg.encode()\n",
    "        self.sockets[server].sendall(msg_bytes)\n",
    "\n",
    "    def listen_thread(self, servidor) -> None:\n",
    "        \"\"\"\n",
    "        Función que escucha al servidor. Dependiendo de cuál eligamos, hace receive de la \n",
    "        información del socket elegido y lo identifica al hacer print.\n",
    "        \"\"\"\n",
    "        while True:\n",
    "            response = self.sockets[servidor].recv(4096)\n",
    "            print(\n",
    "                f\"El siguiente mensaje fue enviado por el servidor {servidor}:\")\n",
    "            print(f\"{response.decode()}\\n>>> \", end='')\n",
    "\n",
    "    def repl(self) -> None:\n",
    "        \"\"\"\n",
    "        Captura el input del usuario.\n",
    "\n",
    "        Lee mensajes desde el terminal y después se pasan a `self.send()`.\n",
    "        \"\"\"\n",
    "\n",
    "        print(\"------ Consola ------\\n>>> \", end='')\n",
    "        while True:\n",
    "            server = input(\n",
    "                \"Selecciona a que servidor deseas escribir (1, 2): \")\n",
    "            msg = input(\"Escribe tu mensaje: \")\n",
    "            self.send(int(server), msg)\n",
    "            # Este sleep() es solo para que los textos en la consola no se sobrepongan\n",
    "            # Y el ejemplo se entienda mejor\n",
    "            time.sleep(1)\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    # Necesitamos saber los datos de ambos servidores\n",
    "    datos_servidor_1 = {\n",
    "        'port': 8080,\n",
    "        'host': 'localhost',\n",
    "    }\n",
    "\n",
    "    datos_servidor_2 = {\n",
    "        'port': 8081,\n",
    "        'host': 'localhost',\n",
    "    }\n",
    "\n",
    "    client = Client(datos_servidor_1, datos_servidor_2)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## Interacción entre clientes _Peer 2 Peer_ (P2P)\n",
    "\n",
    "Hasta ahora, hemos trabajado con arquitecturas centralizadas en donde siempre existe una diferencia clara entre Servidor (quien se encarga de procesar solicitudes, responder a clientes, y en generar administrar la red en caso de haber múltiples clientes) y Cliente (quienes solicitan o entregan información al servidor cuando lo necesitan). Sin embargo, no es el único tipo de arquitectura disponible.\n",
    "\n",
    "En el contexto de redes, el concepto de _Peer 2 Peer_ corresponde a un tipo de arquitectura no centralizada en donde dejamos de tener un servidor único que maneja gran parte de la carga o trabajo de la red, y solo poseemos clientes que se comunican entre sí, distribuyéndose la carga o trabajo de la red entre ellos. Ejemplos conocidos de arquitecturas P2P son las redes de transferencia de archivos (como cierta aplicación que no podemos mencionar pero... El que sabe, _sabe_ 🏴‍☠️).\n",
    "\n",
    "Para implementar la arquitectura P2P con _sockets_ en Python, ya no tendremos archivo de servidor y cliente, sino que ambos serán clientes en el sentido de que ninguno tiene mayor privilegio que el otro al trabajar, sino que ambos son equivalentes en jerarquía. A diferencia de los ejemplos anteriores, como resultados tendremos un programa que para fines prácticos es al mismo tiempo un Servidor (ya que debe crear un _socket_ en cierto puerto, para aceptar conexiones de otros clientes) y un Cliente (ya que debe conectarse a los _sockets_ de otros clientes). Por lo que nuestras clases ahora deberán tener tanto los métodos de servidor como de cliente disponibles. Nuevamente, haremos uso de _threads_ para que estos _sockets_ no se bloqueen entre sí y podamos tenerlos trabajando en paralelo.\n",
    "\n",
    "Por simpleza, tendremos en este ejemplo código que deberá ir en dos archivos, `cliente_1.py` y `cliente_2.py` que deben ejecutarse en ese orden para funcionar. Esto es solo para evitar el error de ejecutar en desorden y tratar de conectarnos a un _socket_ de un programa que no esté corriendo todavía. En fines prácticos, si quieres añadir lógica adicional para manejar este posible problema, podrías condensar ambos archivos en un solo archivo `cliente.py` que use el mismo código (pero eso te lo dejamos como desafío 😉)\n",
    "\n",
    "Vamos al ejemplo:"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Crea un archivo cliente_1.py con el código de esta celda y ejecútalo primero.\n",
    "import socket\n",
    "import threading\n",
    "import time\n",
    "\n",
    "\n",
    "class Client_1:\n",
    "    \"\"\"\n",
    "    Nuestro primer cliente Peer 2 Peer (Que es un server y cliente a la vez).\n",
    "    Este abre un servidor, acepta la conexión de otro cliente (haciendo que tomemos el rol de server)\n",
    "    Luego, se trata de conectar al servidor de ese cliente (haciendo que tomemos el rol de cliente también).\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, host: str, port: int, datos_otro_cliente: dict) -> None:\n",
    "        print(\"Inicializando cliente P2P (1)...\")\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.datos_otro_cliente = datos_otro_cliente\n",
    "\n",
    "        # Tenemos que crear un socket para nosotros abrir y escuchar conexiones como servidor,\n",
    "        # Y un socket que usaremos para conectarnos al otro cliente P2P como clientes de su Servidor.\n",
    "        self.sockets = {\n",
    "            'server': socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM),\n",
    "            'client': socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM)\n",
    "        }\n",
    "\n",
    "        # Primero abriremos nuestro socket como servidor para aceptar conexiones de otros clientes\n",
    "        self.bind_and_listen()\n",
    "        self.accept_connections()\n",
    "\n",
    "    # Los siguientes 6 métodos sirven para realizar las acciones que haría un servidor\n",
    "    # INICIO MÉTODOS SERVIDOR\n",
    "    def bind_and_listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Nos enlazamos a un host y puerto para escuchar conexiones\n",
    "        \"\"\"\n",
    "        self.sockets['server'].bind((self.host, self.port))\n",
    "        self.sockets['server'].listen()\n",
    "        print(f\"Cliente P2P (1) escuchando en {self.host}:{self.port}...\")\n",
    "\n",
    "    def accept_connections(self) -> None:\n",
    "        \"\"\"\n",
    "        Hacemos un thread solo para aceptar conexiones\n",
    "        \"\"\"\n",
    "        thread = threading.Thread(target=self.accept_connections_thread)\n",
    "        thread.start()\n",
    "\n",
    "    def accept_connections_thread(self) -> None:\n",
    "        \"\"\"\n",
    "        Levantamos un thread por cliente que se quiere conectar a nosotros.\n",
    "        \"\"\"\n",
    "        print(\"Cliente P2P (1) aceptando conexiones...\")\n",
    "\n",
    "        while True:\n",
    "            client_socket, _ = self.sockets['server'].accept()\n",
    "            listening_client_thread = threading.Thread(\n",
    "                target=self.listen_client_thread,\n",
    "                args=(client_socket, ),\n",
    "                daemon=True)\n",
    "            listening_client_thread.start()\n",
    "            # Como ya aceptamos al otro cliente, significa que este ya está online.\n",
    "            # Entonces, podemos conectarnos al servidor de este y volvernos sus clientes\n",
    "            try:\n",
    "                self.connect_to_server()\n",
    "                self.listen()\n",
    "                self.repl()\n",
    "            except ConnectionError:\n",
    "                print(\"Conexión terminada.\")\n",
    "                self.sockets['client'].close()\n",
    "                exit()\n",
    "\n",
    "    def listen_client_thread(self, client_socket: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Decodificamos y respondemos los mensajes de los clientes.\n",
    "        \"\"\"\n",
    "        print(\"Cliente P2P (1) recibió a un nuevo cliente...\")\n",
    "\n",
    "        while True:\n",
    "            response = client_socket.recv(4096)\n",
    "\n",
    "            received = response.decode()\n",
    "\n",
    "            if received != \"\":\n",
    "                response = self.handle_command(received, client_socket)\n",
    "                self.answer_client(response, client_socket)\n",
    "\n",
    "    def handle_command(self, received: str, client_socket: socket.socket) -> str:\n",
    "        print(\"Comando recibido por el Cliente P2P (1):\", received)\n",
    "        # Este método debería ejecutar la acción y enviar una respuesta.\n",
    "        return \"Acción del Cliente P2P (1) asociada a \" + received\n",
    "\n",
    "    @staticmethod\n",
    "    def answer_client(value: any, sock: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Enviamos la respuesta a un cliente en específico.\n",
    "        En este caso, lo enviamos como si fueramos un servidor que responde a su cliente.\n",
    "        \"\"\"\n",
    "        stringified_value = str(value)\n",
    "        msg_bytes = stringified_value.encode()\n",
    "        sock.sendall(msg_bytes)\n",
    "\n",
    "    # FIN MÉTODOS SERVIDOR\n",
    "\n",
    "    # Los siguientes 5 métodos son los que necesitamos para actuar como clientes.\n",
    "    # INICIO MÉTODOS CLIENTE\n",
    "    def connect_to_server(self) -> None:\n",
    "        \"\"\"\n",
    "        Crea la conexión al otro cliente (que será nuestro server).\n",
    "        \"\"\"\n",
    "\n",
    "        self.sockets['client'].connect(\n",
    "            (self.datos_otro_cliente['host'], self.datos_otro_cliente['port']))\n",
    "\n",
    "        print(\"Cliente conectado exitosamente al otro cliente.\")\n",
    "\n",
    "    def listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Inicializa el thread que escuchará al otro cliente como si fuera un servidor.\n",
    "        \"\"\"\n",
    "\n",
    "        thread_listening_server = threading.Thread(\n",
    "            target=self.listen_thread, daemon=True)\n",
    "        thread_listening_server.start()\n",
    "\n",
    "    def listen_thread(self) -> None:\n",
    "        \"\"\"\n",
    "        Función que escucha al otro cliente como servidor.\n",
    "        \"\"\"\n",
    "        while True:\n",
    "            response = self.sockets['client'].recv(4096)\n",
    "            print(\n",
    "                f\"El siguiente mensaje fue una respuesta por el otro cliente P2P:\")\n",
    "            print(f\"{response.decode()}\\n>>> \", end='')\n",
    "\n",
    "    def send(self, msg: str) -> None:\n",
    "        \"\"\"\n",
    "        Envía mensajes al otro cliente iniciados por nosotros.\n",
    "        En este caso, lo estamos enviando como si fueramos un cliente de su servidor.\n",
    "        \"\"\"\n",
    "\n",
    "        msg_bytes = msg.encode()\n",
    "        self.sockets['client'].sendall(msg_bytes)\n",
    "\n",
    "    def repl(self) -> None:\n",
    "        \"\"\"\n",
    "        Captura el input del usuario para iniciar mensajes.\n",
    "\n",
    "        Lee mensajes desde el terminal y después se pasan a `self.send()`.\n",
    "        \"\"\"\n",
    "        # Nuevamente, estos sleep() son solo para mantener orden de los prints por fines didácticos.\n",
    "        time.sleep(1)\n",
    "        print(\"------ Consola P2P (1) ------\\n>>> \", end='')\n",
    "        while True:\n",
    "            msg = input(\"Escribe tu mensaje al otro cliente: \")\n",
    "            self.send(msg)\n",
    "            time.sleep(1)\n",
    "\n",
    "    # FIN MÉTODOS CLIENTE\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    # Necesitamos saber los datos nuestros para levantar un server interno\n",
    "    host = 'localhost'\n",
    "    port = 8080\n",
    "    # Y los datos del otro cliente con el que nos conectaremos\n",
    "    datos_otro_cliente = {\n",
    "        'port': 8081,\n",
    "        'host': 'localhost',\n",
    "    }\n",
    "\n",
    "    client = Client_1(host, port, datos_otro_cliente)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "```python\n",
    "# Crea un archivo cliente_2.py con el código de esta celda y ejecútalo segundo.\n",
    "import socket\n",
    "import threading\n",
    "import time\n",
    "\n",
    "\n",
    "class Client_2:\n",
    "    \"\"\"\n",
    "    Nuestro segundo cliente Peer 2 Peer (Que es un server y cliente a la vez).\n",
    "    Este primero abre un servidor y espera conexiones.\n",
    "    Luego, se conecta al Cliente_1 como si nosotros fueramos un Cliente de su Servidor.\n",
    "    Posteriormente, aceptamos la solicitud de Cliente_1 para conectarse a nosotros como si \n",
    "    fueramos un servidor, cerrando el ciclo y volviendo nuestra comunicación P2P.\n",
    "    \"\"\"\n",
    "\n",
    "    def __init__(self, host: str, port: int, datos_otro_cliente: dict) -> None:\n",
    "        print(\"Inicializando cliente P2P (2)...\")\n",
    "        self.host = host\n",
    "        self.port = port\n",
    "        self.datos_otro_cliente = datos_otro_cliente\n",
    "\n",
    "        # Tenemos que crear un socket para nosotros abrir y escuchar conexiones como servidor,\n",
    "        # Y un socket que usaremos para conectarnos al otro cliente P2P como clientes de su Servidor.\n",
    "        self.sockets = {\n",
    "            'server': socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM),\n",
    "            'client': socket.socket(\n",
    "                socket.AF_INET, socket.SOCK_STREAM)\n",
    "        }\n",
    "\n",
    "        # Primero abriremos nuestro socket como servidor para aceptar conexiones de otros clientes\n",
    "        self.bind_and_listen()\n",
    "        self.accept_connections()\n",
    "\n",
    "        # Luego, como asumimos que el otro servidor ya está levantado, inmediatamente nos conectaremos a\n",
    "        # Este como clientes\n",
    "        try:\n",
    "            self.connect_to_server()\n",
    "            self.listen()\n",
    "            self.repl()\n",
    "        except ConnectionError:\n",
    "            print(\"Conexión terminada.\")\n",
    "            self.sockets['client'].close()\n",
    "            exit()\n",
    "\n",
    "    # Los siguientes 6 métodos sirven para realizar las acciones que haría un servidor\n",
    "    # INICIO MÉTODOS SERVIDOR\n",
    "\n",
    "    def bind_and_listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Nos enlazamos a un host y puerto para escuchar conexiones\n",
    "        \"\"\"\n",
    "        self.sockets['server'].bind((self.host, self.port))\n",
    "        self.sockets['server'].listen()\n",
    "        print(f\"Cliente P2P (2) escuchando en {self.host}:{self.port}...\")\n",
    "\n",
    "    def accept_connections(self) -> None:\n",
    "        \"\"\"\n",
    "        Hacemos un thread solo para aceptar conexiones\n",
    "        \"\"\"\n",
    "        thread = threading.Thread(target=self.accept_connections_thread)\n",
    "        thread.start()\n",
    "\n",
    "    def accept_connections_thread(self) -> None:\n",
    "        \"\"\"\n",
    "        Levantamos un thread por cliente que se quiere conectar a nosotros.\n",
    "        \"\"\"\n",
    "        print(\"Cliente P2P (2) aceptando conexiones...\")\n",
    "\n",
    "        while True:\n",
    "            client_socket, _ = self.sockets['server'].accept()\n",
    "            listening_client_thread = threading.Thread(\n",
    "                target=self.listen_client_thread,\n",
    "                args=(client_socket, ),\n",
    "                daemon=True)\n",
    "            listening_client_thread.start()\n",
    "\n",
    "    def listen_client_thread(self, client_socket: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Decodificamos y respondemos los mensajes de los clientes.\n",
    "        \"\"\"\n",
    "        print(\"Cliente P2P (2) recibió a un nuevo cliente...\")\n",
    "\n",
    "        while True:\n",
    "            response = client_socket.recv(4096)\n",
    "\n",
    "            received = response.decode()\n",
    "\n",
    "            if received != \"\":\n",
    "                response = self.handle_command(received, client_socket)\n",
    "                self.answer_client(response, client_socket)\n",
    "\n",
    "    def handle_command(self, received: str, client_socket: socket.socket) -> str:\n",
    "        print(\"Comando recibido por el Cliente P2P (2):\", received)\n",
    "        # Este método debería ejecutar la acción y enviar una respuesta.\n",
    "        return \"Acción del Cliente P2P (2) asociada a \" + received\n",
    "\n",
    "    @staticmethod\n",
    "    def answer_client(value: any, sock: socket.socket) -> None:\n",
    "        \"\"\"\n",
    "        Enviamos la respuesta a un cliente en específico.\n",
    "        En este caso, lo enviamos como si fueramos un servidor que responde a su cliente.\n",
    "        \"\"\"\n",
    "        stringified_value = str(value)\n",
    "        msg_bytes = stringified_value.encode()\n",
    "        sock.sendall(msg_bytes)\n",
    "\n",
    "    # FIN MÉTODOS SERVIDOR\n",
    "\n",
    "    # Los siguientes 5 métodos son los que necesitamos para actuar como clientes.\n",
    "    # INICIO MÉTODOS CLIENTE\n",
    "    def connect_to_server(self) -> None:\n",
    "        \"\"\"\n",
    "        Crea la conexión al otro cliente.\n",
    "        \"\"\"\n",
    "\n",
    "        self.sockets['client'].connect(\n",
    "            (self.datos_otro_cliente['host'], self.datos_otro_cliente['port']))\n",
    "\n",
    "        print(\"Cliente conectado exitosamente al otro cliente.\")\n",
    "\n",
    "    def listen(self) -> None:\n",
    "        \"\"\"\n",
    "        Inicializa el thread que escuchará al otro cliente como si fuera un servidor.\n",
    "        \"\"\"\n",
    "        thread_listening_server = threading.Thread(\n",
    "            target=self.listen_thread, daemon=True)\n",
    "        thread_listening_server.start()\n",
    "\n",
    "    def listen_thread(self) -> None:\n",
    "        \"\"\"\n",
    "        Función que escucha al otro cliente como servidor.\n",
    "        \"\"\"\n",
    "        while True:\n",
    "            response = self.sockets['client'].recv(4096)\n",
    "            print(\n",
    "                f\"El siguiente mensaje fue una respuesta por el otro cliente P2P:\")\n",
    "            print(f\"{response.decode()}\\n>>> \", end='')\n",
    "\n",
    "    def send(self, msg: str) -> None:\n",
    "        \"\"\"\n",
    "        Envía mensajes al otro cliente.\n",
    "        En este caso, lo estamos enviando como si fueramos un cliente de su servidor.\n",
    "        \"\"\"\n",
    "\n",
    "        msg_bytes = msg.encode()\n",
    "        self.sockets['client'].sendall(msg_bytes)\n",
    "\n",
    "    def repl(self) -> None:\n",
    "        \"\"\"\n",
    "        Captura el input del usuario para iniciar mensajes.\n",
    "\n",
    "        Lee mensajes desde el terminal y después se pasan a `self.send()`.\n",
    "        \"\"\"\n",
    "        # Nuevamente, estos sleep() son solo para mantener orden de los prints por fines didácticos.\n",
    "        time.sleep(1)\n",
    "        print(\"------ Consola P2P (2) ------\\n>>> \", end='')\n",
    "        while True:\n",
    "            msg = input(\"Escribe tu mensaje al otro cliente: \")\n",
    "            self.send(msg)\n",
    "            time.sleep(1)\n",
    "\n",
    "    # FIN MÉTODOS CLIENTE\n",
    "\n",
    "\n",
    "if __name__ == \"__main__\":\n",
    "    # Necesitamos saber los datos nuestros para levantar un server interno\n",
    "    host = 'localhost'\n",
    "    port = 8081\n",
    "    # Y los datos del otro cliente con el que nos conectaremos\n",
    "    datos_otro_cliente = {\n",
    "        'port': 8080,\n",
    "        'host': 'localhost',\n",
    "    }\n",
    "\n",
    "    client = Client_2(host, port, datos_otro_cliente)\n",
    "```"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Bastante código, ¿no? Puede que sea un poco complicado al inicio, pero al final de cuentas todos los ejemplos se resumen en:\n",
    "\n",
    "- Quien debería ser un servidor, quien debería ser cliente. (Recordando que podemos tener múltiples servidores y clientes, y clases que pueden ser ambas)\n",
    "- Generar un _thread_ por cada _socket_ que instanciemos.\n",
    "- Generar un _thread_ por cada cliente que se conecte a nosotros.\n",
    "- Evaluar la necesidad de _threads_ extras (para interactuar con la terminal, por ejemplo, u otros procesos que sean necesarios).\n",
    "\n",
    "Con estos ejemplos, deberías poder generar distintas combinaciones de arquitecturas para tus programas."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.7"
  },
  "varInspector": {
   "cols": {
    "lenName": 16,
    "lenType": 16,
    "lenVar": 40
   },
   "kernels_config": {
    "python": {
     "delete_cmd_postfix": "",
     "delete_cmd_prefix": "del ",
     "library": "var_list.py",
     "varRefreshCmd": "print(var_dic_list())"
    },
    "r": {
     "delete_cmd_postfix": ") ",
     "delete_cmd_prefix": "rm(",
     "library": "var_list.r",
     "varRefreshCmd": "cat(var_dic_list()) "
    }
   },
   "types_to_exclude": [
    "module",
    "function",
    "builtin_function_or_method",
    "instance",
    "_Feature"
   ],
   "window_display": false
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
