×
Preliminares
1 Primeros pasos
2 El mundo desde un ordenador
3 Ampliando horizontes
3.1 Fuentes de información
3.1.1 La función help()
3.1.2 Namespaces. Ámbitos de los identificadores
3.1.3 Clases y objetos. Introducción a la OOP
3.1.4 La función dir() y la biblioteca inspect
3.1.5 Profundizando en la definición de funciones
Funciones lambda
3.1.6 Documentación de Python en las redes
La web oficial: python.org
La web española: pyspanishdoc
Nuestras bibliotecas documentadas
La WEB "en persona": w3schools
3.2 El interfaz de usuario
3.2.1 CLI: Interfaz de línea de comandos
3.2.2 Control de la consola mediante curses
3.2.3 Diseño de programas. Diagramas de flujo
3.2.4 Reutilizando el código: Paquetes y módulos
importlib
3.2.5 Interfaces gráficos: tkinter
Cuadros o Frames
Etiquetas o Labels
Botones (Buttons)
Casillas de verificación (Checkbuttons)
Botones de opción (Radiobuttons)
Campos de entrada (Entry)
Listas de opciones (Listbox)
Barras de desplazamiento(Scrollbar)
Campos de texto (Text)
Menús en tkinter
Lienzos (Canvas)
4 Teoría y teoremas
5 Jugando con Python

Aprender Python:

3 Ampliando horizontes

Despues de la experiencia acumulada en estas dos grandes secciones vamos a diversificar nuestros exploraciones acerca del mundo de los ordenadores, aprovechando para seguir progresando con nuestro Python. Ya mencionaba al comienzo de la sección 2 que para ser programador hay que tener un amplio bagage de conocimientos sobre el funcionamiento de la máquina y tampoco vienen mal unos cuantos conceptos matemáticos, físicos y acerca del mundo en general.

3.1 Fuentes de información

En esta sección vamos a ver fuentes de información y métodos para que puedas aumentar por tí mismo tus conocimientos, e incluso indagar en aspectos mal documentados del lenguaje o algunas bibliotecas. Por supuesto, una de las formas de aprender es sencillamente mirar el código de los programas, que es una de las ventajas que nos ofrece el hecho de usar un lenguaje interpretado. Esto es solo una verdad a medias, porque gran parte de las bibliotecas tienen extensas secciones que están programadas en otros lenguajes, principalmente en C++.

Hay tres motivos principales para utilizar código C++ en lugar de Python, la primera es el rendimiento, hay procesos que se realizan a una velocidad enormemente superior en C++. La segunda es proporcionar una API (Application Program Interface), que consiste en una serie de funciones que enlazan nuestros programas en Python con otros escritos en otros lenguajes, principalmente en C++, para poder disponer de las capacidades de estos últimos. Por último, está la necesidad de acceder a un control a bajo nivel de características del propio Python, el sistema operativo o el hardware.

Python, con treinta años de desarrollo a sus espaldas, y con una mentalidad de software abierto desde sus mismos comienzos, dispone de una inmensa cantidad de información libremente accesible, fundamentalmente a través de la web. Sin embargo vamos a unas fuentes mucho más próximas e inmediatas.

3.1.1 La función help()

Hemos arrancado tantas vece IDLE o el intérprete de Python que no prestamos atención a lo que ocurre desde el primer instante. Fíjate bien en la pantalla inicial:

Después del mensaje de copyright en la segunda línea nos da una valiosa y poco valorada información. Nada menos que cuatro métodos para obtener información. De los cuatro, el que realmente nos interesa como programadores es el primero: Teclea "help" para más información. Al seguir la indicación nos aparece un simple mensaje. Podemos entrar en el modo interactivo de ayuda o solicitar ayuda sobre algún objeto, es decir, sobre cualquier identificador de Python que conozcamos.

Como estamos curioseando el sistema de ayuda, podemos empezar por ahí, volvamos a IDLE:

>>> help(help)
Help on _Helper in module _sitebuiltins object:

class _Helper(builtins.object)
|  Define the builtin 'help'.
|
|  This is a wrapper around pydoc.help that provides a helpful message
|  when 'help' is typed at the Python interactive prompt.
|
|  Calling help() at the Python prompt starts an interactive help session.
|  Calling help(thing) prints help for the python object 'thing'.
|
|  Methods defined here:
|
|  __call__(self, *args, **kwds)
|Call self as a function.
|
|  __repr__(self)
|Return repr(self).
|
|  ----------------------------------------------------------------------
|  Data descriptors defined here:
|
|  __dict__
|dictionary for instance variables (if defined)
|
|  __weakref__
|list of weak references to the object (if defined)
>>>

Hay mucha información, si aprendemos a descifrarla. En la primera línea nos indica que Help está definido como un objeto de la clase _Helper en el módulo _sitebuiltins. Los identificadores que comienzan por guión bajo _ existen para uso del sistema. Por supuesto, esto es una convención de los programadores dado que en Python todo está "a la vista". De la misma manera, identificadores que empiezan y comienzan por doble guión bajo han sido diseñados para el funcionamiento interno de los objetos y no para ser empleados "desde fuera". Una vez más, sigue siendo una convención. Como nosotros queremos ver las "tripas" del sistema no vamos a arredrarnos ante esas convenciones.

A continuación nos explica que se trata de un envoltorio del objeto Helper definido en el módulo pydoc y que proporciona un mensaje de ayuda al introducir "help" en el modo interactivo. Después enumera los métodos y atributos del objeto. El método __call__() implica que un objeto es invocable, podemos teclear su nombre seguido de paréntesis y los argumentos indicados. El método __repr__() devuelve una representación textual del objeto destinada al programador. Cuando en el intérprete tecleamos un nombre que representa algo comprensible para él nos devuelve dicha representación invocando este método si el objeto dispone de él.

El parámetro self que vemos tantas veces permite que se invoque el método para un objeto concreto entre todos los que podemos instanciar a partir de una definición de clase. Un poco más adelante empezaremos a profundizar en la OOPProgramación orientada a objetos. En cualquier caso para nosotros es como si no existiera, el sistema proporciona la autoreferencia a los diferentes objetos.

Vamos a comprobar la primera información. Usa la opcíón File->Open Module (ALT+M) e introduce el nombre _sitebuiltins. Al final del fichero, que es corto, verás la declaración de la clase (con la palabra reservada class) _Helper.

class _Helper(object):
"""Define the builtin 'help'.

This is a wrapper around pydoc.help that provides a helpful message
when 'help' is typed at the Python interactive prompt.

Calling help() at the Python prompt starts an interactive help session.
Calling help(thing) prints help for the python object 'thing'.
"""

def __repr__(self):
return "Type help() for interactive help, " \
"or help(object) for help about object."
def __call__(self, *args, **kwds):
import pydoc
return pydoc.help(*args, **kwds)

La clase _Helper deriva directamente de la clase object, que es la base de la jerarquía de clases de Python. Inmediatamente después de la definición de la clase hay una cadena de texto de varias líneas. Esto es lo que se llama docstring. Si en la primera línea de un módulo, o una definición de clase o de función ponemos un literal de texto, el sistema lo interpreta de una forma especial, cuando invocamos la orden help() sobre ese módulo, clase o función emplea ese texto para darnos información.

Esto quiere decir que la información de ayuda está directamente codificada dentro del código Python, y ha sido puesta ahí por el programador. Por cierto, es una buena costumbre para cuando creemos nuestra propia biblioteca de funciones: documentarlo todo lo mejor posible emplando docstrings.

En cuanto a los métodos que se definen, vemos que si solicitamos una representación textual de la clase devuelve la cadena de las líneas 99-100. Esto es exáctamente lo que vemos al teclear help en el intérprete. Por otra parte, si invocamos la clase con paréntesis el método __call__ importa el módulo pydoc y llama la función help() definida en este último. Por eso el texto nos indica que esto es tan solo un "envoltorio" para llamar a la auténtica función.

Vamos a crear nuestra biblioteca personal incluyendo docstrings y alguna función útil:

''' -----mylib.py-----

Una biblioteca personal con funciones diversas '''

import msvcrt, sys

def inIDLE():
''' Nos permite saber si estamos ejecutando
un programa en el entorno de IDLE.
Devuelve True si es así y False en caso contrario.'''
return("idlelib" in sys.modules)

def waitch():
''' Detiene la ejecución hasta que pulsemos cualquier tecla
Solo funciona desde la consola, en IDLE no tiene efecto.
No produce eco en pantalla. Está diseñada para poder ver
la salida de los programas de consola desde Windows.'''
if inIDLE():
return
while msvcrt.kbhit():
pass
while not msvcrt.kbhit():
pass
return

Hemos incluído la función inIDLE() que ya utilizamos en la primera sección con colorama, y otra función muy útil que si se invoca al final de un programa nos permite ejecutar este desde el explorador de windows (con un doble click) y mantiene la consola abierta hasta que pulsemos una tecla para poder ver el resultado. Es más elegante que input() porque no requiere pulsar INTRO y no produce eco, es decir, no aparece en pantalla el caracter correspondiente a la tecla pulsada. Guarda el módulo y ve a la ventana del shell de IDLE.

>>> import mylib
>>> help(mylib)
Help on module mylib:

NAME

mylib - -----mylib.py-----

DESCRIPTION
Una biblioteca personal con funciones diversas

FUNCTIONS
inIDLE()
Nos permite saber si estamos ejecutando
un programa en el entorno de IDLE.
Devuelve True si es así y False en caso contrario.

waitch()
Detiene la ejecución hasta que pulsemos cualquier tecla
Solo funciona desde la consola, en IDLE no tiene efecto.
No produce eco en pantalla. Está diseñada para poder ver
la salida de los programas de consola desde Windows.

FILE
c:\users\miguel\documents\desarrollo\python\mylib.py

>>>

Vemos dos cosas. Podemos importar el módulo y usarlo como cualquier librería. Invocaríamos las funciones usando el nombre del módulo, un punto y el nombre de la función con los paréntesis. Esto significa que podemos ir ampliando nuestra biblioteca con todas aquellas funciones que nos resulten útiles, y disponer de ellas en adelante sin volver a teclearlas.

La segunda cosa es el funcionamiento de la documentación. Al usar help() con el nombre de nuestro módulo vemos toda la información no solo del módulo sino de cada función o valor definidos en él (y que dispongan de la correspondiente docstring). También podemos solicitar ayuda sobre una función en concreto:

>>> help(mylib.inIDLE)
Help on function inIDLE in module mylib:

inIDLE()
Nos permite saber si estamos ejecutando
un programa en el entorno de IDLE.
Devuelve True si es así y False en caso contrario.

>>> help(mylib.waitch)
Help on function waitch in module mylib:

waitch()
Detiene la ejecución hasta que pulsemos cualquier tecla
Solo funciona desde la consola, en IDLE no tiene efecto.
No produce eco en pantalla. Está diseñada para poder ver
la salida de los programas de consola desde Windows.

>>>

Para terminar, si tecleamos help() sin argumentos en el intérprete interactivo, entraremos en un modo especial de ayuda, en el que podemos teclear directamente nombres de modulos o funciones, pero también obtener listas de los módulos disponibles, las palabras clave, los símbolos y una lista de tópicos que es ideal para los curiosos. No es necesario usar comillas pero hay que respetar las mayúsculas como ocurre habitualmente en Python.

Ocurrirá si curioseas con ello que en algún momento te saldrá un recuadro amarillo indicando que hay una cantidad grande de líneas que han sido "comprimidas" para no desbordarte la pantalla. Tienes dos opciones, con el botón derecho puedes acceder a un cómodo visor o copiar el texto. La segunda es hacer doble click y se desplegarán las líneas comprimidas. Esta forma es menos cómoda.

3.1.2 Namespaces. Ámbitos de los identificadores

Vimos muy por encima en el punto 2.2.4 que los identificadores tienen significado dentro de un ámbito (o alcance; en inglés scope) y que por ello podemos definir en distintas funciones variables con nombres idénticos sin que haya conflicto entre ellas. Vamos ahora a ver en profundidad el mecanismo de gestión de los identificadores que usa el lenguaje Python.

Sabemos que los identificadores son literales de texto sujetos a una serie de reglas sintácticas que representan objetos Python (valores de cualquier tipo, funciones, etc.). Da la casualidad de que hay una similitud muy grande con uno de los tipos que hemos visto hasta ahora, los diccionarios, que asocian una clave con un valor. En este caso la clave sería el identificador y el valor el objeto representado.

Si en algún momento has sentido curiosidad acerca de cómo Python sabe de qué manera una determinada palabra se asocia con una función o valor, es muy sencillo. Hay una serie de diccionarios en los que se guardan todas las palabras definidas por el sistema o el usuario y se correlacionan con un significado (un objeto). Incluso las palabras claves y las funciones o valores incorporados al intérprete están almacenadas en los diccionarios, puesto que no se trata de un único diccionario sino de múltiples.

Observa el siguiente código:

'''
Banco de pruebas del alcance de las variables 01
'''

x="Global"

def f():
def g():
print(f"Desde g() x vale \"{x}\"")
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

Si ejecutas el programa el resultado será:

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 01.py =============
Desde g() x vale "Global"
Desde f() x vale "Global"
Desde __main__ x vale "Global"
>>>

La variable x ha sido definida dentro del cuerpo principal del programa, así como la función f(). La función g() está definida dentro de la función f(). Sin embargo x es visible para todos ellos. En la línea 14 hemos empleado otra variable global pero esta vez se trata de una variable del sistema. Si ejecutamos directamente un programa __name__ adopta el valor "__main__", en cambio, si usamos una sentencia import para cargar el fichero o módulo adoptará el nombre del mismo.

Como g() ha sido definida dentro de la función f() pertenece al ámbito local de dicha función y no es visible fuera de ella. Sin cerrar la ventana del shell de Python prueba lo siguiente:

>>> g()
Traceback (most recent call last):
File "<pyshell#0>", line 1, in <module>
g()
NameError: name 'g' is not defined

>>> dir()
['__annotations__', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'f', 'x']
>>>

La función dir() sin argumentos nos devuelve las claves del diccionario (el namespace) perteneciente al ámbito en que sea invocada. Como la hemos llamado desde el intérprete nos devuelve el diccionario global establecido para nuestro programa. Vemos unas cuantas entradas del sistema (con doble subrayado antes y después del nombre), entre ellas la entrada __name__ que usamos anteriormente. Lo que en este momento nos interesa son aquellas entradas sin subrayados, que se corresponden con nuestra variable x y la función f. Por supuesto, no hay rastro de la función g en este nivel.

En realidad, en este momento no podemos encontrar rastro de la función g() en ningún sitio, dado que el namespace de una función se crea al ser invocada y se elimina cuando la función termina. Esto constituye un espacio de nombres local. Es por esto por lo que podemos usar los mismos nombres de variables dentro de una función sin afectar a los valores "exteriores".

Modifica el programa de la siguiente forma:

'''
Banco de pruebas del alcance de las variables 01
'''

x="Global"

def f():
def g():
print(f"Desde g() x vale \"{x}\"")
g()
print(f"Desde f() x vale \"{x}\"")
breakpoint()

f()
print(f"Desde {__name__} x vale \"{x}\"")

La ejecución de esta nueva versión es un tanto diferente:

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 01.py =============
Desde g() x vale "Global"
Desde f() x vale "Global"
--Return--
> c:\users\miguel\documents\desarrollo\python\ámbitos 01.py(12)f()->None
-> breakpoint()
(Pdb)
dir()
['__return__', 'g']
(Pdb)
print(__return__)
None
(Pdb)
g
<function f.<locals>.g at 0x0000021E75632280>
(Pdb)
c
Desde __main__ x vale "Global"
>>>

La función breakpoint() detiene la ejecución del código y lanza el depurador de Python (Pdb). Un depurador es una herramienta de programación que nos permite examinar el código durante su ejecución para localizar y corregir errores. Vemos que el programa ha discurrido con normalidad hasta llegar a la línea 12, donde ha sido detenido y hemos entrado en un modo especial indicado por el prompt. Como hemos interrumpido dentro de la función f() esta vez la orden dir() nos muestra el namespace local de la función, que contiene dos entradas: la función g() y un valor del sistema llamado __return__. Aquí se almacena el valor de retorno de la función, que en este caso es None.

En Python todas las funciones devuelven un valor de retorno. Si no asignamos uno explícitamente devuelven None, que representa el valor "nada".

El depurador Pdb incluye una serie de órdenes especiales, en concreto c produce la continuación normal del programa, como podemos ver. Volvamos a modificar el programa:

'''
Banco de pruebas del alcance de las variables 02
'''

x="Hola"

def f():
def g():
print(f"Desde g() x vale \"{x}\"")
x+=", Mundo"
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

La diferencia es que modificamos el valor de la variable desde la función g(). Ejecútalo y ocurrirá esto:

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 02.py =============
Traceback (most recent call last):
File "C:\Users\Miguel\Documents\Desarrollo\Python\Ámbitos 02.py", line 15, in <module>
f()
File "C:\Users\Miguel\Documents\Desarrollo\Python\Ámbitos 02.py", line 11, in f
g()
File "C:\Users\Miguel\Documents\Desarrollo\Python\Ámbitos 02.py", line 9, in g
print(f"Desde g() x vale \"{x}\"")
UnboundLocalError: local variable 'x' referenced before assignment

>>>

Observa la información de trazado del error. Desde la línea 15 del programa (indicado por <module>) se invoca la función f(). Desde la línea 11, esta vez dentro del ámbito de f se invoca g() y el error se produce en la línea 9 dentro del ámbito de esta última en una sentencia que en la versión inicial funcionaba perfectamente.

La clave del problema está en la línea 10, en la que intentamos modificar la variable x. Al hacer esto el intérprete asume que se trata de una variable local, y como no la encuentra en este ámbito produce el error en cuanto intentamos acceder a ella.

En un programa podemos distinguir cuatro tipos de ámbitos, desde el más amplio al más limitado.

  1. Incorporados (builtins)
    (Pertenecen al propio intérprete)
  2. Global
    (Pertenecen al programa principal)
  3. Externo (enclosing)
    (Pertenecen a funciones superiores)
  4. Local
    (Pertenecen a la función en curso)
Builtins Global Enclosing Local

Cuando nos referimos a un identificador (variable, función o cualquier objeto) el intérprete busca en orden inverso, desde el ámbito más próximo al más lejano, la primera coincidencia que encuentre. Si usamos una sentencia de asignación de cualquier clase, se asume que el valor es local. Vamos a dar otra vuelta al programita:

'''
Banco de pruebas del alcance de las variables 02
'''

x="Hola"

def f():
def g():
global x
print(f"Desde g() x vale \"{x}\"")
x+=", Mundo"
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

Hemos añadido una sentencia en la línea 9 declarando que la variable x es global. Ahora podemos no solo leer su valor sino modificarlo. El resultado será:

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 02.py =============
Desde g() x vale "Hola"
Desde f() x vale "Hola, Mundo"
Desde __main__ x vale "Hola, Mundo"
>>>

Esta vez funciona y la función g() puede modificar la variable global. Vamos a añadir una nueva modificación:

'''
Banco de pruebas del alcance de las variables 02
'''

x="Hola"

def f():
x="Soy de f()"
def g():
global x
print(f"Desde g() x vale \"{x}\"")
x+=", Mundo"
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

Esta vez declaramos una nueva variable x en la función f().

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 02.py =============
Desde g() x vale "Hola"
Desde f() x vale "Soy de f()"
Desde __main__ x vale "Hola, Mundo"
>>>

La función g() accede sin problemas a la variable x independientemente de lo declarado en f(). De nuevo probemos otra pequeña modificación, quitando las líneas 10 con la declaración de x como global y 12 con la modificación de la variable.

'''
Banco de pruebas del alcance de las variables 02
'''

x="Hola"

def f():
x="Soy de f()"
def g():
print(f"Desde g() x vale \"{x}\"")
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

El resultado es correcto, la función g() encuentra una variable x en la función f() que la "envuelve" y utiliza su valor.

============= RESTART: C:/Users/User/Documents/Python/Ámbitos 02.py =============
Desde g() x vale "Soy de f()"
Desde f() x vale "Soy de f()"
Desde __main__ x vale "Hola"
>>>

Prueba a volver a añadir la línea con la modificación de la variable x después de la línea 10 y ejecútalo. Se vuelve a producir un error porque ya no se busca la variable hacia "arriba" sino que se supone que es local. Si queremos utilizar una variable de un ámbito externo tenemos que utilizar una nueva palabra clave:

'''
Banco de pruebas del alcance de las variables 02
'''

x="Hola"

def f():
x="Soy de f()"
def g():
nonlocal x
print(f"Desde g() x vale \"{x}\"")
x+=", Mundo"
g()
print(f"Desde f() x vale \"{x}\"")

f()
print(f"Desde {__name__} x vale \"{x}\"")

Observa la línea 10. Hemos utilizado nonlocal para identificar la variable x. De ese modo podemos acceder a ella plenamente y modificar su valor además de leerlo.

Cuando importamos un módulo se crea también un diccionario global privado para dicho módulo, que es accesible desde nuestro programa empleando el nombre de dicho diccionario, que coincide con el del módulo o el alias utilizado, y separar los identificadores con un punto. Esto es lo que ya hemos practicado cuando hemos empleado importaciones de bibiotecas. Sin embargo, si importamos elementos concretos, o todo la biblioteca con la forma:

from módulo import *

Se añaden los identificadores directamente al diccionario global de nuestro programa y por eso funciona emplear el nombre sin otras referencias. Esto tiene el peligro de solaparse encima de identificadores ya existentes y privarnos de ellos. En cualquier caso no debemos abusar del mecanismo de acceso a variables en ámbitos superiores. En general es mejor emplear argumentos en las llamadas a funciones y valores de retorno, pero la idea es entender cómo funciona.

3.1.3 Clases y objetos. Introducción a la OOP

Ya tenemos un amplio conocimiento del lenguaje y hemos oído hablar frecuentemente de objetos, métodos y otros conceptos relacionados con la Programación orientada a objetos (Object oriented programming). Vamos a explicar en que consiste este paradigma de programación y qué lo diferencia de la programación imperativa y estructurada que practicamos hasta el momento.

La principal característica de la OOP es la desaparición de la frontera entre los dos grandes tipos de elementos de la programación clásica: datos y código. Tradicionalmente los datos representan información, el material sobre el que trabajan los programas, y el código representa procesos de manipulación sobre dichos datos, los instrumentos con los que modelamos y trabajamos dicho material. Hay una separación radical entre ambas categorías, y si modificamos la estructura de los datos puede ser muy complejo adaptar los códigos para asumir esa modificación.

Aquí interviene la idea principal de la formación de objetos: un objeto contiene no solo los datos sino los códigos que manejan dichos datos. De esta manera, como con los objetos de la vida real, podemos construir programas juntando unos objetos con otros de la forma adecuada y no necesitamos pensar en cómo está organizado el objeto sino en su comportamiento, el interfaz que nos presenta.

Como es más sencillo entender los conceptos con la práctica, vamos a crear nuestra primera clase:

'''
Implementa una clase para gestionar números racionales
'''

class fracción:

n=None
d=None

def __init__(self, n, d):
self.n=n
self.d=d

def __repr__(self):
return f"{self.n}/{self.d}"

La palabra reservada class sirve para definir una clase, de la misma manera que def se usa para definir una función. A continuación empleamos un identificador como nombre de la clase y luego en un bloque de código definimos el contenido de la clase, atributos (valores) y métodos (funciones)

Hemos llamado fracción a nuestra clase y hemos definido dos atributos: n y d que representan el numerador y el denominador, y dos métodos privados: __init__ que es el método invocado por el constructor de la clase para inicializar un objeto al ser creado y __repr__ que como comentamos en el anterior epígrafe es el método de representación de los objetos, invocado automáticamente cuando se solicita esta. La ejecución del programa no genera ninguna salida, puesto que se limita a crear la clase, pero a partir de ese momento podemos usar fracción en nuestro código.

============= RESTART: C:/Users/User/Documents/Python/Fracción.py =============
>>> f=fracción(1,2)
>>> f
1/2
>>>

Aclaremos conceptos: Hemos creado una clase. Esto representa un molde o modelo pero no algo concreto. Para generar objetos tenemos que instanciar la clase, que es tan sencillo como invocar el constructor que es el nombre de la clase con paréntesis y los argumentos que se requieran para inicializar el objeto. Verás que en los métodos de nuestra clase el primer parámetro es siempre self. Esto es una referencia a la instancia para la cual se invoca el método, es decir, el objeto desde el cual lo invocamos. Nosotros no tenemos que incluir este argumento, el proceso es automático. Solamente hemos de proporcionar los valores después de la referencia self, en este caso el del numerador y el del denominador.

La expresión fracción(1,2) genera un objeto de clase fracción con esos valores para sus atributos y la asignación almacena la referencia al objeto en la variable f. Al escribir la variable a secas en el modo interactivo Python trata de mostrarnos el objeto invocando el método __repr__ y obtenemos el resultado esperado.

>>> f.n
1
>>> f.d
2
>>> type(f)
<class '__main__.fracción'>
>>>

Como en Python no hay secretos, podemos acceder directamente a los atributos del objeto si conocemos sus nombres. Vemos también que el tipo de f es fracción y está referido al ámbito global de nuestro programa.

>>> dir()
['__annotations__', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'f', 'fracción']
>>>

En el namespace del programa están los identificadores de nuestra clase y de la variable que hemos creado. Aunque la utilidad de nuestra clase aún no es muy clara, de momento el hecho de poder obtener una representación textual sin necesidad de pensar (ni de conocerla) en la organización interna de la clase es realmente cómodo. Esto mismo aplicado a otros procesos es lo que hace tan interesante la programación con objetos.

Añadamos unos métodos más a la clase fracción:

'''
Implementa una clase para gestionar números racionales
'''

import math

class fracción:

n=None
d=None

def __init__(self, n, d):
self.n=n
self.d=d

def __repr__(self):
return f"{self.n}/{self.d}"

def reduce(self):
"Simplifica la fracción"
mcd = math.gcd(self.n, self.d)
return fracción(self.n // mcd, self.d // mcd)

def __float__(self):
return self.n / self.d

def __add__(self, f):
return fracción((self.n * f.d) + (self.d * f.n), self.d * f.d)

Hemos añadido tres nuevos métodos:

reduce():  Simplifica la fracción
__float__():  Permite transformar la fracción en un valor float
__add__():  Permite sumar fracciones

============= RESTART: C:/Users/User/Documents/Python/Fracción.py =============
>>> f=fracción(1,2)
>>> g=fracción(1,4)
>>> f+g
6/8
>>> _.reduce()
3/4
>>> float(_)
0.75

Ahora empezamos a vislumbrar el potencial de los objetos. Podemos simplificar las fracciones, obtener su valor numérico y sumarlas. Y no necesitamos saber nada acerca de ellas salvo estas posibilidades. Es lo que venimos haciendo hasta ahora con los números, cadenas de texto, listas y demás tipos de datos. Así es como numpy crea el tipo array y otras librerías sus propias clases. Te habrás dado cuenta de que hay una relación entre el operador + y el método __add__. En efecto, cuando se usan los operadores sobre un objeto se busca el método correspondiente y este es el que hace el trabajo, por eso podemos sumar cadenas igual que lo hacemos con valores numéricos, porque la clase str tiene definido una operación de suma que realiza la concatenación. Cada operador se refleja en un método del que pueden disponer los objetos si lo consideramos adecuado.

Apliquemos la función help() a nuestra clase:

>>> help(fracción)
Help on class fracción in module __main__:

class fracción(builtins.object)
|  fracción(n, d)
|
|  Methods defined here:
|
|  __add__(self, f)
|
|  __float__(self)
|
|  __init__(self, n, d)
|Initialize self. See help(type(self)) for accurate signature.
|
|  __repr__(self)
|Return repr(self).
|
|  reduce(self)
|Simplifica la fracción
|
|  ----------------------------------------------------------------------
|  Data descriptors defined here:
|
|  __dict__
|dictionary for instance variables (if defined)
|
|  __weakref__
|list of weak references to the object (if defined)
|
|  ----------------------------------------------------------------------
|  Data and other attributes defined here:
|
|  d = None
|
|  n = None

>>>

Nos da una sorprendente cantidad de información, incluyendo por supuesto todos los métodos y atributos que hemos definido. Vemos la docstring del método reduce() y también información sobre los métodos __init__() y __repr__(). Vemos en la definición de la clase que hereda de la clase object. Todas las clases de Python son descendientes de object, que es la base del sistema de objetos.

Aprovechamos para detallar las características de la programación orientada a objetos implementadas en Python:

Características de la Programación Orientada a Objetos en Python
Abstracción
Es el principio que ya hemos mencionado; conocemos y empleamos el objeto a partir de aquellos métodos y atributos que muestra hacia el exterior. No necesitamos saber cómo funcionan las cosas dentro de él.
Encapsulamiento
En este caso nos referimos a la capacidad de ocultación del interior del objeto. Como Python es interpretado y no dispone de ninguna forma de crear identificadores privados es solo relativo.
Polimorfismo
Métodos diferentes para diferentes clases pueden compartir el mismo nombre.
Sobrecarga de operadores
Es una de las formas de implementar el polimorfismo. Cada clase puede redefinir los operadores para funcionar con objetos de esa clase.
Herencia
Una clase puede ser "hija" de otras, con lo cual hereda sus características que luego podemos ampliar o modificar. Podemos reutilizar las clases adaptándolas.

Vamos a ver cómo se implementa la sobrecarga de operadores. Adaptemos la función __add__ para aceptar suma de fracciones con enteros. De paso modifiquemos declaración de la función __init__ de la siguiente forma:

def __init__(self, n, d = 1):

Así podemos convertir números enteros en fracciones más fácilmente. Añade un nuevo método y modifica el método __add__ como sigue:

def test(valor):
if type(valor) == fracción:
return valor
if type(valor) == int:
return fracción(valor)
raise TypeError("Valor inadecuado para fracción")

def __add__(self, f):
f = fracción.test(f)
return fracción((self.n * f.d) + (self.d * f.n), self.d * f.d)

Con esta revisión podemos sumar fracciones entre sí y también fracciones con un valor entero. También podemos usar el constructor con un valor entero o con un par numerador/denominador.

============= RESTART: C:/Users/User/Documents/Python/Fracción.py =============
>>> f=fracción(1,2)
>>> f+1
3/2
>>>

Vamos a seguir ampliando las posibilidades de nuestra clase. Cambiaremos la docstring del módulo y la colocaremos dentro de la clase para que nos proporcione información de ayuda, también moveremos la importación de math dentro de la clase directamente. Añadimos métodos para invertir la fracción y para implementar la suma, multiplicación y división. Por último, vamos a añadir la posibilidad de usar números float con nuestras fracciones. Como se modifica mucho la estructura del programa lo ponemos entero:

# Fracción versión 2

class fracción:
'''
Implementa una clase para gestionar números racionales
'''
from math import gcd, modf

n=None
d=None

def __init__(self, n, d = 1):
if type(n) == int and type(d) == int:
self.n = n
self.d = d
else:
n = fracción.test(n)
d = fracción.test(d).inverse()
self.n = n.n * d.n
self.d = n.d * d.d

def __repr__(self):
return f"{self.n}/{self.d}"

def inverse(self):
"Devuelve la fracción inversa"
return fracción(self.d,self.n)

def reduce(self):
"Simplifica la fracción"
mcd = fracción.gcd(self.n, self.d)
return fracción(self.n // mcd, self.d // mcd)

def __float__(self):
return self.n / self.d

def test(valor):
if type(valor) == fracción:
return valor
if type(valor) == int:
return fracción(valor)
if type(valor) == float:
d, _ = fracción.modf(valor)
e = 10 ** (len(str(d)) - 2)
return fracción(int(valor * e), e).reduce()
raise TypeError("Valor inadecuado para fracción")

def __add__(self, f):
f = fracción.test(f)
return fracción((self.n * f.d) + (self.d * f.n), self.d * f.d)

def __sub__(self, f):
f = fracción.test(f)
return fracción((self.n * f.d) - (self.d * f.n), self.d * f.d)

def __mul__(self, f):
f = fracción.test(f)
return fracción(self.n * f.n, self.d * f.d)

def __truediv__(self, f):
f = fracción.test(f)
return fracción(self.n * f.d, self.d * f.n)

El constructor ahora emplea el método test para los valores que no sean enteros. De esta manera podremos ampliar las posibilidades simplemente modificando este método. Igualmente lo hacen todos los métodos que son llamados para las operaciones suma, resta, multiplicación y división. Fíjate en el nombre de este último método, no es __div__ como podríamos suponer. Guarda el módulo, no lo ejecutes. Abre luego una nueva ventana del shell de IDLE.

>>> from Fracción import fracción
>>> f=fracción(.5)
>>> f
1/2
>>> g=fracción(1,.75)
>>> g
4/3
>>> f+.5
4/4
>>> f/2
1/4
>>> g.inverse()
3/4
>>> g/f
8/3
>>>

La primera novedad es que no necesitamos ejecutar el programa, simplemente importamos la clase fracción desde el módulo Fracción y a partir de ahí disponemos de nuestro tipo de datos. Ahora podemos combinar fracciones con números enteros o flotantes sin problemas, pero solo si lo hacemos en este orden. Si tratamos de operar un número con una fracción se producirá un error.

>>> 1+f
Traceback (most recent call last):
File "<pyshell#15>", line 1, in <module>
1+f
TypeError: unsupported operand type(s) for +: 'int' and 'fracción'

>>>

Lo mismo ocurre con los números float, pero hay una solución muy simple, que consiste en usar los métodos reflejados previstos por el lenguaje. Veamos cuales son todos los métodos que se utilizan para efectuar los cálculos de los diferentes operadores:

Operadores aritméticos
DirectoRefejado
__add__(self, otro) __radd__(self, otro) + Suma
__sub__(self, otro) __rsub__(self, otro) - Resta
__mul__(self, otro) __rmul__(self, otro) * Producto
__matmul__(self, otro) __rmatmul__(self, otro) @ Producto matricial
__truediv__(self, otro) __rtruediv__(self, otro) / División
__floordiv__(self, otro) __rfloordiv__(self, otro) // División entera
__mod__(self, otro) __rmod__(self, otro) % Módulo
__divmod__(self, otro) __rdivmod__(self, otro) divmod() Devuelve cociente entero y resto
__pow__(self, otro) __rpow__(self, otro) **
pow()
Potencia
Operadores a nivel de bits
DirectoReflejado
__lshift__(self, otro) __rlshift__(self, otro) << Desplazamiento a la izquierda
__rshift__(self, otro) __rrshift__(self, otro) >> Desplazamiento a la derecha
__and__(self, otro) __rand__(self, otro) & and booleano
__xor__(self, otro) __rxor__(self, otro) ^ xor booleano
__or__(self, otro) __ror__(self, otro) | or booleano
Operadores de asignación aumentados
__iadd__(self, otro) += Asignación con suma
__isub__(self, otro) -= Asignación con resta
__imul__(self, otro) *= Asignación con producto
__imatmul__(self, otro) @= Asignación con producto matricial
__itruediv__(self, otro) /= Asignación con división
__ifloordiv__(self, otro) //= Asignación con división entera
__imod__(self, otro) %= Asignación con módulo
__ipow__(self, otro) **= Asignación con potencia
__ilshift__(self, otro) <<= Asignación con desplazamiento a la izquierda
__irshift__(self, otro) >>= Asignación con desplazamiento a la derecha
__iand__(self, otro) &= Asignación con and booleano
__ixor__(self, otro) ^= Asignación con xor booleano
__ior__(self, otro) |= Asignación con or booleano
Operadores relacionales
__lt__(self, otro) < Menor que
__le__(self, otro) <= Menor o igual que
__eq__(self, otro) == Igual a
__ne__(self, otro) != Distinto de
__gt__(self, otro) > Mayor que
__ge__(self, otro) >= Mayor o igual que
Otros operadores
__bool__(self) bool() Conversión a valor booleano
__neg__(self) - Negación unaria (cambio de signo)
__pos__(self) + Signo positivo unario
__abs__(self) abs() Valor absoluto
__invert__(self) ~ Complemento a dos
__complex__(self) complex() Conversión a valor complejo
__int__(self) int() Conversión a valor entero
__float__(self) float() Conversión a valor flotante
__round__(self)
__round__(self, dígitos)
round() Redondeo
__trunc__(self) math.trunc() Truncado de decimales
__floor__(self) math.floor() Redondeo hacia abajo
__ceil__(self) math.ceil() Redondeo hacia arriba

Estos son los métodos que podemos emplear para tipos de datos numéricos. Vamos a implementar simplemente los métodos aritméticos reflejados de los que ya teníamos para poder usar nuestra clase fracción con otros números de forma natural. Vamos a implementar también los operadores de comparación < (menor que) y == (igual a). Con estos dos ya podemos establecer comparaciones de cualquier clase dado que el intérprete es capaz de calcular el resto a partir de ellos.

Nuestra clase fracción al completo (de momento):

# Fracción versión 3

class fracción:
'''
Implementa una clase para gestionar números racionales
'''
from math import gcd, modf

n=None
d=None

def __init__(self, n, d = 1):
if type(n) == int and type(d) == int:
self.n = n
self.d = d
else:
n = fracción.test(n)
d = fracción.test(d).inverse()
self.n = n.n * d.n
self.d = n.d * d.d

def __repr__(self):
return f"{self.n}/{self.d}"

def inverse(self):
"Devuelve la fracción inversa"
return fracción(self.d,self.n)

def reduce(self):
"Simplifica la fracción"
mcd = fracción.gcd(self.n, self.d)
return fracción(self.n // mcd, self.d // mcd)

def __float__(self):
return self.n / self.d

def test(valor):
if type(valor) == fracción:
return valor
if type(valor) == int:
return fracción(valor)
if type(valor) == float:
d, _ = fracción.modf(valor)
e = 10 ** (len(str(d)) - 2)
return fracción(int(valor * e), e).reduce()
raise TypeError("Valor inadecuado para fracción")

def __add__(self, f):
f = fracción.test(f)
return fracción((self.n * f.d) + (self.d * f.n), self.d * f.d)

def __radd__(self, f):
return self.__add__(f)

def __sub__(self, f):
f = fracción.test(f)
return fracción((self.n * f.d) - (self.d * f.n), self.d * f.d)

def __rsub__(self, f):
f = fracción.test(f)
return fracción((f.n * self.d) - (f.d * self.n), f.d * self.d)

def __mul__(self, f):
f = fracción.test(f)
return fracción(self.n * f.n, self.d * f.d)

def __rmul__(self, f):
return self.__mul__(f)

def __truediv__(self, f):
f = fracción.test(f)
return fracción(self.n * f.d, self.d * f.n)

def __rtruediv__(self, f):
f = fracción.test(f)
return fracción(f.n * self.d, f.d * self.n)

def __lt__(self, f):
f = fracción.test(f)
return self.__float__() < f.__float__()

def __eq__(self, f):
f = fracción.test(f)
return self.__float__() == f.__float__()

Puedes importar la clase como en el ejemplo anterior y probar las diferentes combinaciones de las cuatro operaciones y las comparaciones con valores de tipo int, float o fracción. Y de momento dejamos aquí la programación orientada a objetos y pasamos al siguiente escalón.

3.1.4 La función dir() y la biblioteca inspect

Ya conocemos la función dir(), pero ahora que sabemos que todos los identificadores de Python se gestionan mediante diccionarios verás que es una formidable herramienta para indagar entre los engranajes del lenguaje. Por defecto, si no pasamos argumentos, dir nos devuelve las claves del diccionario global en una lista. Dependiendo de en qué momento ejecutemos la orden puede ser el diccionario de nuestro programa o de un módulo.

Hay otras dos funciones incorporadas relacionadas con los namespaces, que son globals() y locals(). Cada una devuelve el diccionario que indica el nombre de la función, el diccionario completo, no solo las claves como hace dir(). Si las ejecutamos desde el intérprete interactivo o desde el primer nivel de un programa los diccionarios global y local son el mismo.

>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>}
>>> locals()
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <class '_frozen_importlib.BuiltinImporter'>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>}
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']
>>>

Para obtener información sobre las palabras clave tenemos el módulo keyword que ya vimos en la primera parte del curso. Para obtener todas las funciones y variables incorporadas tenemos el módulo builtins. En general existe un diccionario con todas las definiciones incorporadas llamado __builtins__ dentro del namespace global, pero hay implementaciones de Python en las que no es así, por lo que es más seguro usar el módulo.

'''
Obtener las funciones incorporadas
'''

import builtins
from types import *

builtfuncs=[name for name, obj in vars(builtins).items()
if isinstance(obj, (BuiltinFunctionType, BuiltinMethodType,
FunctionType, MethodType)) and not name.startswith("__")]

print(sorted(builtfuncs))

Usamos la biblioteca builtins para obtener todos los objetos incorporados. Hay varias funciones nuevas: vars(obj) devuelve el diccionario incorporado a cualquier módulo, clase o instancia de objeto en el atributo __dict__, si este existe. En caso contrario se produce una excepción TypeError. Extraído el diccionario iteramos sobre los items, es decir, los pares clave:valor y guardamos las claves en una lista por comprensión si el valor es del tipo función o método y el nombre (la clave) no empieza por un doble subrayado. Para saber los tipos hemos importado el contenido de la biblioteca types, que proporciona descripciones de tipos no accesibles directamente desde los nombres incorporados y empleamos la función isinstance(), que acepta un objeto como primer elemento y una clase o lista de clases como segundo, y devuelve True si el objeto pertenece a alguna de las clases en la lista. He aquí el resultado:

============= RESTART: C:/Users/User/Documents/Python/Builtfuncs.py =============
['abs', 'all', 'any', 'ascii', 'bin', 'breakpoint', 'callable', 'chr', 'compile', 'delattr', 'dir', 'divmod', 'eval', 'exec', 'format', 'getattr', 'globals', 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'open', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars']
>>>

A partir de la lista de funciones incorporadas podemos obtener más información con la función help() sobre cualquiera de ellas. Podríamos igualmente obtener la lista de excepciones y errores incorporados.

>>> import builtins
>>> errores = [n for n in dir(builtins) if "Error" in n or "Exception" in n or "Warning" in n]
>>> errores
['ArithmeticError', 'AssertionError', 'AttributeError', 'BaseException', 'BlockingIOError', 'BrokenPipeError', 'BufferError', 'BytesWarning', 'ChildProcessError', 'ConnectionAbortedError', 'ConnectionError', 'ConnectionRefusedError', 'ConnectionResetError', 'DeprecationWarning', 'EOFError', 'EnvironmentError', 'Exception', 'FileExistsError', 'FileNotFoundError', 'FloatingPointError', 'FutureWarning', 'IOError', 'ImportError', 'ImportWarning', 'IndentationError', 'IndexError', 'InterruptedError', 'IsADirectoryError', 'KeyError', 'LookupError', 'MemoryError', 'ModuleNotFoundError', 'NameError', 'NotADirectoryError', 'NotImplementedError', 'OSError', 'OverflowError', 'PendingDeprecationWarning', 'PermissionError', 'ProcessLookupError', 'RecursionError', 'ReferenceError', 'ResourceWarning', 'RuntimeError', 'RuntimeWarning', 'SyntaxError', 'SyntaxWarning', 'SystemError', 'TabError', 'TimeoutError', 'TypeError', 'UnboundLocalError', 'UnicodeDecodeError', 'UnicodeEncodeError', 'UnicodeError', 'UnicodeTranslateError', 'UnicodeWarning', 'UserWarning', 'ValueError', 'Warning', 'WindowsError', 'ZeroDivisionError']

En realidad en esta lista nos faltan varias excepciones especiales, que se utilizan para gestionar iteradores o la interrupción de los programas: StopAsyncIteration, StopIteration, GeneratorExit, SystemExit y KeyboardInterrupt. También podemos aprender más sobre cada error invocando la función help() sobre ellos. Vamos a investigar los tipos que podemos encontrar en la biblioteca types empleando el mismo sistema.

>>> import types
>>> tipos=sorted([name for name, obj in vars(types).items() if name[0]!="_" and name.endswith("Type")])
>>> tipos
['AsyncGeneratorType', 'BuiltinFunctionType', 'BuiltinMethodType', 'CellType', 'ClassMethodDescriptorType', 'CodeType', 'CoroutineType', 'FrameType', 'FunctionType', 'GeneratorType', 'GetSetDescriptorType', 'LambdaType', 'MappingProxyType', 'MemberDescriptorType', 'MethodDescriptorType', 'MethodType', 'MethodWrapperType', 'ModuleType', 'TracebackType', 'WrapperDescriptorType']
>>>

Y a estos debemos sumar los tipos incorporados:

'''
Listado de tipos incorporados
'''

import builtins

builtypes = []
for n, o in vars(builtins).items():
if type(o)!=type:
continue
if n[0]=="_" or "Error" in n or "Warning" in n \
or "Exception" in n or "Stop" in n \
or "Exit" in n or "Interrupt" in n:
continue
builtypes.append(n)

print(builtypes)

============= RESTART: C:/Users/User/Documents/Python/Builtypes.py =============
['bool', 'memoryview', 'bytearray', 'bytes', 'classmethod', 'complex', 'dict', 'enumerate', 'filter', 'float', 'frozenset', 'property', 'int', 'list', 'map', 'object', 'range', 'reversed', 'set', 'slice', 'staticmethod', 'str', 'super', 'tuple', 'type', 'zip']
>>>

Vemos que aún nos quedan unas cuantas cosas que aprender sobre Ptyhon pero poco a poco llegaremos a controlar cada uno de los tipos consignados en estas listas. Este método está bien para revisar namespaces y diccionarios, pero si queremos un mayor nivel de detalle acerca de cualquier tipo de objeto que estemos empleando en ese momento debemos recurrir a la biblioteca inspect, que proporciona funciones para examinar objetos durante la misma ejecución del código. Puede resultar especialmente útil para escudriñar en módulos y en objetos. La función que más emplearemos será inspect.getmembers() que nos devuelve una lista con los miembros del módulo u objeto observado. Vamos a probarla para obtener todas las funciones del módulo math:

>>> import math, inspect
>>> [n for n in inspect.getmembers(math, inspect.isroutine)]
[('acos', <built-in function acos>), ('acosh', <built-in function acosh>), ('asin', <built-in function asin>), ('asinh', <built-in function asinh>), ('atan', <built-in function atan>), ('atan2', <built-in function atan2>), ('atanh', <built-in function atanh>), ('ceil', <built-in function ceil>), ('comb', <built-in function comb>), ('copysign', <built-in function copysign>), ('cos', <built-in function cos>), ('cosh', <built-in function cosh>), ('degrees', <built-in function degrees>), ('dist', <built-in function dist>), ('erf', <built-in function erf>), ('erfc', <built-in function erfc>), ('exp', <built-in function exp>), ('expm1', <built-in function expm1>), ('fabs', <built-in function fabs>), ('factorial', <built-in function factorial>), ('floor', <built-in function floor>), ('fmod', <built-in function fmod>), ('frexp', <built-in function frexp>), ('fsum', <built-in function fsum>), ('gamma', <built-in function gamma>), ('gcd', <built-in function gcd>), ('hypot', <built-in function hypot>), ('isclose', <built-in function isclose>), ('isfinite', <built-in function isfinite>), ('isinf', <built-in function isinf>), ('isnan', <built-in function isnan>), ('isqrt', <built-in function isqrt>), ('ldexp', <built-in function ldexp>), ('lgamma', <built-in function lgamma>), ('log', <built-in function log>), ('log10', <built-in function log10>), ('log1p', <built-in function log1p>), ('log2', <built-in function log2>), ('modf', <built-in function modf>), ('perm', <built-in function perm>), ('pow', <built-in function pow>), ('prod', <built-in function prod>), ('radians', <built-in function radians>), ('remainder', <built-in function remainder>), ('sin', <built-in function sin>), ('sinh', <built-in function sinh>), ('sqrt', <built-in function sqrt>), ('tan', <built-in function tan>), ('tanh', <built-in function tanh>), ('trunc', <built-in function trunc>)]
>>>

La función getmembers() acepta como primer argumento un objeto y devuelve una lista con todos los miembros del objeto, atributos o métodos. Admite un segundo argumento (predicado) opcional que debe ser ejecutable y se invoca para cada uno de los miembros del objeto devolviendo un valor booleano. Si dicho valor es True el miembro es incluído en la lista resultante. La lista incluye una tupla para cada miembro encontrado con su nombre y su valor. Si solo deseamos obtener un listado de los nombres basta con indicar n[0] como primer elemento de la lista por comprensión.

>>> [n[0] for n in inspect.getmembers(math, inspect.isroutine)]
['acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'comb', 'copysign', 'cos', 'cosh', 'degrees', 'dist', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'isclose', 'isfinite', 'isinf', 'isnan', 'isqrt', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'perm', 'pow', 'prod', 'radians', 'remainder', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'trunc']
>>>

Si queremos obtener los nombres de todos los miembros del módulo math:

>>> [n[0] for n in inspect.getmembers(math) if n[0][0]!="_"]
>>> ['acos', 'acosh', 'asin', 'asinh', 'atan', 'atan2', 'atanh', 'ceil', 'comb', 'copysign', 'cos', 'cosh', 'degrees', 'dist', 'e', 'erf', 'erfc', 'exp', 'expm1', 'fabs', 'factorial', 'floor', 'fmod', 'frexp', 'fsum', 'gamma', 'gcd', 'hypot', 'inf', 'isclose', 'isfinite', 'isinf', 'isnan', 'isqrt', 'ldexp', 'lgamma', 'log', 'log10', 'log1p', 'log2', 'modf', 'nan', 'perm', 'pi', 'pow', 'prod', 'radians', 'remainder', 'sin', 'sinh', 'sqrt', 'tan', 'tanh', 'tau', 'trunc']

Podemos examinar la lista para saber el tipo de cada uno:

>>> math_members = [n for n in inspect.getmembers(math) if n[0][0]!="_"]
>>> for n in math_members:
print(f"{n[0]:>10} - {type(n[1])}")


acos - <class 'builtin_function_or_method'>
acosh - <class 'builtin_function_or_method'>
asin - <class 'builtin_function_or_method'>
asinh - <class 'builtin_function_or_method'>
atan - <class 'builtin_function_or_method'>
atan2 - <class 'builtin_function_or_method'>
atanh - <class 'builtin_function_or_method'>
ceil - <class 'builtin_function_or_method'>
comb - <class 'builtin_function_or_method'>
  copysign - <class 'builtin_function_or_method'>
cos - <class 'builtin_function_or_method'>
cosh - <class 'builtin_function_or_method'>
   degrees - <class 'builtin_function_or_method'>
dist - <class 'builtin_function_or_method'>
e - <class 'float'>
erf - <class 'builtin_function_or_method'>
erfc - <class 'builtin_function_or_method'>
exp - <class 'builtin_function_or_method'>
expm1 - <class 'builtin_function_or_method'>
fabs - <class 'builtin_function_or_method'>
 factorial - <class 'builtin_function_or_method'>
floor - <class 'builtin_function_or_method'>
fmod - <class 'builtin_function_or_method'>
frexp - <class 'builtin_function_or_method'>
fsum - <class 'builtin_function_or_method'>
gamma - <class 'builtin_function_or_method'>
gcd - <class 'builtin_function_or_method'>
hypot - <class 'builtin_function_or_method'>
inf - <class 'float'>
   isclose - <class 'builtin_function_or_method'>
  isfinite - <class 'builtin_function_or_method'>
isinf - <class 'builtin_function_or_method'>
isnan - <class 'builtin_function_or_method'>
isqrt - <class 'builtin_function_or_method'>
ldexp - <class 'builtin_function_or_method'>
lgamma - <class 'builtin_function_or_method'>
log - <class 'builtin_function_or_method'>
log10 - <class 'builtin_function_or_method'>
log1p - <class 'builtin_function_or_method'>
log2 - <class 'builtin_function_or_method'>
modf - <class 'builtin_function_or_method'>
nan - <class 'float'>
perm - <class 'builtin_function_or_method'>
pi - <class 'float'>
pow - <class 'builtin_function_or_method'>
prod - <class 'builtin_function_or_method'>
   radians - <class 'builtin_function_or_method'>
 remainder - <class 'builtin_function_or_method'>
sin - <class 'builtin_function_or_method'>
sinh - <class 'builtin_function_or_method'>
sqrt - <class 'builtin_function_or_method'>
tan - <class 'builtin_function_or_method'>
tanh - <class 'builtin_function_or_method'>
tau - <class 'float'>
trunc - <class 'builtin_function_or_method'>

>>>

Pero inspect nos permite obtener mucha más información. Por ejemplo, podemos emplear la función inspect.signature() para obtener información sobre los argumentos y valores de retorno de una función.

>>> inspec.signature(math.fsum)
<Signature (seq, /)>
>>> inspect.signature(math.pow)
<Signature (x, y, /)>
>>>

Para obtener la signatura es preciso que al definir la función se empleen anotaciones, y para interpretar la información devuelta debemos conocer más aspectos de la definición de funciones. Por supuesto, obtendríamos una información más detallada empleando help(), pero help depende de la docstring, y en muchas ocasiones no se proporciona ninguna, así que aquí tenemos un medio alternativo de conocer qué argumentos espera una función. También podemos obtener otros datos a partir del objeto Signature que nos devuelve la función, por ejemplo los valores de parámetros por defecto. Otra función muy interesante es inspect.getsource() que nos devuelve el código fuente de un objeto, siempre que esté disponible (por desgracia para muchos objetos de la biblioteca estándar no lo está). Vamos a trastear un poco con las posibilidades. Definiremos una función que nos proporcione una lista de métodos para un objeto dado.

>>> def métodos(obj):
return [n[0] for n in inspect.getmembers(obj, inspect.isroutine)]


>>> from Fracción import fracción
>>> métodos(fracción)
['__add__', '__delattr__', '__dir__', '__eq__', '__float__', '__format__', '__ge__', '__getattribute__', '__gt__', '__init__', '__init_subclass__', '__le__', '__lt__', '__mul__', '__ne__', '__new__', '__radd__', '__reduce__', '__reduce_ex__', '__repr__', '__rmul__', '__rsub__', '__rtruediv__', '__setattr__', '__sizeof__', '__str__', '__sub__', '__subclasshook__', '__truediv__', 'gcd', 'inverse', 'modf', 'reduce', 'test']
>>> inspect.signature(fracción.__init__)
<Signature (self, n, d=1)>
>>> print(inspect(getsource(fracción.__init__)))
def __init__(self, n, d = 1):
if type(n) == int and type(d) == int:
self.n = n
self.d = d
else:
n = fracción.test(n)
d = fracción.test(d).inverse()
self.n = n.n * d.n
self.d = n.d * d.d

>>> import tkinter as tk
>>> métodos(tk.Tk)
['_Misc__winfo_getint', '_Misc__winfo_parseitem', '__delattr__', '__dir__', '__eq__', '__format__', '__ge__', '__getattr__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', '_bind', '_configure', '_displayof', '_getboolean', '_getconfigure', '_getconfigure1', '_getdoubles', '_getints', '_grid_configure', '_gridconvvalue', '_loadtk', '_nametowidget', '_options', '_register', '_report_exception', '_root', '_substitute', 'after', 'after_cancel', 'after_idle', 'anchor', 'aspect', 'attributes', 'bbox', 'bell', 'bind', 'bind_all', 'bind_class', 'bindtags', 'cget', 'client', 'clipboard_append', 'clipboard_clear', 'clipboard_get', 'colormapwindows', 'columnconfigure', 'command', 'config', 'configure', 'deiconify', 'deletecommand', 'destroy', 'event_add', 'event_delete', 'event_generate', 'event_info', 'focus', 'focus_displayof', 'focus_force', 'focus_get', 'focus_lastfor', 'focus_set', 'focusmodel', 'forget', 'frame', 'geometry', 'getboolean', 'getdouble', 'getint', 'getvar', 'grab_current', 'grab_release', 'grab_set', 'grab_set_global', 'grab_status', 'grid', 'grid_anchor', 'grid_bbox', 'grid_columnconfigure', 'grid_location', 'grid_propagate', 'grid_rowconfigure', 'grid_size', 'grid_slaves', 'group', 'iconbitmap', 'iconify', 'iconmask', 'iconname', 'iconphoto', 'iconposition', 'iconwindow', 'image_names', 'image_types', 'keys', 'lift', 'loadtk', 'lower', 'mainloop', 'manage', 'maxsize', 'minsize', 'nametowidget', 'option_add', 'option_clear', 'option_get', 'option_readfile', 'overrideredirect', 'pack_propagate', 'pack_slaves', 'place_slaves', 'positionfrom', 'propagate', 'protocol', 'quit', 'readprofile', 'register', 'report_callback_exception', 'resizable', 'rowconfigure', 'selection_clear', 'selection_get', 'selection_handle', 'selection_own', 'selection_own_get', 'send', 'setvar', 'size', 'sizefrom', 'slaves', 'state', 'title', 'tk_bisque', 'tk_focusFollowsMouse', 'tk_focusNext', 'tk_focusPrev', 'tk_setPalette', 'tk_strictMotif', 'tkraise', 'transient', 'unbind', 'unbind_all', 'unbind_class', 'update', 'update_idletasks', 'wait_variable', 'wait_visibility', 'wait_window', 'waitvar', 'winfo_atom', 'winfo_atomname', 'winfo_cells', 'winfo_children', 'winfo_class', 'winfo_colormapfull', 'winfo_containing', 'winfo_depth', 'winfo_exists', 'winfo_fpixels', 'winfo_geometry', 'winfo_height', 'winfo_id', 'winfo_interps', 'winfo_ismapped', 'winfo_manager', 'winfo_name', 'winfo_parent', 'winfo_pathname', 'winfo_pixels', 'winfo_pointerx', 'winfo_pointerxy', 'winfo_pointery', 'winfo_reqheight', 'winfo_reqwidth', 'winfo_rgb', 'winfo_rootx', 'winfo_rooty', 'winfo_screen', 'winfo_screencells', 'winfo_screendepth', 'winfo_screenheight', 'winfo_screenmmheight', 'winfo_screenmmwidth', 'winfo_screenvisual', 'winfo_screenwidth', 'winfo_server', 'winfo_toplevel', 'winfo_viewable', 'winfo_visual', 'winfo_visualid', 'winfo_visualsavailable', 'winfo_vrootheight', 'winfo_vrootwidth', 'winfo_vrootx', 'winfo_vrooty', 'winfo_width', 'winfo_x', 'winfo_y', 'withdraw', 'wm_aspect', 'wm_attributes', 'wm_client', 'wm_colormapwindows', 'wm_command', 'wm_deiconify', 'wm_focusmodel', 'wm_forget', 'wm_frame', 'wm_geometry', 'wm_grid', 'wm_group', 'wm_iconbitmap', 'wm_iconify', 'wm_iconmask', 'wm_iconname', 'wm_iconphoto', 'wm_iconposition', 'wm_iconwindow', 'wm_manage', 'wm_maxsize', 'wm_minsize', 'wm_overrideredirect', 'wm_positionfrom', 'wm_protocol', 'wm_resizable', 'wm_sizefrom', 'wm_state', 'wm_title', 'wm_transient', 'wm_withdraw']

Vemos como podemos escudriñar en nuestra clase fracción. El módulo tkinter es el que proporciona la biblioteca estándar para gestionar el interfaz de ventanas, y la clase tkinter.Tk es la clase principal de ventana de este módulo. Vemos que podemos obtener fácilmente una lista de todos los métodos disponibles. A veces es la única manera de descubrir la existencia de algunos métodos sin documentar. Para ir abriendo el apetito sobre el entorno gráfico, prueba a teclear en el intérprete la siguiente orden:

>>> import tkinter as tk
>>> tk.Tk()
<tkinter.Tk object .>
>>>

La mera existencia del objeto Tk produce una ventana. Ya veremos qué hacer con ella en próximos apartados. De momento vamos a completar nuestros conocimientos sobre funciones y cómo definirlas.

3.1.5 Profundizando en la definición de funciones

Ya desde la primera sección vimos cómo definir funciones, cómo emplear argumentos por defecto y argumentos en cantidad indefinida. Vamos a dar una vuelta de tuerca para ver todas las posibilidades que tenemos para definir una función y sus parámetros. Existen cinco formas de indicar estos últimos:

Tipos de parámetros en la definición de una función
Posicional Recibe el argumento según su orden
Posicional o nombrado Recibe el argumento según su orden o por su nombre
Nombrado Recibe el argumento por su nombre
Posicional indeterminado Recibe una secuencia de argumentos por su orden
Nombrado indeterminado Recibe una secuencia de argumentos por sus nombres

Los parámetros son posicionales o nombrados por defecto.

>>> def f(x, y, z):
return x + y + z

>>> f(1, 2, 3)
6
>>> f(1, 2, z=3)
6
>>> f(1, y=2, 3)
SyntaxError: positional argument follows keyword argument
>>>

No nos proporciona ninguna ventaja, dado que no podemos invocar argumentos posicionales después de los nombrados, tendríamos que haber especificado:

>>> f(1, y=2, z=3)
6
>>>

Pero en realidad es mucho más engorroso que la forma posicional. Otra cosa sería si empleasemos valores por defecto para los parámetros.

>>> def f(x=0, y=0, z=0):
return x + y + z

>>> f(z=3)
3
>>> f(1, z=3)
4
>>> f(z=3, x=1)
4
>>>

Ahora podemos emplear los argumentos por su posición o por su nombre. Los argumentos con nombre pueden ir en cualquier orden, pero siempre a continuación de los argumentos posicionales.

Existe una sintaxis especial para indicar el tipo de parámetros que deseamos. Si queremos que solamente se utilicen parámetros posicionales podemos usar la barra "/" separada por una coma después de la declaración de los parámetros.

>>> def f(x, y, z, /):
return x + y + z

>>> f(1, 2, 3)
6
>>> f(1, 2, z=3)
Traceback (most recent call last):
File "<pyshell#38>", line 1, in <module>
f(1,2,z=3)
TypeError: f() got some positional-only arguments passed as keyword arguments: 'z'

>>>

Aquellos parámetros que se encuentren antes de la barra solo pueden ser empleados por su posición, no por el nombre. Por supuesto, podemos seguir asignando valores por defecto a cualquiera de ellos. También podemos emplear un asterisco, separado por comas entre los demás parámetros. Todos aquellos posteriores a esta indicación son parámetros nombrados, hemos de utilizar su nombre forzosamente para emplearlos.

>>> def f(x, y, *, z,):
return x + y + z
>>> f(1, 2, 3)
Traceback (most recent call last):
File "<pyshell#2>", line 1, in <module>
f(1,2,3)
TypeError: f() takes 2 positional arguments but 3 were given

>>> f(1, 2, x=3)
Traceback (most recent call last):
File "<pyshell#3>", line 1, in <module>
f(1, 2, x=3)
TypeError: f() got multiple values for argument 'x'

>>> f(1, 2, g=3)
Traceback (most recent call last):
File "<pyshell#4>", line 1, in <module>
f(1, 2, g=3)
TypeError: f() got an unexpected keyword argument 'g'

>>> f(1, 2, z=3)
6
>>>

Vemos que no podemos emplear el valor por posición, y tampoco podemos emplear un nombre diferente del establecido en la declaración de la función. De hecho lo que si podemos hacer es emplear nombres para los argumentos anteriores, que como hemos dicho, por defecto son posicionales o nombrados. Si intentamos emplear dos veces un argumento también nos lo indica como error. Los parámetros nombrados son útiles como modificadores del comportamiento de una función, y conviene asignarles un valor por defecto. De este modo solamente hemos de emplearlos para cambiar el comportamiento predeterminado. La función print() utiliza de este modo el parámetro end.

Si empleamos ambos indicadores tenemos la posibilidad de usar parámetros de las tres clases.

>>> def f(x, /, y, *, z,):
return x + y + z

>>> x, y, z = 1, 2, 3
>>> f(x, y, z)
Traceback (most recent call last):
File "<pyshell#11>", line 1, in <module>
f(x, y, z)
TypeError: f() takes 2 positional arguments but 3 were given

>>> f(x, y, z=z)
6
>>> f(x, z=z, y=y)
6
>>>

Hemos hecho una pequeña maniobra de despiste al declarar tres variables con los mismos nombres de los parámetros de la función f(). No debemos olvidar que los nombres de los parámetros son siempre variables locales de la función, no tienen nada que ver con otras variables globales o de su entorno. De esta manera, al invocar la función con f(x, y, z) lo que hacemos es pasar el valor de los argumentos (las variables x, y, z del ámbito global) a los parámetros (las variables x, y, z del ámbito local de f()). Para emplear parámetros con nombre debemos hacer la asignación explícita en la llamada de la función. Volvemos a ver que los parámetros con nombre se pueden utilizar en cualquier orden.

Aunque lo vimos en la primera sección vamos a volver a recordar el cuarto tipo de parámetros, posicionales de longitud indeterminada. Recordemos que empleábamos un asterisco en la declaración del parámetro y que todos aquellos parámetros posicionales que fueran después de los explícitamente declarados se enviaban en forma de tupla al invocar la función.

# Parámetros posicionales indeterminados

def suma(x, y, *args, output=False):
''' Suma un mínimo de dos argumentos y un máximo indeterminado.
El parámetro output determina la impresión de la ecuación'''

total = x + y
for n in args:
total += n
if output:
print(x , y, sep=" + ", end="")
for n in args:
print(f" + {n}", end="")
print(f" = {total}")
return total

Ejecútalo para que la función quede definida y sin cerrar el shell prueba lo siguiente:

============= RESTART: C:/Users/User/Documents/Python/ParámetrosPI.py =============
>>> suma(1,2)
3
>>> suma(1,2,output=True)
1 + 2 = 3
3

>>> help(suma)
Help on function suma in module __main__:

suma(x, y, *args, output=False)
Suma un mínimo de dos argumentos y un máximo indeterminado.
El parámetro output determina la impresión de la ecuación


>>> suma(1,2,3,4,5)
15
>>> suma(1,2,3,4,5,output=True)
1 + 2 + 3 + 4 + 5 = 15
15

>>>

Todo aquello posterior a la declaración *args (Siempre lo escribiremos así, es una convención de Python, aunque podríamos emplear cualquier nombre en lugar de args) se trata como parámetros nombrados. Vemos que nuestra función dispone de ayuda gracias a la docstring que hemos incorporado. Ahora bien, si tenemos una función que suma conjuntos de números podemos pensar que sería adecuado emplear secuencias en la invocación. Podríamos incorporar código extra en nuestro programa para determinar si un argumento es un valor sencillo o una secuencia, pero hay una forma más sencilla de desempaquetar las secuencias de Python al invocar una función. Continuamos en el mismo shell anterior.

>>> lista=[1,2,3]
>>> suma(lista)
Traceback (most recent call last):
File "<pyshell#14>", line 1, in <module>
suma(lista)
TypeError: suma() missing 1 required positional argument: 'y'

>>> suma(1,2,lista)
Traceback (most recent call last):
File "<pyshell#15>", line 1, in <module>
suma(1,2,lista)
File "C:/Users/Miguel/Documents/Desarrollo/Python/ParámetrosPI.py", line 9, in suma
total += n
TypeError: unsupported operand type(s) for +=: 'int' and 'list'

>>> suma(*lista, output=True)
1 + 2 + 3 = 6
6

>>>

Simplemente debemos anteponer un asterisco al iterable empleado como argumento. De este modo Python no envía el iterable en si sino cada elemento por separado. Podemos emplear cualquier clase de iterable, no solo listas o tuplas.

>>> D={1:"uno" ,2:"dos", 3:"tres"}
>>> suma(*D.keys())
6
>>> suma(*range(1,6))
15
>>> *lista
SyntaxError: can't use starred expression here
>>> print(*lista)
1 2 3
>>>

Solamente podemos usar este método en una llamada de función al emplear el iterable como argumento, no es válido en otro tipo de expresiones.

Nos queda por ver el último tipo de parámetros, los parámetros nombrados de longitud indeterminada. Para ellos empleamos la siguiente notación:

>>> def test(**kwargs):
print(kwargs)
print(type(kwargs))

>>> test(uno=1, dos=2, Saludo="HOLA")
{'uno': 1, 'dos': 2, 'Saludo': 'HOLA'}
<class 'dict'>

>>>

El candidato lógico para recibir los argumentos con nombre era un diccionario, y efectivamente es así. Se produce un diccionario con los pares clave:valor correspondientes al nombre de cada argumento y al valor asignado. Las claves son cadenas de caracteres, pero en la invocación de la función hay que emplearlas como identificadores, sin comillas.

>>> test("uno"=1, "dos"=2, "Saludo"="HOLA")
SyntaxError: expression cannot contain assignment, perhaps you meant "=="?
>>>

Por supuesto, no podemos emplear parámetros posicionales cuando deben ser nombrados.

>>> test(1,2)
Traceback (most recent call last):
File "<pyshell#7>", line 1, in <module>
test(1,2)
TypeError: test() takes 0 positional arguments but 2 were given

>>>

Y eso es todo en lo relativo a los parámetros en la definición de funciones. Podemos mezclar todas las posibilidades. Los parámetros posicionales de longitud indeterminada siempre deben ser los últimos después de los posicionales explícitos (pudiendo estos ser también nombrados o no), luego vendrán los parámetros nombrados concretos y en último lugar los parámetros nombrados de longitud indeterminada. Una declaración puede quedar de la siguiente forma:

def func(x1,...,xn, /, n1,...,nn, *args, nombre1,...,nombren, **kwargs):
├───────┘    ├───────┘  ├───┘  ├─────────────────┘  ├──────┘
└Nombrados└Nombrados intederminados

└Posicionales indeterminados

└Posicionales o nombrados

└Posicionales

Además cualquiera de los parámetros puede tener valor por defecto. Si recurrimos a ello es mejor hacerlo desde los de más a la derecha para poder invocar a la función solo con los argumentos esenciales y emplear los demás como modificadores.

Respecto a los valores por defecto en los parámetros, hay que tener en cuenta que la evaluación de dicho valor se efectúa solamente una vez, al llegar a la sentencia de deficición de la función. Si asignamos un valor inmutable, se mantendrá siempre igual. Si lo hacemos con un valor mutable, como una lista, podemos acumular valores en las llamadas a la función, o crear funciones con "memoria".

#Parámetros por defecto mutables

def contador(l=[0]):
l[0]+=1
return l

print(contador())
print(contador())
print(contador())

Cada vez que llamamos a la función contador se incrementa en uno el valor almacenado en la lista. La lista es la misma que fué asignada en la declaración inicial de la función, y va cambiando su contenido porque es mutable.

============= RESTART: C:/Users/User/Documents/Python/Def_par mutable.py =============
[1]
[2]
[3]

>>>

Vamos a aprovechar esta ciscunstancia para crear una función que identifica números primos. La función mantiene una lista que va actualizando cuando es necesario para ganar en eficiencia.

#Primos segundos ;-)

# Vamos ampliando la lista de primos conocidos
def primo(i,*,primos=[2]):
'''Devuelve True si el valor pasado en un número primo
Para saber los primos conocidos emplea:
primo(None)'''

if i==None:
print(f"Conozco {len(primos)} primos. El mayor es {primos[-1]}")
return
if type(i)!=int or i<2:
return False
if primos[-1]<i:
for p in primos:
if i%p==0:
return False
for n in range(primos[-1]+1,i+1):
for p in primos:
if n%p==0:
break
else:
primos.append(n)
if n<i and i%n==0:
return False
return i in primos

Guárdala con <CTRL>+<S> (yo he elegido el nombre primos_v2.py) y luego ve a la ventana del shell.

>>> from primos_v2 import primo
>>> primo(1097)
True
>>> primo(None)
Conozco 184 primos. El mayor es 1097
>>> primo (80051)
True
>>> primo(None)
Conozco 7840 primos. El mayor es 80051
>>> # Para cantidades altas puede quedarse aparentemente colgado...
>>> # Si te cansas siempre puedes interrumpir con <CTRL>+<C>
>>> primo(979787)
Traceback (most recent call last):
File "<pyshell#9>", line 1, in <module>
primo(979787)
File "C:\Users\Miguel\Documents\Desarrollo\Python\primos_v2.py", line 20, in primo
if n%p==0:
KeyboardInterrupt

>>> primo(None)
Conozco 50671 primos. El mayor es 620759
>>>

Puedes volver a introducir la misma orden y seguirá incrementando el número de primos conocidos. Y efectivamente, el 979787 es primo. Podríamos pensar que es un dispendio de recursos almacenar una lista con 50671 valores, pero en realidad en los términos modernos no es relevante. Si cada valor ocupa 8 bytes eso supondría 400KB (Kilobytes) o 0.4MB (Megabytes) en unos tiempos en los que medimos la memoria en Gigabytes.

Vamos a emplear el módulo inspect para curiosear en la lista de los primos y ver su tamaño en memoria.

>>> import inspect, sys
>>> pars=inspect.signature(primo).parameters
>>> pars.keys()
odict_keys(['i', 'primos'])
>>> len(pars["primos"].default)
50671
>>> pars["primos"].default[-1]
620759
>>> sys.getsizeof(pars["primos"].default)
414696
>>>

Como ya vimos, el objeto inspect.Signature posee una serie de atributos, y concretamente inspect.Signature.parameters contiene los parámetros de la función en forma de diccionario que relaciona los nombres de los parámetros con objetos de tipo inspect.Parameter. A su vez estos objetos incluyen una serie de atributos e inspect.Parameter.default contiene el valor por defecto de un parámetro dado, con lo cual podemos conseguir acceso a la lista primos. Hemos de andar con cuidado, no nos interesa la lista en si que es demasiado grande para IDLE (3287 líneas). Si intentamos mostrarla se nos colgará el shell. Lo que nos interesaba es conocer su tamaño en memoria y eso nos lo proporcina la función sys.getsizeof(objeto). Puedes ver que la estimación era muy aproximada.

Y ya que estamos con ello, vamos a examinar la manera de anotar los parámetros de una función. En realidad el concepto es muy sencillo, se trata de poner tras cada parámetro el caracter dos puntos ":" seguido por una expresión que debería devolver un tipo de Python. Esto iría inmediatamente tras el nombre del parámetro, antes del signo "=" si añadimos un valor por defecto. Para anotar el valor de retorno escribiremos "->" seguido de la expresión que indique el tipo tras los paréntesis de la definición de la función y antes de los dos puntos finales de línea.

Podríamos haber anotado la función primo() de la siguiente forma:

>>> def primo(i:int or None,*,primos:[int]=[2])->bool:

Al observar esta definición obtenemos información acerca de los tipos de parámetros esperados. En cualquier caso las anotaciones constituyen un paso más en la documentación del programa, pero no tienen ninguna relevancia en la ejecución de este. Podríamos pasar otro tipo de valor a la función y si esta está preparada para gestionarlo no daría ningún problema.

Funciones lambda

Para terminar con este repaso de las funciones vamos a ver una palabra clave que nos permite definir al vuelo funciones anónimas. Se trata de la palabra reservada lambda.

Podemos emplear las funciones lambda en contextos en los que se requiere una función pero no merece la pena definirla específicamente, por ejemplo en un filtro para el método sort() de las listas.

>>> help(list.sort)
Help on method_descriptor:

sort(self, /, *, key=None, reverse=False)
Sort the list in ascending order and return None.

The sort is in-place (i.e. the list itself is modified) and stable (i.e. the
order of two equal elements is maintained).

If a key function is given, apply it once to each list item and sort them,
ascending or descending, according to their function values.

The reverse flag can be set to sort in descending order.

>>>

Vemos que el método .sort() posee un parámetro con nombre llamado key que consiste en una función que se aplica a cada elemento antes de ser ordenado. Si tenemos una lista bidimensional, los sistemas de ordenamiento pueden resultar confusos, o podemos querer ordenar por una u otra dimensión. Podemos hacerlo mediante una función lambda.

>>> lista=[(1,"uno"),(2,"dos"),(3,"tres"),(4,"cuatro")]
>>> lista.sort(key=lambda i:i[1])
>>> lista
[(4, 'cuatro'), (2, 'dos'), (3, 'tres'), (1, 'uno')]
>>>

Hemos ordenado la lista según el segundo valor de cada tupla. Veamos la sintaxis de una expresión lambda.

lambda parámetros : expresión

Los parámetros son cero o más valores separados por comas y la expresión puede ser cualquiera, pero solamente de una línea. Dentro de la expresión en sí podemos utilizar los parámetros o no hacerlo, depende de lo que queramos conseguir

>>> x=lambda:4
>>> x
<function <lambda> at 0x0000021160CBE3A0>
>>> x()
4
>>>

Podemos usar lambda para emplear funciones con argumentos en contextos en los que no podemos escribir los paréntesis y argumentos directamente porque eso supondría ejecutar la función y obtener su retorno, no la función en sí.

>>> saludo=print("Hola")
Hola
>>> saludo
>>> print(saludo)
None
>>>
>>> saludo=lambda:print("Hola")
>>> saludo()
Hola
>>>

Vemos que en el contexto de la expresión lambda la expresión print("Hola") no ha sido ejecutada sino almacenada tal cual en la variable saludo que queda así convertida en la función saludo(). En realidad podemos ver una equivalencia directa entre una función lambda y una definición normal de función:

>>> cuad=lambda x:x**2
>>> cuad(4)
16
>>>
>>> #Equivale a declarar:
>>> def cuad(x):
return x**2

>>>

Cuando veamos el interfaz gráfico mediante la biblioteca tkinter usaremos funciones lambda para gestionar eventos con bastante frecuencia. Así podemos llamar a una misma función enviándole distintos argumentos en función de qué objeto del interfaz realize la llamada, pero aún queda un trecho para llegar a eso. Ahora vamos a ver la última fuente de información acerca de Python que nos puede llevar a la maestría en la programación.

3.1.6 Documentación de Python en las redes

Hay muchísima información sobre cualquier cosa en la red, y Python no podía faltar. Además recordemos que se trata de software abierto, que se distribuye a través de internet y por supuesto toda la documentación está ahí. Eso si, la más detallada y actual está en inglés, pero si te planteas conocer el mundo de la programación es inevitable practicar la lectura en inglés, que por otra parte al tratarse de una materia especializada resulta más asequible que un inglés coloquial o literario. De paso así mejoras tus conocimientos idiomáticos, que es un extra.

La web oficial: python.org

Sin duda este es el primer y principal lugar de referencia para todo lo relativo a Python. De aquí es de donde debes descargar el intérprete de Python (desde la sección Downloads).

Hay aquí tres enlaces de información fundamentales:

En la seción de documentación puedes elegir el idioma Español y la versión de Python que prefieras (incluso versiones en desarrollo), hay un completo Tutorial y referencias de la biblioteca estándar y la sintaxis. Es la principal fuente de información del paquete de instalación de Python. No cubre las bibliotecas externas.

En el wiki tienes como ya hemos mencionado buenos tutoriales, y tambien puedes verlos en Español si buscas en el enlace Languages. Hay también una completa sección de documentación y una interesante referencia de libros, algunos de los cuales puedes descargarlos gratuitamente o leerlos por internet.

PyPI es la cornucopia de todas las bibliotecas que puedas imaginar. Aquí puedes buscar las bibliotecas que hemos empleado hasta ahora y acceder a la información para descargarlas y a las webs oficiales y documentación existentes (No todas las bibliotecas están bien documentadas).

La web española: pyspanishdoc

Es un esfuerzo de traducción de la documentación oficial de Python. No todo está traducido y no está tan actualizado, pero puedes aprender mucho aquí.

También hay buenos tutoriales y guías en español en la web recursospython.

Nuestras bibliotecas documentadas

A continuación tienes los enlaces a la documentación oficial de las cuatro bibliotecas externas que hemos empleado hasta el momento:

Colorama

Se trata de la escasa documentación incorporada en la propia página de PyPI acerca de esta biblioteca. Es suficiente dado que la biblioteca ofrece una funcionalidad muy concreta. Viene una tabla interesante de las secuencias ANSI utilizadas por el módulo.

Pillow

La información es en inglés y bastante técnica, pero empezando por el manual (Handbook) y la sección Tutorial se aprenden muchas cosas. La referencia es detallada pero requiere un nivel de comprensión del lenguaje intermedio.

Numpy

Fantástica documentación. Se nota que se trata de un paquete con mucha solera y orientado al mundo científico. Tienes la posibilidad de descargar la guía del usuario y el manual de referencia en PDF. Lo más recomendable es empezar en la sección Web->Absolute Beginners Tutorial. La referencia es completa.

Matplotlib

Otra página de gran calidad, y con muchas imágenes como corresponde a una biblioteca gráfica. El mejor punto para "hincarle el diente" es a través de Documentation -> User's Guide -> Tutorials

La WEB "en persona": w3schools

El consorcio w3c es el que gestiona el desarrollo de la red. Una web que hay que tener junto a la cabecera. Aquí puedes aprender no solo Python, sino todos los lenguajes de la Web (HTML, CSS, JavaScript) y mucho más. Tiene una característica muy particular y fantástica que es un modo interactivo en el que puedes modificar el código y ver los resultados dentro del mismo navegador. Absolutamente imprescindible.

Más enlaces con información interesante en inglés:

Y en español:

3.2 El interfaz de usuario

Hay programas que se relacionan unicamente con otros programas, con redes o máquinas o con colecciones de datos, pero en último término esos programas o sus interlocutores electrónicos desarrollan tareas destinadas a usuarios humanos. Y al final el resultado de esos procesos (y muchas veces una buena parte de los datos empleados) tienen que ser recibidos (o transmitidos) por personas. Aquí intervienen los Interfaces de usuario.

Un interfaz es el mecanismo de comunicación entre dos sistemas que les permite intercambiar información. Nos podemos referir a los medios físicos de esa comunicación pero también a las convenciones lógicas que permiten llevarla a cabo. Un interfaz de usuario permite a una persona interactuar con una máquina, para lo cual existen sistemas de entrada y salida de datos.

Hace muchos años que empleamos el teclado (y un poco más tarde añadimos el ratón) como medio principal de entrada de datos de usuario de los ordenadores. De la misma manera hace aproximadamente el mismo tiempo que se emplean pantallas (con el añadido posterior de altavoces) como el principal medio de salida de esos datos hacia el usuario.

Hasta ahora hemos usado un CLI (Command Line Inteface) en nuestros programas Python, con las funciones input() y print() como principales canales de entrada y salida, y sabemos las limitaciones de este tipo de interfaz. Vamos a avanzar en este aspecto hasta llegar a la creación de programas que aprovechen los modernos sistemas de ventanas gráficas.

3.2.1 CLI: Interfaz de línea de comandos

Incluso trabajando en un terminal con línea de comandos, hay cosas que podemos hacer para mejorar mucho nuestro interfaz de usuario. La primera observación es que esto no funciona en IDLE, solamente en la consola, pero en realidad si hacemos un programa que queremos usar lo haremos desde esta última y no desde IDLE.

Lo primero que tenemos que preparar es acceso al intérprete de Python desde la línea de comandos en nuestro directorio de programas. Esto último es importante para poder importar módulos sin necesidad de introducir constantemente la trayectoria. Otra aproximación consiste en introducir el directorio de programas en la trayectoria de búsqueda de módulos de Python. Veamos ambas.

Arranca el intérprete de Python en consola desde el icono que deberías tener ya en el escritorio, si no lo tenías es el momento de colocarlo bien centrado.

En esta pantalla sería el segundo icono. De paso te recuerdo que el manual que se instala con la distribución de Python (aquí el tercer icono) es también una inestimable fuente de conocimientos. Pulsa con el botón derecho sobre el icono llamado Python 3.8 (o la versión que tengas instalada) y elige Abrir la ubicación del archivo. Aparecerás en la ventana en la que se ha instalado tu intérprete. Ahora pulsa en un espacio libre de la ventana y selecciona Nuevo->Documento de texto, en el diálogo que te aparece cambia tanto el nombre como la extensión y llámalo "pydir.py". Guárdalo y luego haz click derecho y elige la opción Editar o Editar con IDLE.

Ahora necesitamos obtener la localización de tu directorio de programas. Deberías también tener un acceso en el escritorio. Abre la ventana del directorio y copia la dirección desde la barra de direcciones. Edita el programa que tienes abierto en IDLE de la siguiente manera:

import os
os.chdir(b"C:\Users\Miguel\Documents\Desarrollo\Python")
print("Directorio seleccionado:",os.getcwd())

Sustituye el contenido de las comillas por tu propio directorio. Es importante que uses la b antes de las comillas para indicar que se trata de una secuencia de Bytes, de este modo las barras se procesan de forma adecuada. En una cadena normal se tomarían por secuencias de escape y se produciría un error. Guarda el fichero y cierra todas las ventanas.

Si ahora abres el intérprete de Python (esta vez haz doble click sobre él). Puedes usar la orden.

>>> import pydir
Directorio seleccionado: C:\Users\Miguel\Documents\Desarrollo\Python
>>>

Y ya estás en tu directorio de programas, listo para continuar. Veamos la segunda aproximación que consiste en añadir nuestro directorio de programas a la ruta de búsqueda de módulos. Vuelve a abrir el directorio del intérprete haciendo click derecho y Abrir la ubicación del archivo. Vuelve a crear un fichero de texto y esta vez ponle por nombre "pypath.py" y guárdalo. Edítalo de la siguiente manera:

import sys
miruta="C:\\Users\\Miguel\\Documents\\Desarrollo\\Python"
sys.path.append(miruta)
print(f"Ruta <{miruta}> añadida al PATH")

De nuevo reemplaza el contenido de las comillas en la segunda línea por tu propio directorio, esta vez tendrás que duplicar las barras invertidas a mano. Guárdalo como "pypath.py", por ejemplo. Ahora tienes una segunda opción, sin cambiar de directorio:

>>> import pypath
Ruta <C:\Users\Miguel\Documents\Desarrollo\Python> añadida al PATH
>>>

Uno de los aspectos más interesantes y divertidos de la programación es que no existe una única forma de hacer las cosas. Podemos enfrentarnos a un mismo problema desde perspectivas diferentes y encontrar diferentes soluciones. Realmente no necesitábamos programar nada para este problema en concreto. Bastaba hacer click derecho sobre el icono en el escritorio y en el menú Propiedades modificar el campo Iniciar en: para que se ejecute directamente en nuestra carpeta de programas, pero lo que aquí se pretende es aprender a programar.

Y después de este preámbulo vamos a ver que podemos hacer para potenciar nuestro interfaz de línea de comandos. Ya vimos en la primera sección el módulo colorama, que en realidad se limita a emplear secuencias de caracteres ANSI para manejar el terminal de texto. Vamos a crear nuestras propias funciones para manejar el terminal sin recurrir (casi) a colorama. Crearemos el siguiente programa (emplea IDLE para editarlo aunque lo ejecutaremos en la ventana de comandos):

'''
CLI. Funciones para manejar la consola de texto
'''

#Importamos nuestra librería particular
from mylib import *

#Comprobamos si estamos en IDLE y abortamos si es así
if inIDLE():
print("Este programa no funciona en IDLE")
sys.exit()

import os
import sys
import ctypes

#Inicializamos la consola para que responda a los códigos ANSI
from colorama import init
init()

#Imprime directamente en stdout sin dejar espacios ni saltos de línea
def write(*args):
'''Alternativa a print()

Escribe directamente en stdout sin incorporar nada al texto'''
cuenta=0
for s in args:
if type(s)!=str:
s = repr(s)
cuenta+=len(s)
sys.stdout.write(s)
return cuenta

#Inicializamos las constantes de los colores.
#Creamos variables para cada color y su versión brillante
COLORES=["NEGRO","ROJO","VERDE","AMARILLO","AZUL","MAGENTA","CYAN","BLANCO"]
color=0
for n in range(30,38):
exec(COLORES[color]+"="+str(n))
exec(COLORES[color]+"_B="+str(n+60))
color+=1
del COLORES

CSI="\033["

#Movimiento del cursor
UP= lambda n=1 : write(CSI+str(n)+"A")
DOWN= lambda n=1 : write(CSI+str(n)+"B")
RIGHT= lambda n=1 : write(CSI+str(n)+"C")
LEFT= lambda n=1 : write(CSI+str(n)+"D")
BACK= lambda n=1 : write(n*"\b")
MOVE= lambda x,y : write(CSI+str(y)+";"+str(x)+"H") # El origen de coordenadas es 1,1

#Configuración de colores
COLOR= lambda fg=BLANCO,bg=NEGRO : write(CSI+str(fg)+";"+str(bg+10)+"m")

#Título del terminal
TITLE= lambda s :write("\033]2;"+s+"\007")

#Borrado de pantalla y de línea
CLS= lambda mode=2 : write(CSI+str(mode)+"J") #0:hasta final, 1:desde principio, 2:todo y cursor a origen
CLine= lambda mode=2 : write(CSI+str(mode)+"K") #0:hasta final, 1:desde principio, 2:toda (mantiene cursor)

ON=True
OFF=False

class _CursorInfo(ctypes.Structure):
_fields_ = [("size", ctypes.c_int),
("visible", ctypes.c_byte)]

#Mostrar y ocultar el cursor, empleamos llamadas a la API de Windows
def CURSOR(Flag=ON):
'''Activar o desactivar el cursor de texto'''
if Flag:
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci))
ci.visible = Flag
ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci))
else:
ci = _CursorInfo()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
ctypes.windll.kernel32.GetConsoleCursorInfo(handle, ctypes.byref(ci))
ci.visible = Flag
ctypes.windll.kernel32.SetConsoleCursorInfo(handle, ctypes.byref(ci))

def termsize():
'''Obtener las dimensiones del terminal'''
size=os.get_terminal_size()
ancho=size.columns
alto=size.lines
return ancho, alto

Es un programa de cierta extensión, que además no hace nada visible. Solamente define una serie de constantes y funciones para manejar el terminal en modo texto. Por si a esta alturas aún no lo has descubierto, si haces click en el programa se copia al portapapeles y puedes pegarlo directamente en IDLE.No lo ejecutes, solamente guárdalo como "CLI.py".

Ahora, en el intérprete de Python en consola y una vez que estés en el directorio de programas PYTHON o hayas activado el PATH prueba lo siguiente.

>>> from CLI import *
>>> COLOR(ROJO)
>>> COLOR(VERDE)
>>> COLOR(ROJO_B)
>>> COLOR()
>>> DOWN(3)



>>> COLOR(NEGRO,BLANCO)
>>> CLS()

En este momento no podemos reflejar los resultados de nuestras órdenes, dado que no seguimos el flujo de texto normal de la consola. Ahora tendrás una consola con el fondo en blanco y el texto negro. Prueba lo siguiente:

>>> TITLE("CLI Test")
>>>

Sorprendente, ¿no?. Bueno, a estas alturas ya deberías entender el programa por tí mismo, pero daremos alguna somera explicación. En las líneas 5-11 comprobamos que no estemos en IDLE, en caso contrario presentamos un mensaje y abortamos el programa. A continuación importamos las bibliotecas: os para trabajar con ciertas funciones del sistema operativo, sys para hacerlo con el propio intérprete de Python y ctypes. Esta última permite comunicarnos con la propia APIApplication Program Interface
Interfaz de programa de aplicación
de Windows invocando funciones a través de las DLLsDinamic Link Libraries
Bibliotecas de enlace dinámico
de este.

La API es un interfaz que permite comunicarse a otros programas con uno, en este caso con el propio sistema operativo. Una DLL es algo parecido a una biblioteca de Python, contiene programas y funciones que pueden ser invocados cargando el código (compilado) en memoria temporalmente y luego desechándolo. Esto nos permite no mantener en memoria funciones que no son constantemente necesarias.

Las líneas 17-19 son lo único de colorama que empleamos. La función init() activa el reconocimiento de secuencias ANSI en el terminal de Windows.

A continuación creamos una función para imprimir a través de la salida estándar, de un modo similar a como lo hace print() pero sin incorporar nada de su propia cosecha. Solo se imprime lo que enviamos, no se intercalan espacios ni se ponen saltos de línea al final. De este modo controlamos con bastante precisión la posición del cursor. Además devuelve el número de caracteres escritos.

Seguidamente creamos una serie de constantes con los códigos de los colores. Tenemos ocho colores en versión normal y brillante, hemos dispuesto las etiquetas en la lista COLORES en el orden adecuado para poder emplear un bucle para hacer las asignaciones. A la vez creamos las versiones brillantes añadiendo a los nombres "_B". En realidad para los colores de fondo hay que usar diferentes valores, pero eso lo gestionamos más adelante en la función COLOR(), así empleamos siempre los mismos valores. Una vez creadas las variables eliminamos la lista puesto que ya no es necesaria.

Usamos con profusión funciones lambda para los movimientos del cursor, los cambios de color, borrados de pantalla y título de la ventana (Existe una secuencia ANSI para hacer esto). Todas estas funciones se limitan a enviar secuencias de códigos a la salida estándar.

Por último, creamos una función para gestionar la visibilidad del cursor, para ello empleamos la API de Windows, como mencionábamos antes. La última función obtiene el tamaño (en caracteres, es decir filas y columnas de texto) del terminal.

Con todo ello podemos crear programas en línea de comandos con una presentación infinitamente mejor que hasta el momento. Vamos a crear un banco de pruebas para nuestra biblioteca. Crea el fichero y guárdalo como "CLItest.py".

from CLI import *

def inputtyped(prompt, *tipos):
'''Acepta solo valores del tipo o tipos indicados'''
for t in tipos:
if type(t) is not type:
raise TypeError("Los argumentos de inputtyped() deben ser tipos de datos")
write(prompt)
while True:
entrada=sys.stdin.readline()
UP()
write("\r")
RIGHT(len(prompt))
write(len(entrada)*" ")
BACK(len(entrada))
try:
entrada=eval(entrada)
except NameError:
entrada=entrada[:-1]
except KeyboardInterrupt:
return None
if type(entrada) in tipos:
return entrada

Tenemos una función input que selecciona los valores aceptables. Esto no tiene nada que ver con nuestro interfaz de línea de comandos, sino el hecho de que esta vez cuando introduzcamos un valor incorrecto lo borra y nos solicita el nuevo valor en la misma posición. Como mejoras podemos incorporar más expresividad para informarnos cuando nos equivoquemos. Además solo funciona si el prompt se escribe justo empezando una línea, de otra manera producirá resultados extraños. Prueba la función en el intérprete:

>>> from CLItest import *
>>> inputtyped("Dime un número entero: ",int)
Dime un número entero: 100
>>>

Comprobarás que hasta que introduzcas un valor correcto se borra la entrada y debes teclear un nuevo dato. Podemos incorporar color a nuestra vida. Modifica la función de la siguiente forma:

from CLI import *

def inputtyped(prompt, *tipos, color=BLANCO):
'''Acepta solo valores del tipo o tipos indicados'''
for t in tipos:
if type(t) is not type:
raise TypeError("Los argumentos de inputtyped() deben ser tipos de datos")
write(prompt)
while True:
try:
COLOR(color)
entrada=sys.stdin.readline()
UP()
write("\r")
RIGHT(len(prompt))
write(len(entrada)*" ")
BACK(len(entrada))
entrada=eval(entrada)
except (NameError, SyntaxError):
entrada=entrada[:-1]
if entrada=="":
continue
except (KeyboardInterrupt, SystemExit):
break
except:
continue
finally:
COLOR(BLANCO)
if type(entrada) in tipos:
return entrada

Estamos asumiendo que el fondo debe ser negro, pero en el estado normal de la consola es así. También asumimos que el texto por defecto es blanco, cosa que no está garantizada. Si antes de llamar a inputtyped() empleamos COLOR() puede que no sea así, pero no vamos a complicar el asunto demasiado. Además del color hemos incorporado mejor gestión de los errores, asumimos que hay cadenas de texto que pueden producir errores de sintaxis (Una cadena de varias palabras, por ejemplo) e interceptamos todos los errores salvo los de terminación. La clausula finally: garantiza que recuperaremos el color blanco de texto pase lo que pase.

Puedes probarla de nuevo. Es posible que no notes mucha diferencia, pero eso es porque no te dedicas a tratar de producir errores a través de la entrada para ponerla a prueba.

Una idea para mejorar mucho más sería añadir una función de validación de datos opcional entre los argumentos. Así no solo comprobaríamos el tipo sino si se trata de un email válido, o una fecha, por ejemplo, o si un número está en un rango de valores admisible. También convendría desarrollar un sistema de entrada más controlado, leyendo incluso el teclado directamente. Todo eso queda para posibles lecciones posteriores.

Vamos a ver las posibilidades gráficas que nos da controlar el cursor con nuestra nueva biblioteca CLI, pero en primer lugar vamos a añadir una función a nuestra biblioteca personal, y de paso corregiremos un error en la función waitch().

''' -----mylib.py-----

Una biblioteca personal con funciones diversas '''

import msvcrt, sys

def inIDLE():
''' Nos permite saber si estamos ejecutando
un programa en el entorno de IDLE.
Devuelve True si es así y False en caso contrario.'''
return("idlelib" in sys.modules)

def waitch():
''' Detiene la ejecución hasta que pulsemos cualquier tecla
Solo funciona desde la consola, en IDLE no tiene efecto.
No produce eco en pantalla. Está diseñada para poder ver
la salida de los programas de consola desde Windows.'''
if inIDLE():
return
while msvcrt.kbhit():
msvcrt.getch()
while not msvcrt.kbhit():
pass
msvcrt.getch()
return

def execfile(fname):
''' Ejecuta un programa fname.py desde un fichero en disco'''
if type(fname) != str:
raise TypeError("execfile() requiere el nombre de un fichero Python")
if not fname.endswith(".py"):
fname=fname+".py"
with open(fname,"rb") as file:
source=file.read()
exec(source)

El error consistia en que la función solo funcionaba una vez. Al no vaciar el buffer de lectura de teclas se quedaba esperando permanentemente a que no hubiese una tecla pulsada si era llamada después de la primera vez. La nueva función execfile() sirve para ejecutar un programa python desde el intérprete o desde otro programa. Podemos usar import para ejecutar un módulo, pero solo funciona una vez. Si estamos probando un programa y haciendo modificaciones necesitamos algo como esta función.

Lo primero que hacemos es comprobar que el argumento es una cadena de texto. A continuación le añadimos la extensión ".py" si no la tiene, y luego viene el trabajo serio. En la línea 33 abrimos el fichero en modo de lectura de bytes. En la línea 34 leemos el contenido del fichero y a continuación lo ejecutamos a través de la función exec().

Ahora estamos mejor pertrechados para ejecutar programas desde el intérprete en la ventana de texto. Vamos ver esos gráficos en modo texto:

#CLItest_2

from CLI import *

ancho, alto = termsize()

CLS()
COLOR(AZUL_B)

left=1
top=1
right=ancho
bottom=alto-1
MOVE(left,top)

write("┌")
while True:
for x in range(left,right):
MOVE(x,top)
write("─")
MOVE(right,top)
write("┐")
top+=1
if top>bottom:
break
for y in range(top,bottom):
MOVE(right,y)
write("│")
MOVE(right,bottom)
write("┘")
right-=1
if left>right:
break
for x in range(right,left,-1):
MOVE(x,bottom)
write("─")
MOVE(left,bottom)
write("└")
bottom-=1
if top>bottom:
break
for y in range(bottom,top,-1):
MOVE(left,y)
write("│")
MOVE(left,top)
write("┌")
left+=1
if left>right:
break

MOVE(1,alto)
COLOR(BLANCO)

Intenta seguir la lógica del programa por ti mismo. Lo que hace es pintar una espiral en la pantalla, empleamos cuatro variables con los límites de la pantalla que se van estrechando según avanzamos hacia el centro. Ejecútalo con las siguientes líneas.

>>> from mylib import *
execfile("CLItest_2")

El resultado es como este.

A partir de aquí podemos incluso programar juegos en modo texto. De momento vamos a programar funciones útiles para ser empleadas en nuestros programas de línea de comandos. Un ejemplo puede ser una barra de progreso. Veamos varias soluciones.

#CLIprogress
from CLI import *
import time

LHALF="\u258C"
FULL="\u2588"
RHALF="\u2590"
SPACE=" "
UHALF="\u2580"
DHALF="\u2584"

def progress(x,y,size,fg=BLANCO,bg=NEGRO,speed=0.1):
'''Barra horizontal hacia la derecha'''
MOVE(x,y)
COLOR(fg,bg)
for i in range(size):
time.sleep(speed)
write(LHALF)
time.sleep(speed)
BACK()
write(FULL)

def progress2(x,y,size,fg=NEGRO,bg=BLANCO,speed=0.1):
'''Barra horizontal hacia la derecha
y desaparece desde la izquierda'''
progress(x,y,size,fg,bg,speed)
MOVE(x,y)
COLOR(fg,bg)
for i in range(size):
time.sleep(speed)
write(RHALF)
time.sleep(speed)
BACK()
write(" ")

def progress3(x,y,size,ancho=1,fg=BLANCO,bg=NEGRO,speed=0.1):
'''Barra vertical ascendente'''
MOVE(x,y)
COLOR(fg,bg)
for i in range(size):
write(DHALF*ancho)
time.sleep(speed)
BACK(ancho)
write(FULL*ancho)
time.sleep(speed)
BACK(ancho)
UP()

def progress4(x,y,size,ancho=1,fg=BLANCO,bg=NEGRO,speed=0.1):
'''Barra vertical ascendente y descendente'''
progress3(x,y,size,ancho,fg,bg,speed)
time.sleep(.1)
for i in range(size):
DOWN()
write(DHALF*ancho)
time.sleep(speed)
BACK(ancho)
write(SPACE*ancho)
time.sleep(speed)
BACK(ancho)

LIN=("\u2502","\u2571","\u2500","\u2572","\u2573")

def progress5(x,y,secs,fg=BLANCO):
'''Indicador giratorio y marca de tiempo'''
COLOR(fg)
for n in range(secs*10):
MOVE(x,y)
write(LIN[n%4])
write(f"{secs-n//10:>4}s")
time.sleep(.1)
MOVE(x,y)
write(LIN[4])
write(f"{0:>4}s")

def box(x,y,lines,cols,fg=BLANCO):
MOVE(x-1,y-1)
COLOR(fg)
write("┌"+cols*"─"+"┐")
for i in range(lines):
MOVE(x-1,y+i)
write("│")
MOVE(x+cols,y+i)
write("│")
MOVE(x-1,y+lines)
write("└"+cols*"─"+"┘")


CLS()
CURSOR(OFF)

COLOR(BLANCO)
MOVE(30,12)
write("Cuenta")
MOVE(30,13)
write("atrás:")
box(30,15,1,6,ROJO)
progress5(30,15,10,VERDE)

COLOR(BLANCO)
MOVE(29,23)
write(">> Barra horizontal >>")
box(30,25,1,20,AMARILLO)
progress2(30,25,20,MAGENTA,NEGRO,0.07)

COLOR(BLANCO)
MOVE(63,25)
write("\u02c4 Barra vertical \u02c5")
box(60,6,20,2,NEGRO_B)
progress4(60,25,20,2,AZUL_B,speed=0.03)

MOVE(1,1)
COLOR(BLANCO)
CURSOR(ON)

Definimos constantes en las líneas 5-10 para manejar los caracteres de bloques y medios bloques (en unicode existen también cuartos y octavos, pero la consola no los puede reflejar). Luego creamos varias exhibiciones de como podríamos mostrar una indicación de progreso. Para un programa real deberíamos crear funciones que se pudieran llamar para actualizar el estado de la barra según vamos progresando a lo largo del programa, y no gestionarlas con un temporizador como hacemos aquí.

En un programa de línea de comandos existe un problema con el control de la posición. Es imposible saber en que posición está el cursor, más allá de que al lanzar el programa se va a colocar al principio de una línea. Podemos borrar la pantalla y a partir de ahí llevar un cuidadoso control de la posición por medio del programa, o limitarnos a efectuar movimientos relativos, nunca absolutos, aparte de contar siempre cada caracter y salto de línea que realizemos. El trabajo es arduo, aunque una vez realizado podríamos aprovecharlo para muchos programas. En nuestro nivel de conocimientos actual no es la intención de este curso desarrollar un sistema práctico, sino entender mecanismos de programación. Para ver la demostración en la consola teclea:

>>> from CLIprogress import *

Un aspecto importante es la gestión de la entrada de datos. Nuestra familiar función input() no es muy competente en este ámbito en el que deberíamos tener siempre bajo control la posición y evitar la salida de determinados códigos. Aquí te presento una función de entrada que lee directamente el teclado y gestiona tanto la posición del campo de entrada como su longitud.

#CLI input

from CLI import *
import msvcrt

def Getchtest():
'''Comprobar los códigos que devuelve getwch()'''
while True:
ch=msvcrt.getwch()
if ord(ch)==13:
return
print(ch, ord(ch))

def CLInput(x,y,maxi):
'''Función de entrada de texto:

Se realiza en las coordenadas x,y y admite maxi caracteres'''

#Inicializamos las variables
text, pos = "", 0

MOVE(x,y)
#Bucle principal
while True:
ch=msvcrt.getwch()
or1=ord(ch)
if or1==13: #INTRO
return text
if or1==8: #Delete
if pos:
if pos==len(text):
pos-=1
text=text[:pos]
LEFT()
write(" ")
LEFT()
else:
pos-=1
text=text[:pos]+text[pos+1:]
LEFT()
LEFT(write(text[pos:]+" "))
elif or1==0 or or1==224: # Control Keys
or2=ord(msvcrt.getwch())
if or2==75: #LEFT
if pos:
pos-=1
LEFT()
elif or2==77: #RIGHT
if pos<len(text):
pos+=1
RIGHT()
elif or2==71: #HOME
pos=0
MOVE(x,y)
elif or2==79: #END
pos=len(text)
MOVE(x+pos,y)
else: #Introducir caracter
if pos<maxi and or1>31:
if pos==len(text):
text=text+ch
write(ch)
else:
text=text[:pos]+ch+text[pos:]
LEFT(write(text[pos:]))
RIGHT()
pos+=1

Empleamos la biblioteca msvcrt para acceder al teclado, y gestionamos las teclas de control a través de su valor numérico. Las teclas que tienen efecto de edición son INTRO (termina y devuelve el texto introducido), DEL (borra un caracter a la izquierda), LEFT y RIGHT (desplazan el cursor a izquierda o derecha), HOME (mueve el cursor al principio) y END (mueve el cursor al final). Si introducimos caracteres imprimibles (de código superior a 31) los guardamos en la variable text y los reflejamos en pantalla.

Hay cosas que no podemos hacer, como controlar si se pulsa la tecla MAYUSC a la vez que un control, por lo cual no podemos usar MAYUSC con las flechas para seleccionar texto (en realidad no hemos implementado ningún mecanismo para seleccionar texto). Si te interesa ver los códigos emplea la función Getchtest(). Para usar la función de entrada emplea:

>>> from CLInput import *
>>> CLInput(20,20,30)

De nuevo, para usarla en un entorno de línea de comandos podemos hacer que gestione la posición de forma relativa. Para ello elimina los parámetros x e y la línea 22. Luego hay que reemplazar los movimientos absolutos para gestionar HOME y END. Puedes guardarlo como "CLInput_rel.py".

elif or2==71: #HOME
LEFT(pos)
pos=0
elif or2==79: #END
RIGHT(len(text)-pos)
pos=len(text)

Y ya que estamos, podemos añadir un prompt como en el input original. Añade un parámetro prompt="" en la segunda posición de la declaración de parámetros y luego incorpora una línea write(prompt) antes del bucle principal.

Rizando el rizo, podemos hacer que una vez introducido el texto se borre la línea y nos coloquemos al principio de ella. Para eso solo tienes que introducir dos órdenes antes de la sentencia return text.

if or1==13: #INTRO
BACK(200)
CLine(0)
return text

Si has hecho las modificiaciones y lo has guardado en CLInput_rel, haz un código de prueba, puedes añadirlo al final del mismo fichero:

dir() # Así tenemos varias líneas impresas en pantalla
algo=CLInput(30,"Cuéntame algo: ")
write(f"Algo es <")
COLOR(VERDE)
write(algo)
COLOR(BLANCO)
write(">\n")

Y ejecútalo tecleando:

Python 3.8.6rc1 (tags/v3.8.6rc1:08bd63d, Sep 7 2020, 23:10:23) [MSC v.1927 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from CLInput_rel import *
Cuéntame algo: vale

Cuando hayas introducido el valor, la línea desaparece y se muestra una nueva salida.

Python 3.8.6rc1 (tags/v3.8.6rc1:08bd63d, Sep 7 2020, 23:10:23) [MSC v.1927 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from CLInput_rel import *
Algo es <vale>
>>>

Podríamos cambiar el enfoque del color, generar valores que podamos emplear directamente dentro de las instrucciones de impresión para simplificar el código pero si lo que queremos es un auténtico control del interfaz en modo texto podemos recurrir a la biblioteca curses, que está incorporada en la biblioteca estándar.

3.2.2 Control de la consola mediante curses

curses es un módulo que nos permite acceder a muchas más funciones de las que hemos visto hasta ahora, controlando la consola en todos los aspectos. De hecho curses incluye el concepto de ventanas de texto, con lo cual podemos crear un interfaz de ventanas en programas de consola. Vamos a comenzar por el programa con el que todo programador empieza a explorar un nuevo sistema.

#Hola curses

import curses

#Inicialización de curses
screen=curses.initscr()

#Mostramos un mensaje en pantalla
screen.addstr("Hola, curses")
screen.refresh()

#Esperamos una pulsación de tecla
while screen.getch()==-1:
pass

#Retornamos al estado normal de la consola
curses.endwin()

Ejecútalo desde el símbolo del sistema:

Microsoft Windows [Versión 10.0.19041.508]
(c) 2020 Microsoft Corporation. Todos los derechos reservados.

C:\Users\Miguel>pydir

C:\Users\Miguel\Documents\Desarrollo\Python>"Hola curses.py"

No hace falta que teclees el nombre entero ni las comillas, empieza a teclear Ho y luego usa la tecla <TAB>. El resultado es el que cabe esperar de un programa tipo "Hola, mundo", pero cuando se inicializa curses se borra el terminal y entramos en un modo en el que las cosas están gestionadas por la biblioteca. No es buena idea usar curses.initscr() desde el terminal, pruébalo y verás. Siempre hay que invocar curses.endwin() para volver al estado normal. En ese momento recuperamos la pantalla que teníamos antes del programa pero perdemos cualquier salida de este. Por ello curses está en una categoría diferente de un CLI, es algo intermedio entre este y un GUI (Interfaz gráfico de usuario), nuestro conocido entorno de ventanas gráficas.

En concepto fundamental de curses es el objeto ventana. Cuando invocamos curses.initscr() recibimos una ventana que corresponde a toda la pantalla del terminal, nosotros la guardamos en la variable screen. A partir de una ventana podemos invocar diversos métodos como .addstr() que muestra una cadena de caracteres en la ventana o .refresh(), que actualiza la pantalla para mostrar los cambios efectuados.

Esto último se debe a otro principio de diseño de curses, que es no escribir directamente en la pantalla sino en una réplica de esta en memoria, y sincronizar esta con aquella cuando así lo decidamos. De esta manera mejoramos enormemente el rendimiento de nuestro programa. Vamos a ver diversas funciones de curses.

Funciones de curses (selección)
curses.initscr() Inicializa curses. Devuelve un objeto ventana referido a toda la pantalla
curses.endwin() Des-inicializa la biblioteca y vuelve a la consola normal
curses.noecho() Inhabilita el eco de la entrada
Los caracteres introducidos no se reflejan en la salida
curses.echo() Habilita el eco de la entrada
Los caracteres introducidos se visualizan en la salida
curses.cbreak() Desactiva el buffer de entrada
Los caracteres se reciben instantáneamente sin tener que pulsar INTRO
curses.nocbreak() Activa el buffer de entrada
Los caracteres se almacenan hasta que pulsemos INTRO
curses.keypad(bool) Si el argumento es True traduce las teclas especiales
si es False devuelve las secuencias originales para estas teclas
curses.curs_set(valor) Define la visibilidad del cursor
0: invisible
1: normal - Suele ser un subrayado
2: muy visible - Suele ser un bloque
curses.longname() Devuelve el nombre descriptivo del terminal
curses.termname() Devuelve el nombre del terminal (máximo 14 caracteres)
curses.has_colors() Devuelve True si el terminal es capaz de mostrar colores
curses.can_change_color() Devuelve True si el terminal es capaz de redefinir los colores mostrados
curses.color_content(color) Devuelve una tupla con los componentes RGB de color (un entero)
Los valores para cada componente van de 0 a 1000
curses.init_color(color,r,g,b) Define un color por sus componentes RGB (de 0 a 1000)
Los colores se representan por números
curses.pair_content(par) Devuelve una tupla (fg,bg) con los colores
definidos en el par (un número)
curses.init_pair(par,fg,bg) Define el par de colores par (un número)
con los colores de fondo y primer término indicados
curses.color_pair(par) Devuelve el valor necesario para emplear el par de colores en una función
curses.termattrs() Devuelve un valor con las capacidades del terminal. Cada bit define una capacidad.
Si el bit es uno la capacidad está disponible, si es 0 no lo está.
Para comprobar una capacidad hacemos un AND a nivel de bits con la correspondiente constante. Por ejemplo:
#Si es cierto disponemos de letra en negrita
if curses.A_BOLD & curses.termattrs():
curses.newwin(lines, cols)
curses.newwin(lines, cols, x, y)
Crea una nueva ventana.
Si no indicamos la posición x,y se coloca arriba y a la izquierda
curses.start_color() Inicializa el color. Conviene llamarla inmediatamente despues de curses.initscr()
curses.update_lines_cols() Actualiza las variables curses.LINES y curses.COLS
según las dimensiones actuales del terminal
curses.wrapper(función) Inicializa curses e invoca función. Esta debe contener todo nuestro programa, así se garantiza que en caso de error se recupera el estado original del terminal.
curses.getsyx() Devuelve una tupla con las coordenadas del cursor (y,x)
curses.setsyx(y,x) Coloca la posición de escritura de pantalla en la posición x,y
curses.resizeterm(filas,cols) Redimensiona la ventana del terminal
curses.getmouse() Devuelve un evento de ratón en una tupla de cinco valores:
(id, x, y, z, estado). id identifica distintos dispositivos
x e y devuelven las coordenadas y estado el evento en los bits de un número
(z no se usa)
curses.mousemask(máscara) Define los eventos del ratón que serán notificados
máscara es un número con los eventos indicados por sus bits

Vamos a crear un programa para obtener información sobre nuestra implementación de curses, las capacidades disponibles en nuestro terminal.

#Curses1

import curses

#Función que comprueba los diferentes atributos
def check_attribute(attr):
screen.addstr(f"{attr:>21}"+": "+str(bool(attrs & eval("curses.A_"+attr)))+"\n")

#Inicialización de curses
screen=curses.initscr()
curses.start_color()

#Mostramos información en pantalla
screen.addstr("Curses inicializado: ")
screen.addstr(curses.longname()+b"\n\n")

screen.addstr(curses.termname()+b" version: "+curses.version+b"\n\n")

if curses.has_colors():
screen.addstr(f"Podemos mostrar {curses.COLORS} colores en {curses.COLOR_PAIRS} pares\n")
else:
screen.addstr("No podemos mostrar colores\n")

if curses.can_change_color():
screen.addstr("Podemos redefinir los colores\n")
else:
screen.addstr("No podemos redefinir los colores\n")

y,x=screen.getyx()
y+=1
x+=40
screen.addstr("\nATRIBUTOS DISPONIBLES\n\n")

attrs=curses.termattrs()

ATTRIBUTES=["ALTCHARSET","BLINK","BOLD","DIM","INVIS","ITALIC","NORMAL",
"PROTECT","REVERSE","STANDOUT","UNDERLINE","HORIZONTAL",
"LEFT","LOW","RIGHT","TOP","VERTICAL","CHARTEXT"]
for a in ATTRIBUTES:
check_attribute(a)

screen.move(y,x)
screen.addstr("COLORES BÁSICOS")

COLORES=["BLACK","BLUE","GREEN","CYAN","RED","MAGENTA","YELLOW","WHITE"]
for i,color in enumerate(COLORES):
curses.init_pair(i+1,i,0 if i>0 else 7)
screen.move(y+i+2,x)
screen.addstr(f"{color:<10}",curses.color_pair(i+1))
RGB=curses.color_content(i)
screen.addstr(f" {RGB[0]:4},{RGB[1]:4},{RGB[2]:4}")

screen.refresh()

#Esperamos una pulsación de tecla
while screen.getch()==-1:
pass

#Retornamos al estado normal de la consola
curses.endwin()

Las constantes curses.COLORS y curses.PAIRS indican el número de colores y pares de color disponibles. Se crean cuando llamamos a curses.star_color(). Para obtener o cambiar la posición del cursor empleamos screen.getyx() y screen.move(), que son métodos de la clase curses.window.

Para conocer las capacidades disponibles empleamos las constantes con la máscara de los atributos. En la lista ATTRIBUTES de la línea 36 introducimos los nombres parciales de dichas constantes. Luego en la función check_attribute() extraemos la constante añadiendo "curses.A_" a cada nombre, evaluando el resultado con eval() y utilizando el valor obtenido como máscara del valor attrs que hemos obtenido a partir de la función curses.termattrs().

Los ocho colores básicos se nombran en la lista COLORES de la línea 45, y corresponden a los números de color del 0 al 7. Curses no emplea los colores directamente, sino creando pares con el color de fondo y el de primer plano. Inicializamos 8 pares con cada color de la lista para primer plano manteniendo el fondo negro y luego mostramos los nombres en su color y los componentes RGB de cada uno.

Observa la expresión de la línea 47. Podemos emplear if y else como expresión para devolver distintos valores en función de una condición. Aquí seleccionamos un fondo negro para todos los colores de texto excepto para el negro, para el que elegimos fondo blanco. La fórmula general para emplear esta construcción es:

expresión1 if condición else expresión2

Desde la consola de Windows ejecútalo tecleando: curses1.py y pulsando INTRO.

Vemos que las capacidades del terminal incluyen REVERSE (invierte los colores de fondo y primer plano), STANDOUT (muestra el remarcado más intenso posible), UNDERLINE (subrayado), LEFT y RIGHT (añaden una línea a la izquierda o derecha de cada caracter). Vamos a crear un programa mínimo para ver cómo funcionan estos atributos.

#Curses2

import curses

#Inicialización de curses
screen=curses.initscr()
curses.start_color()

screen.addstr("REVERSE\n",curses.A_REVERSE)
screen.addstr("STANDOUT\n",curses.A_STANDOUT)
screen.addstr("UNDERLINE\n",curses.A_UNDERLINE)
screen.addstr("LEFT\n",curses.A_LEFT)
screen.addstr("RIGHT\n",curses.A_RIGHT)

screen.refresh()

#Esperamos una pulsación de tecla
while screen.getch()==-1:
pass

#Retornamos al estado normal de la consola
curses.endwin()

Puedes ver que usamos directamente las constantes de cada atributo en el método .addstr(). También podemos emplear nuestros programas de curses desde la carpeta de windows haciendo doble click. La salida del programa será la siguiente:

Estos son los atributos que podemos emplear en nuestro terminal. Si quisiéramos aplicar uno de ellos con un color deberíamos hacer un OR a nivel de bits con el resultado de la función curses.color_pair(par_nº). Si intentamos escribir en la última columna de la última fila de nuestra ventana curses produce un error.

#Curses3

import curses

def espera(screen):
while screen.getch()==-1:
pass

def main(screen):

ancho,alto=curses.COLS, curses.LINES

screen.addstr(((ancho*alto)-1)*"*")
screen.refresh()
espera(screen)

screen.addstr("*")
espera(screen)

#Usamos la función wrapper para gestionar
#La inicialización y el fina de curses
curses.wrapper(main)

En este programa empleamos la función curses.wrapper() para gestionar la inicialización y finalización correctas de curses aunque se produzcan errores. Nuestro programa está codificado dentro de la función main(). Comprobamos el tamaño de la consola e imprimimos asterisco para llenarla por completo, dejando libre justo la última posición. Esperamos la pulsación de una tecla e imprimimos el último asterisco en la última posición de la pantalla, o más bien intentamos imprimirlo, porque nuestro programa se termina con un mensaje de error.

Vamos a dar una vuelta de tuerca e incorporar gestión de errores para evitar esto.

#Curses3b

import curses

def espera(screen):
while screen.getch()==-1:
pass

def main(screen):

ancho,alto=curses.COLS, curses.LINES

screen.addstr(((ancho*alto)-1)*"*")
screen.refresh()
espera(screen)

try:
screen.addstr("*")
except curses.error:
pass
espera(screen)

#Usamos la función wrapper para gestionar
#La inicialización y el fina de curses
curses.wrapper(main)

Cada vez que utilizamos el método .addstr() lo envolvemos en una estructura try: -- except: que se limita a ignorar los errores de curses de forma que esta vez escribimos nuestro asterisco en la esquina sin provocar ningún error obteniendo una pantalla llena de asteriscos. Vamos a continuar, esta vez definiremos una función de impresión que incorpore la gestión de errores y trataremos de imprimir un texto que exceda ampliamente las dimensiones de la pantalla.

#Curses3c

import curses
import sys

def espera(screen):
while screen.getch()==-1:
pass

def escribe(window,*args):
for s in args:
try:
window.addstr(s)
except curses.error:
print(sys.exc_info())
continue

def main(screen):

ancho,alto=curses.COLS, curses.LINES

escribe(screen,((ancho*alto)-1)*"*")
screen.refresh()
espera(screen)

escribe(screen,"*")
espera(screen)

with open("Quijote.txt") as Quijote:
Quijotext=Quijote.read()
screen.clear()
escribe(screen,Quijotext)
espera(screen)

#Usamos la función wrapper para gestionar
#La inicialización y el final de curses
curses.wrapper(main)

El emplear una función simplifica el código enormemente, especialmente si tuviesemos que escribir a menudo en la pantalla. El fichero "quijote.txt" contiene los dos primeros capítulos del Quijote, copiados desde la página Cervantes virtual, para garantizar que hay texto suficiente para desbordar la pantalla. Al escribirlo se muestra según la capacidad de esta, pero no se produce ningún error. Vamos a comprobar el mismo programa pero creando una ventana, que es una de las habilidades que convierten curses en una herramienta tan potente.

#Curses3d

LINS=10
COLS=25

import curses
import sys

def espera(screen):
while screen.getch()==-1:
pass

def escribe(window,*args):
for s in args:
try:
window.addstr(s)
except curses.error:
print(sys.exc_info())
continue

def main(screen):

ancho,alto=curses.COLS, curses.LINES
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)

mywin=curses.newwin(LINS,COLS,(alto-LINS)//2,(ancho-alto)//2)
mywin.bkgd(" ",curses.color_pair(2))

escribe(mywin,((COLS*LINS)-1)*"*")
screen.refresh()
espera(mywin)

escribe(mywin,"*")
espera(mywin)

with open("Quijote.txt") as Quijote:
Quijotext=Quijote.read()
mywin.clear()
escribe(mywin,Quijotext)
mywin.refresh()
espera(screen)

#Usamos la función wrapper para gestionar
#La inicialización y el final de curses
curses.wrapper(main)

Comprobamos que la ventana recorta el espacio en que podemos escribir. De este modo podemos diseñar interfaces de ventanas en modo texto. Hemos definido un par de colores en la línea 24 y lo hemos aplicado como color de la ventana en la línea 27. Comprobarás que esta vez tenemos que emplear el método .refresh() para que se reflejen las modificaciones. Prueba a convertir en comentarios las líneas 30 o 40 y verás el resultado.

Vamos a crear un programa en el que escribimos tanto en la pantalla principal como en una ventana, y además no nos mantenemos ociosos mientras esperamos una pulsación de teclado.

#Curses4

WCOLS=20
WLINES=5


import curses

def main(screen):

ancho,alto=curses.COLS, curses.LINES
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)
curses.init_color(curses.COLOR_YELLOW,1000,1000,0)
curses.init_pair(3,curses.COLOR_YELLOW,curses.COLOR_BLUE)
curses.init_pair(4,curses.COLOR_RED,curses.COLOR_BLUE)

window=curses.newwin(WLINES,WCOLS,(alto-WLINES)//2,(ancho-WCOLS)//2)

screen.bkgd(" ",curses.color_pair(2))
screen.border()
screen.refresh()

window.bkgd(" ",curses.color_pair(3))
window.border()
window.refresh()

c=1
x=1
flag=True
window.nodelay(True)
#Esperamos una pulsación inicial
while window.getch()==-1:
pass
#Mantenemos un bucle hasta que no se vuelva a pulsar alguna tecla
while window.getch()==-1:
window.addstr(2,(WCOLS-len(str(c)))//2,str(c))
c+=1
window.refresh()
if flag:
screen.addch(alto//2-1,int(x),">",curses.color_pair(4))
x+=.01
if x>=ancho-1:
x=1
flag=False
else:
screen.addch(alto//2-1,int(x)," ")
x+=.01
if x>=ancho-1:
x=1
flag=True
screen.refresh()
curses.doupdate()

#Usamos la función wrapper para gestionar
#La inicialización y el fin de curses
curses.wrapper(main)

El resultado es el siguiente:

Vamos mostrando un contador y también una línea que recorre la pantalla de izquierda a derecha. Observarás que al escribir en una ventana podemos invadir el espacio de otras ventanas inscritas en ella. curses incluye un módulo curses.panel que crea estructuras que nos permiten colocar unas ventanas delante de otras, pero por desgracia en la versión actual no funciona en mi intérprete, de modo que tenemos que controlar en nuestro programa la impresión para evitar esto.

#Curses4b

WCOLS=20
WLINES=5


import curses

def main(screen):

curses.curs_set(0)
ancho,alto=curses.COLS, curses.LINES
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)
curses.init_color(curses.COLOR_YELLOW,1000,1000,0)
curses.init_pair(3,curses.COLOR_YELLOW,curses.COLOR_BLUE)
curses.init_pair(4,curses.COLOR_RED,curses.COLOR_BLUE)

window=curses.newwin(WLINES,WCOLS,(alto-WLINES)//2,(ancho-WCOLS)//2)

screen.bkgd(" ",curses.color_pair(2))
screen.border()
screen.refresh()

window.bkgd(" ",curses.color_pair(3))
window.border()
window.refresh()

c=1
x=1
flag=True
screen.nodelay(True)
#Esperamos una pulsación inicial
while window.getch()==-1:
pass
#Mantenemos un bucle hasta que no se vuelva a pulsar alguna tecla
while screen.getch()==-1:
if flag:
if int(x)<(ancho-WCOLS)//2 or int(x)>(ancho-WCOLS)//2+WCOLS-1:
screen.addch(alto//2-1,int(x),">",curses.color_pair(4))
x+=.01
if x>=ancho-1:
x=1
flag=False
else:
if int(x)<(ancho-WCOLS)//2 or int(x)>(ancho-WCOLS)//2+WCOLS-1:
screen.addch(alto//2-1,int(x)," ")
x+=.01
if x>=ancho-1:
x=1
flag=True
window.addstr(2,(WCOLS-len(str(c)))//2,str(c))
c+=1
window.refresh()

#Usamos la función wrapper para gestionar
#La inicialización y el fin de curses

curses.wrapper(main)

Hay un poco de trabajo extra, pero el resultado es el deseado.

Vamos a afinar el sistema de entrada; no nos limitaremos a esperar cualquier pulsación del teclado, sino una tecla concreta. Además vamos a incorporar el ratón, algo imprescindible en un interfaz actual. Para identificar las teclas hay que comprobar el valor de retorno del método .getch(). Para usar el ratón primero tenemos que activar los eventos que deseamos que el programa reciba. Esto se hace mediante la función curses.mousemask() y una máscara de bits, es decir, un número cuyos dígitos en la representación binaria son ceros para desactivar una característica y unos para activarla. En este caso las características se refieren a las diferentes pulsaciones del ratón. curses puede identificar la pulsación y liberación de cada botón y los clicks sencillo o doble, entre otros. Una vez activado el ratón .getch() devuelve un valor especial si se produce un evento que haya sido activado. Dicho valor corresponde a la constante curses.KEY_MOUSE.

Nuestro programa es una modificación del anterior, eliminamos el contador y mostramos los eventos del teclado o del ratón en la ventana central.

#Curses5

WCOLS=20
WLINES=5

import curses

def main(screen):

curses.curs_set(0)
ancho,alto=curses.COLS, curses.LINES
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)
curses.init_color(curses.COLOR_YELLOW,1000,1000,0)
curses.init_pair(3,curses.COLOR_YELLOW,curses.COLOR_BLUE)
curses.init_pair(4,curses.COLOR_RED,curses.COLOR_BLUE)

window=curses.newwin(WLINES,WCOLS,(alto-WLINES)//2,(ancho-WCOLS)//2)

screen.bkgd(" ",curses.color_pair(2))
screen.border()
screen.refresh()
screen.keypad(True)
curses.mousemask(0xFFFF)

window.bkgd(" ",curses.color_pair(3))
window.border()
window.refresh()

x=1
flag=True
screen.nodelay(True)
#Mantenemos un bucle hasta pulsar q o doble click en la ventana
#Mostramos los eventos del ratón
while (ch:=screen.getch())!=ord("q"):
#Procesamos los eventos del ratón
if ch==curses.KEY_MOUSE:
_, mousex, mousey, _, state = curses.getmouse()
window.addstr(1,2,"MOUSE")
window.addstr(2,2,f"{state:>016b}")
window.addstr(3,2,f"x,y=<{mousey},{mousex}>")
if mousex>=(ancho-WCOLS)//2 and mousex<(ancho-WCOLS)//2+WCOLS and \
mousey>=(alto-WLINES)//2 and mousey<(alto-WLINES)//2+WLINES:
if state & curses.BUTTON1_DOUBLE_CLICKED:
return
#Procesamos el resto de eventos
elif ch!=-1:
window.addstr(1,2,"KEY  ")
window.addstr(2,2,f"{ch:>3} {chr(ch) if ch>31 else 'ctrl':12}")
window.addstr(3,2," "*(WCOLS-4))
if flag:
if int(x)<(ancho-WCOLS)//2 or int(x)>(ancho-WCOLS)//2+WCOLS-1:
screen.addch(alto//2-1,int(x),">",curses.color_pair(4))
x+=.01
if x>=ancho-1:
x=1
flag=False
else:
if int(x)<(ancho-WCOLS)//2 or int(x)>(ancho-WCOLS)//2+WCOLS-1:
screen.addch(alto//2-1,int(x)," ")
x+=.01
if x>=ancho-1:
x=1
flag=True
window.refresh()

#Usamos la función wrapper para gestionar
#la inicialización y el fin de curses
curses.wrapper(main)

En el caso de eventos de ratón, mostramos la máscara correspondiente al evento que se ha producido y las coordenadas <y, x> (curses emplea las coordenadas al revés que las demás bibliotecas de Python). Todas ellas tienen las correspondientes constantes definidas en el módulo curses:

Eventos del ratón de curses
BUTTONn_PRESSED Pulsación del botón
BUTTONn_RELEASED Liberación del botón
BUTTONn_CLICKED Click sencillo
BUTTONn_DOUBLE_CLICKED Doble click
BUTTONn_TRIPLE_CLICKED Triple click
BUTTON_SHIFT Tecla MAYÚSC pulsada
BUTTON_CTRL Tecla CTRL pulsada
BUTTON_ALT Tecla ALT pulsada
(El caracter n debe ser sustituído por un valor de 1 a 4 que representa el número del botón)

Si importamos la biblioteca de la forma habitual hay que anteponer siempre "curses." a todos los identificadores. Como cada evento está representado por un bit, podemos obtener varios simultáneamente, en concreto podemos comprobar si se ha pulsado una o más de las teclas <MAYUSC>, <CTRL> o <ALT> a la vez que el ratón. Hemos activado el método de interpretación especial de teclas .keypad(), de forma que se devuelven valores especiales (mayores de 255) para combinaciones de teclas con las teclas modificadoras. Así podemos reaccionar a estas combinaciones si es necesario. Vamos a enumerar algunos de los métodos del objeto ventana, a partir de los cuales se construyen los programas de curses. Para una referencia completa tienes la documentación oficial.

Métodos de la clase curses.window
.addch([y,x,] ch [,attr]) Escribe un caracter en la ventana
Opcionalmente podemos indicar la posición y/o el atributo
.addnstr([y,x,] str [,attr]) Escribe hasta n caracteres de la cadena str en la ventana
Opcionalmente podemos indicar la posición y/o el atributo
.addstr([y,x,] str [,attr] Escribe la cadena str en la ventana
Opcionalmente podemos indicar la posición y/o el atributo
.attrof(attr)
.attron(attr)
.attrset(attr)
Elimina el atributo attr para nuevas escrituras en la ventana
Añade el atributo attr para nuevas escrituras en la ventana
Fija los atributos attr para la ventana
.bkgd(ch [,attr]) Define el fondo y opcionalmente los atributos de la ventana
Rellena toda la ventana con el fondo definido
.bkgdset(ch [,attr]) Define el fondo y opcionalmente los atributos de la ventana
Se emplea para cualquier impresión posterior
.border([border-chars]) Pinta un borde alrededor de la ventana
Opcionalmente podemos indicar los caracteres del borde por este orden:
←,→,↑,↓,↖,↗,↙,↘
El valor por defecto crea un borde de línea sencilla
Los argumentos son posicionales, podemos indicar solo algunos
para los que no indiquemos o pongamos un 0 se utiliza el valor por defecto
.box([vertch, horch]) Crea un recuadro alrededor de la ventana
Podemos indicar los caracteres para las líneas verticales y horizontales
en las esquinas se utiliza el valor por defecto
.chgat([y,x,][num,]attr) Cambia los atributos en la posición del cursor
Si indicamos y,x utiliza esa posición
Si indicamos num lo hace para esa cantidad de caracteres
.clear() Borra la ventana (la rellena con el caracter y atributos de fondo)
.clrtobot() Borra desde la posición del cursor hasta el final de la ventana
.clrtoeol() Borra desde la posición del cursor hasta en fin de la línea
.delch([y,x]) Borra el caracter en la posición del cursor o en la posición indicada
Desplaza el resto de la línea desde la derecha
Si indicamos una posición mueve el cursor a ella
.deleteln() Borra la línea del cursor desplazando el resto de líneas desde debajo
.enclose(y,x) Comprueba si el par de coordenadas está dentro de la ventana
en caso afirmativo devuelve True
.encoding() Devuelve la codificación de caracteres aplicada en la ventana
.getbegyx() Devuelve las coordenadas de la esquina superior izquierda en una tupla
.getbkgd() Devuelve el caracter y atributo de fondo de la ventana
Los 8 bits inferiores corresponden al caracter, el resto al atributo
.getch([y,x]) Devuelve un caracter en forma de entero desde la entrada estándar
En modo no-delay devuelve -1 si no hay ningún caracter disponible
Si se indican coordenadas desplaza el cursor a ellas
.get_wch([y,x]) Devuelve un caracter ampliado. Para las teclas normales devuelve el caracter de la tecla
Para las teclas especiales devuelve un valor entero
En modo no-delay produce un error si no hay una tecla disponible
Si se indican coordenadas desplaza el cursor a ellas
.getkey([y,x]) Devuelve una tecla en forma de caracter
Para las teclas especiales devuelve una cadena con el nombre de tecla
En modo no-delay produce un error si no hay una tecla disponible
Si se indican coordenadas desplaza el cursor a ellas
.getmaxyx() Devuelve una tupla con el tamaño de la ventana, algo y ancho
.getstr([y,x],[n]) Devuelve una cadena de bytes desde la entrada estándar
Proporciona una mínima capacidad de edición
Los parámetros opcionales n y y,x indican
la longitud máxima de la cadena y la posición
.getyx() Devuelve una tupla con la posición del cursor dentro de la ventana
.hline([y,x,]ch,n)
.vline([y,x,]ch,n)
Traza con el caracter ch una línea horizontal o vertical de longitud n
Opcionalmente podemos indicar la posición de origen
.inch([y,x]) Devuelve el caracter en la posición del cursor o en la posición indicada
el caracter está representado en los 8 bits inferiores del valor devuelto
y los atributos en la parte superior
.insch([y,x,]ch[,attr]) Inserta el caracter ch desplazando el resto de la línea a la derecha
.insdelln(nºlines) Inserta nºlines en blanco en la posición actual
desplaza el resto hacia abajo, perdiéndose las nºlines inferiores
Para valores negativos borra nºlines en la posición del cursor
y desplaza el resto hacia arriba. Mantiene la posición del cursor
.insertln() Inserta una línea en blanco en la posición del cursor
desplazando el resto una línea hacia abajo
.insnstr([y,x,]str,n[,attr]) Inserta hasta n caracteres de la cadena str
desplazando la línea a la derecha. Si n es -1 inserta toda la cadena
Si se excede el margen derecho no se escriben más caracteres
.instr([y,x],[n]) Devuelve una cadena de bytes con los caracteres sin atributos
desde la posición del cursos o desde y,x si se indican
El argumento opcional n indica el número de caracteres leídos
si no se indica se lee hasta el final de línea
.keypad(flag) Si flag es True las secuencias de escape de las teclas especiales son
traducidas por curses, de otro modo se envía tal cual
.leaveok(flag) Si flag es True la imágen del cursor se mantiene donde esté
sin reflejar los cambios en la posición del cursor que se siguen produciendo
.move(y,x) Mueve la posición del cursor al punto indicado
.mvwin(y,x) Mueve la ventana de forma que su esquina superior derecha pasa a la posición indicada
.nodelay(flag) Si flag es True las llamadas de lectura del teclado
no esperan a que haya una pulsación disponible
.refresh() Actualiza inmediatamente la ventana en la salida del terminal
.resize(lines,cols) Cambia el tamaño de la ventana. Si el nuevo tamaño es mayor,
el espacio se rellena con el caracter y atributos de fondo
.standout()
.standend()
Activa o desactiva el modo A_STANDOUT

Estos son los métodos que usaremos por el momento. Además de la clase ventana existe una clase especial: los pads, que son ventanas que no están limitadas por el tamaño de la pantalla y que no se asocian con ninguna parte de esta. Podemos crear ventanas de un tamaño mayor y mostrar solo una parte de ellas en cada momento. Los pads se crean mediante la función curses.newpad(nºlines, nºcols)y disponen de los mismos metodos que las ventanas, pero el método .refresh() requiere seis argumentos: las coordenadas y, x de la esquina superior izquierda a mostrar del pad, y la región de pantalla (top, left, bottom, right) donde se mostrará. He aquí un ejemplo con la clase pad.

#Curses6: pads

PADY=200
PADX=80

import curses


def main(screen):

ancho, alto = curses.COLS, curses.LINES
pad=curses.newpad(PADY,PADX)
curses.init_color(curses.COLOR_WHITE,1000,1000,1000)
curses.init_pair(1,curses.COLOR_BLACK,curses.COLOR_WHITE)
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)
screen.bkgd(" ",curses.color_pair(2))
pad.bkgd(" ",curses.color_pair(1))

with open("Quijote.txt",encoding="UTF-8") as Quijote:
Quijotext=Quijote.read()
screen.clear()
screen.refresh()
try:
pad.addstr(Quijotext)
except curses.error:
pass

screen.addstr(4,1,f"PANTALLA: {alto}x{ancho}")
screen.addstr(alto-7,1,"\u2190 Izquierda")
screen.addstr(alto-6,1,"\u2192 Derecha")
screen.addstr(alto-5,1,"\u2191 Arriba")
screen.addstr(alto-4,1,"\u2193 Abajo")
screen.addstr(alto-2,1,"q para salir")


toppad=0
leftpad=0
screen.nodelay(True)
curses.curs_set(False)
while (ch:=screen.getch())!=ord("q"):
if ch==curses.KEY_DOWN:
if toppad<PADY-alto-1:
toppad+=1
elif ch==curses.KEY_UP:
if toppad>0:
toppad-=1
elif ch==curses.KEY_LEFT:
if leftpad>0:
leftpad-=1
elif ch==curses.KEY_RIGHT:
if leftpad<PADX-60-1:
leftpad+=1
elif ch==curses.KEY_HOME:
toppad=0
elif ch==curses.KEY_END:
toppad=PADY-alto-1
screen.addstr(1,2,f"  Línea: {toppad:>4}")
screen.addstr(2,2,f"Columna: {leftpad:>4}")
pad.refresh(toppad,leftpad,0,(ancho-60)//2,alto-1,(ancho+60)//2)

#Usamos la función wrapper para gestionar
#La inicialización y el final de curses
curses.wrapper(main)

Observa que definimos el tamaño del pad en dos variables PADX y PADY. Esta es una buena práctica de programación; si deseásemos cambiar estos valores solo tendríamos que modificarlos en la definición de la constante. Además, al observar el código es más explicativo ver los nombres de las constantes que simples números.

En las líneas 11-17 creamos el pad y configuramos los colores. A continuación leemos el fichero "Quijote.txt" y lo volcamos en el pad. Al utilizar un bloque de gestión de errores se carga el texto hasta agotar la capacidad del pad sin producir errores.

Presentamos un menú de opciones en la esquina inferior izquierda y mostramos las dimensiones de la consola y la posición actual de visión del pad en la esquina superior derecha. Luego inicializamos el punto de visualización del pad y configuramos un par de aspectos de curses. En la línea 38 activamos el modo de no-espera para la entrada de datos. Esto representa que al emplear funciones de entrada estas no esperan a que haya una pulsación disponible, empleamos .getch() porque en dicho caso no produce un error sino que devuelve el valor -1. En la línea siguiente desactivamos la visualización del cursor.

EL bucle principal (que se mantiene hasta que pulsemos "q") comprueba si hemos pulsado las teclas de movimiento del cursor y en dicho caso actualiza las variables toppad y leftpad que indican la esquina superior izquierda a mostrar dentro del espacio del pad. Mira el código, hay dos teclas funcionales que no hemos mostrado en el menú de pantalla. Finalmente mostramos las nuevas coordenadas y en la línea 59 mostramos el área del pad indicada. El aspecto del programa en ejecución es el siguiente:

Si te gusta poner a prueba los programas redimensiona la ventana. Si la haces mayor simplemente se crea un área sin pintar, en negro, alrededor de nuestra pantalla. Si tratas de reducir las dimensiones el programa se interrumpe con un error al intentar escribir fuera de los límites de la consola. Vamos a añadir algo de código para gestionar mejor este aspecto.

#Curses6b: pads con redimensionamiento

PADY=200
PADX=80

import curses


def main(screen):

ancho, alto = 0,0
pad=curses.newpad(PADY,PADX)
curses.init_color(curses.COLOR_WHITE,1000,1000,1000)
curses.init_pair(1,curses.COLOR_BLACK,curses.COLOR_WHITE)
curses.init_pair(2,curses.COLOR_WHITE,curses.COLOR_BLUE)
screen.bkgd(" ",curses.color_pair(2))
pad.bkgd(" ",curses.color_pair(1))

with open("Quijote.txt",encoding="UTF-8") as Quijote:
Quijotext=Quijote.read()
screen.clear()
screen.refresh()
try:
pad.addstr(Quijotext)
except curses.error:
pass

toppad=0
leftpad=0
screen.nodelay(True)
curses.curs_set(False)
while (ch:=screen.getch())!=ord("q"):
#Comprobamos si la pantalla cambia de tamaño
curses.update_lines_cols()
anchonew, altonew = curses.COLS, curses.LINES
if ancho!=anchonew or alto!=altonew:
ancho, alto = anchonew, altonew
screen.clear()
screen.addstr(4,1,f"PANTALLA: {alto}x{ancho}")
screen.addstr(alto-7,1,"\u2190 Izquierda")
screen.addstr(alto-6,1,"\u2192 Derecha")
screen.addstr(alto-5,1,"\u2191 Arriba")
screen.addstr(alto-4,1,"\u2193 Abajo")
screen.addstr(alto-2,1,"q para salir")
if ch==curses.KEY_DOWN:
if toppad<PADY-alto-1:
toppad+=1
elif ch==curses.KEY_UP:
if toppad>0:
toppad-=1
elif ch==curses.KEY_LEFT:
if leftpad>0:
leftpad-=1
elif ch==curses.KEY_RIGHT:
if leftpad<PADX-60-1:
leftpad+=1
elif ch==curses.KEY_HOME:
toppad=0
elif ch==curses.KEY_END:
toppad=PADY-alto-1
screen.addstr(1,2,f"  Línea: {toppad:>4}")
screen.addstr(2,2,f"Columna: {leftpad:>4}")
pad.refresh(toppad,leftpad,0,(ancho-60)//2,alto-1,(ancho+60)//2)

#Usamos la función wrapper para gestionar
#La inicialización y el final de curses
curses.wrapper(main)

Hemos asignado un tamaño inicial de 0,0 para garantizar que la pantalla se dibuja correctamente en la primera vuelta del bucle. Hemos incluído la presentación del menú dentro de este para poder colocarlo correctamente cuando se produzcan cambios. Actualizamos las dimensiones actuales de la consola en la línea 34 y leemos las nuevas dimensiones. Si se ha producido algún cambio, borramos la pantalla y redibujamos en menú. Ejecútalo y verás que ahora podemos redimensionar la ventana, aunque si lo hacemos muy rápido podamos perder el menú y si reducimos el ancho demasiado el pad no cabrá y se producirá un error, pero algo es algo. Intenta pensar por tí mismo cómo resolver este último problema.

3.2.3 Diseño de programas. Diagramas de flujo

Hasta ahora hemos realizado pequeños programas, pero según va creciendo el volumen de estos resulta peor la estrategia de construirlos al vuelo, manteniendo una idea aproximada en la cabeza de cómo queremos realizar cada tarea. Vamos a recapacitar en las fases de construcción de un programa:

1. Análisis

El principio de todo es establecer de una forma precisa y detallada cuál es el problema que queremos resolver, y qué clase de solución es la que necesitamos. En este curso, de momento, abordamos cuestiones lo bastante simples para no requerir un análisis muy detallado.

2. Diseño

Esta es la fase en la que vamos a centrarnos en este momento. Se trata de descender a un nivel de proceso de datos para evaluar cuales son los algoritmos aplicables o de construir dichos algoritmos y establecer la secuencia de pasos que nuestro programa debe llevar a cabo para obtener el resultado buscado.

3. Codificación

La codificación es la realización en un lenguaje de programación de los pasos definidos en la fase de diseño, es el momento en el que empezamos a teclear codigo Python.

4. Depuración

La depuración consistiría en poner el código a prueba planteando las circunstancias en las que pueden producirse errores, y buscando solucionar estos. Consiste en ejecutar el programa y tratar de ver qué situaciones pueden hacerlo fallar y de encontrar los errores de codificación y de diseño. Un proceso paralelo sería la optimización, buscar las formas de aumentar la eficacia.

Centrándonos en los aspectos del diseño lo primero que plantearemos es una estrategia de subdivisión de problemas (top-down). Esto consiste en enunciar, sin definirlas, las diferentes etapas en la solución, y luego afrontar cada una del mismo modo. Convertimos un problema en una serie de subproblemas, y vamos dividiendo estos en piezas más pequeñas hasta llegar a un punto en que las soluciones son sencillas. Para esto vamos a utilizar dos herramientas del programador, el pseudo-código y los diagramas de flujo.

El pseudocódigo consiste en escribir los pasos sucesivos en un lenguaje intermedio entre el lenguaje natural y el lenguaje de programación. Esto nos ayuda a crear los algoritmos de una forma comprensible que luego podemos traducir fácilmente al código de nuestro lenguaje. Por ejemplo:

PASO1
Generar un valor aleatorio
PASO2
Pedir al usuario otro valor
Si los valores coinciden
Escribir "¡Lo has adivinado!"
TERMINAR
Si no
Escribir "No es ese..."
repetir desde PASO2

Los diagramas de flujo son una representación gráfica de los pasos a realizar, consisten en una serie de casillas (nodos) en las que podemos incluir pseudocódigo conectadas mediante líneas con una dirección establecida, de forma que representan el flujo del programa.

Podemos distribuir el flujo de forma horizontal o vertical, son más habituales los diagramas en vertical. Por supuesto que luego puede haber todo tipo de ramificaciones. Si nuestro diagrama no cabe en una página podemos usar conectores de página, pequeños círculos con una identificación que deben coincidir. Tambien podemos incluir nodos que luego detallemos en diagramas aparte. Hay varios estándares, nosotros emplearemos aproximádamente el del Instituto Nacional Estadounidense de Estándares o ANSI, adoptado después por la Organización Internacional de Normalización o ISO:

Todo programa tiene (al menos) un punto de entrada y uno de salida. Los representamos mediante los recuadros con las esquinas redondeadas, y utilizaremos etiquetas como START y END (el mundo de la programación está muy metido en el idioma inglés, dado que la mayor parte de los lenguajes de programación han creado sus órdenes a partir de palabras inglesas). Un rectángulo representa un proceso con los datos, el trapezoide una interacción de Entrada/Salida y un rombo es una decisión, realmente una bifurcación en función de una condición, es decir, lo que solemos ejecutar mediante una sentencia condicional, pero también los bucles.

Raramente tenemos diagramas de flujo lineales, solamente en programas extremadamente sencillos. Vamos a presentar uno de estos diagramas de flujo para un convertidor de grados Farenheit a Celsius, y una forma genérica que es aplicable casi al 100% de los programas que desarrollemos.

El diagrama de la izquierda obtiene un valor numérico y luego mediante una sencilla ecuación lo transforma en otro, asumiendo que el primer valor representa grados Farenheit el resultado representa el mismo valor en Celsius. El de la derecha representa un código abstracto con los pasos habituales en un programa cualquiera. Podríamos añadir un proceso de finalización antes del nodo END y tendríamos el esquema conceptual de casi todos los programas que realizemos.

Los diagramas de flujo proporcionan una información visual excelente del desarrollo de un programa, podemos combinarlos con pseudocódigo al definir los distintos procesos. Una vez conseguido un esquema general podemos trabajar más detalladamente cada nodo dividiéndolo a su vez en otros diagramas hasta conseguir un algoritmo completo. Por supuesto, hay muchas procesos cuyas soluciones ya están disponibles, generalmente en forma de funciones definidas dentro de módulos (nosotros mismos podemos ir creando una biblioteca propia con tales soluciones) y en ese caso lo más correcto es emplear las soluciones ya disponibles, aunque en un entorno de aprendizaje puede ser más instructivo volver a reinventar la rueda.

Puestos a inventar ruedas, vamos a crear una serie de funciones matemáticas elementales. Aprovecharemos la función primos() que desarrollamos en la sección 3.1.5. El objetivo es obtener sendas funciones para obtener el máximo común divisor y el mímino común múltiplo de una serie de números. Para empezar vamos a descomponer un número en factores primos.

Descomponer valor en factores:
Crear un diccionario vacío para los factores
Crear una lista de primos hasta valor (incluído)
BUCLE: mientras valor sea mayor que 1
# Buscar el primer primo divisor de valor. Para ello:
BUCLE2: Recorrer la lista de primos
Si valor módulo primo es 0
Si primo está en factores
Incrementar cuenta para primo
De otro modo
Añadir primo a valores con cuenta igual 1
Dividir valor entre primo
Saltar a BUCLE
Saltar a BUCLE2 para el siguiente primo
Devolver el diccionario de factores

El diagrama de flujo equivalente sería algo así:

Vemos claramente que hay un bucle exterior, cuya condición es que valor sea mayor que uno y otro bucle interior que empieza por el primer elemento y va progresando a través de la lista de primos. EL primero es un claro candidato a bucle while y el segundo corresponde obviamente con un bucle for. Poniéndonos a codificar nos quedaría el siguiente programa.

#Factorize - Obtener factores primos

from primos_v2 import *

DEBUG=False

def factorize(n):
factores={}
primos=[i for i in range(2,n+1) if primo(i)]
while n>1:
for i in primos:
if n%i==0:
if i in factores:
factores[i]+=1
else:
factores[i]=1
n=n//i
break
return factores

La función primos() puede tomarse su tiempo si introducimos valores grandes. Puede darnos la impresión de que el programa se cuelga. Esto se reduce para ejecuciones posteriores, una vez que la lista interna de números primos va creciendo. En cualquier caso no es recomendable usar la función para valores que excedan los seis dígitos, si lo haces, ten paciencia para esperar el resultado. Pongamos a prueba nuestra función factorize().

>>> from Factorize import factorize
>>> factorize(1000)
{2: 3, 5: 3}
>>> factorize(24567)
{3: 1, 19: 1, 431: 1}
>>> factorize(65536)
{2: 16}
>>> factorize(.5)
Traceback (most recent call last):
File "<pyshell#27>", line 1, in <module>
factorize(0.5)
File "C:\Users\Miguel\Documents\Desarrollo\Python\factorize.py", line 9, in factorize
primos=[i for i in range(2,n+1) if primo(i)]
TypeError: 'float' object cannot be interpreted as an integer

>>>

La función funciona, valga la redundancia. Vamos a emplear pseudocódigo para elaborar las funciones máximo común divisor y mínimo común múltiplo, partiendo de las fórmulas para obtenerlos en función de sus factores. Hay algoritmos más eficientes, pero utilizando los factores podemos realizar el cálculo para múltiples valores a la vez. La fórmula para el MCD es:

MCD = Producto de todos los factores comunes al menor exponente

El asunto consiste en obtener los factores, y luego averiguar cuáles son comunes y cuál es el menor exponente de cada uno.

MCD - Máximo Común Divisor:

PASO 1: Obtener los factores primos de todos los argumentos
PASO 2: Obtener aquellos factores comunes y sus menores exponentes
PASO 3: Multiplicar los factores elevados a su exponente

=================================================================

PASO 1
Usamos factorize() para cada argumento y guardamos los resultados

PASO 2
# Empleamos la primera lista de factores
# puesto que nos interesan los comunes.

BUCLE1: para cada factor y exponente de la primera serie
Guardamos el exponente en una variable temporal
BUCLE2: para cada lista de factores a partir de la segunda
Si el factor de BUCLE1 no está en el diccionario de BUCLE2
Abortamos BUCLE2 y volvemos a BUCLE1
Tomamos el mínimo de los exponentes entre guardado y actual
Reanudamos BUCLE2
Si el factor era común a todos:
Guardamos el factor y el exponente mínimo
Reanudamos BUCLE1

PASO 3
Consideramos un valor provisional 1 para mcd
Si se han encontrado factores comunes
BUCLE 3: Para cada factor y su exponente
multiplicamos mcd por factor elevado a exponente
Devolvemos mcd

El paso 1 requiere un bucle for para recorrer los argumentos y obtener su descomposición en factores primos mediante nuestra función factorize(). El paso 2 requiere el mayor esfuerzo. Está claro que hemos de emplear dos bucles for anidados, y para saber si el factor es común a todos usaremos una variable booleana como marcador. El paso 3 emplea un nuevo bucle for sobre el diccionario de factores comunes obtenido en el paso 2. Pongámonos a codificar:

#Mates

from Factorize import factorize

def MCD(x, y, *args): #Forzamos un mínimo de dos argumentos
'''Devuelve el Máximo Común Divisor de sus argumentos'''

#PASO 1: Obtenemos los factores de todos los argumentos
factores=[factorize(x),factorize(y)]
for arg in args:
factores.append(factorize(arg))

#PASO 2: Obtenemos los factores comunes al mínimo exponente
comunes={}
for fac,minexp in factores[0].items():
flag=True
for faclist in factores[1:]:
if fac not in faclist:
flag=False
break
minexp=min(minexp,faclist[fac])
if flag:
comunes[fac]=minexp

#PASO 3: Multiplicamos todos los factores elevados a sus exponentes
mcd=1
if comunes!={}:
for k,v in comunes.items():
mcd*=k**v
return mcd

Podemos ejecutar nuestro módulo directamente para que quede definida la función MCD o podemos importar todas las funciones del módulo con una sentencia como: from Mates import * o solo la función MCD emplando: from Mates import MCD. Puedes ver que he llamado al fichero "Mates.py", dado que vamos a ir incorporando una serie de funciones matemáticas.

>>> from Mates import *
>>> MCD(256,576,704)
64
>>> MCD(1024,1026)
2
>>>

Puedes probar las combinaciones que quieras. Si incluímos más de tres números es bastante posible que obtengamos valores muy bajos, incluso 1. En todo caso la función devuelve un resultado correcto, que es lo necesario. Si usamos algún valor por encima de 100000 y resulta ser primo puede que sea un poco lenta. Vamos a construir la función casi gemela, MCM() o mínimo común múltiplo.

Para este nuevo proceso plantearemos una estrategia diferente: considerar todos los factores y su máximo exponente. Luego solo hay que multiplicar por los factores elevados a sus exponentes.

MCM - Mínimo Común Múltiplo:

PASO 1: Obtener los factores primos de todos los argumentos
PASO 2: Obtener todos los factores y sus mayores exponentes
PASO 3: Multiplicar los factores elevados a su exponente

=================================================================

PASO 1
Usamos factorize() para cada argumento y guardamos los resultados

PASO 2
# Exploramos todos los factores de todas las series
Creamos un diccionario de factores vacío.
BUCLE1: para cada serie
BUCLE2: para cada factor de la serie
Si está en el diccionario:
Seleccionamos el máximo exponente

De otro modo:
Lo añadimos al diccionario con su exponente

PASO 3
Consideramos un valor provisional 1 para mcm
BUCLE3: Para cada factor del diccionario
multiplicamos mcm por factor elevado a exponente
Devolvemos mcm

Nos ponemos a codificar ampliando el mismo fichero "Mates.py" de la vez anterior y tendríamos la siguiente función:

def mcm(x, y, *args):
'''Devuelve el Mínimo Común Múltiplo de sus argumentos'''

#Paso 1: Obtenemos los factores de todos los argumentos
factores=[factorize(x),factorize(y)]
for arg in args:
factores.append(factorize(arg))

#PASO 2: Exploramos todos los factores obtenidos
comunes={}
for serie in factores:
for factor in serie:
if factor in comunes:
comunes[factor]=max(comunes[factor],serie[factor])
else:
comunes[factor]=serie[factor]

#PASO 3: Multiplicamos comunes al mayor exponente y no comunes
MCM=1
for factor, exponente in comunes.items():
MCM*=factor**exponente
return MCM

Vamos a ponerlo a prueba, además sabemos que: mcm(a,b) = a·b / MCD(a,b)  Podemos emplearlo como test adicional cuando solo haya dos parámetros.

>>> from Mates import *
>>> mcm(40,100)
200
>>> 40*100/MCD(40,100)
200.0
>>> mcm(8,12)
24
>>> 8*12/MCD(8,12)
24.0
>>> mcm(8,32,12)
96
>>> 8*32*12/MCD(8,32,12)
768.0
>>> (8*32//MCD(8,32))*12/MCD(8*32//MCD(8,32),12)
96.0
>>>

De nuevo funciona, vemos que si hay más de dos argumentos para realizar la comprobación hemos de hacerlo de dos en dos. También podemos constatar cómo la aproximación top-down nos ha funcionado, para crear las funciones MCD() y mcm() necesitábamos la descomposión en factores primos de factorize() y para esta el cálculo de primo(). Es como construir una estructura, primero diseñamos la piezas y las ensamblamos desde la base.

Sobre este esquema podríamos añadir capas, por ejemplo funciones para trabajar con fracciones.

Lo importante es entender el principio de subdivisión de problemas y la utilidad del empleo de pseudo-código y diagramas de flujo para el diseño de programas de cierta complejidad. De hecho normalmente emplearemos una versión mixta que consiste en pseudo-código con líneas para orientarnos en los bucles y bifurcaciones. En realidad, como Python es un lenguaje tan próximo al lenguaje natural (inglés) podemos incorporar directamente en nuestro pseudo-código expresiones Python tal cual.

3.2.4 Reutilizando el código: Paquetes y módulos

Hemos creado una mínima librería personal con un par de funciones, así como las funciones primos, factorize y el módulo Mates, o el módulo para crear interfaces CLI. Vamos a ver como podemos hacer una estructura que nos permita integrar todo este bagage de la misma manera que las bibliotecas de Python. El elemento básico para esto es el paquete. Cuando importamos una biblioteca con PiP en realidad estamos descargando un paquete en el directorio adecuado de nuestra distribución de Python. Veamos como podemos crear nuestro paquete propio con los módulos que hemos ido desarrollando.

Aclaremos un par de conceptos: Un módulo es sencillamente un fichero con extensión .py que contiene código Python. No hay diferencia entre un módulo y un script o programa normal. Normalmente un módulo contiene definiciones de clases o funciones y algún código para declarar identificadores o hacer algún trabajo de inicialización. Cuando ejecutamos el guión si lo hacemos al invocar el intérprete (como cuando lo ejecutamos directamente desde la consola o desde Windows) se considera el programa principal, y en su namespace la variable __name__ adquiere el valor "__main__". Otro modo de ejecutar un módulo es mediante la sentencia import en cualquiera de sus formas. Al ejecutarlo de este modo la variable __name__ adquiere como valor el nombre del módulo. Probemos a crear un programa como el siguiente:

#Importante ;-)

Importancia=False

def importante(x):
return None

print("__name__:",__name__)
print(dir())

Si lo ejecutamos desde IDLE con F5 el resultado será el siguiente:

============= RESTART: C:/Users/User/Documents/Python/Importante.py =============
__name__: __main__
['Importancia', '__annotations__', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'importante']

>>> import Importante
__name__: Importante
['Importancia', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'importante']

>>> import Importante as Desdeñable
>>>

Vemos que la única diferencia entre la ejecución directa y la importación es la diferencia de la variable __name__ y la ausencia en el namespace de la entrada __annotations__, que ha sido reemplazada por __cached__. También vemos que al intentar importarlo por segunda vez no obtenemos ningún resultado aparente. Esta es una característica importante de verdad, el código de los módulos solo se ejecuta la primera vez que los importamos. En posteriores sentencias import no se ejecutarán nunca las líneas 8 y 9 del script. Sin embargo, si no cierras el shell comprueba que se ha creado el namespace Desdeñable.

>>> dir()
['Desdeñable', 'Importancia', 'Importante', '__annotations__', '__builtins__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'importante']
>>> dir(Desdeñable)
['Importancia', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'importante']
>>> Desdeñable.Importancia
False
>>> Desdeñable.importante
<function importante at 0x0000022F12E62700>

Ahora probemos a cerrar el shell y volver a ejecutar IDLE desde cero con lo cual tendremos un shell limpio (No ejecutes el programa "Importante.py").

>>> from Importante import importante
__name__: Importante
['Importancia', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'importante']

>>> Importancia
Traceback (most recent call last):
File "<pyshell#1>", line 1, in <module>
Importancia
NameError: name 'Importancia' is not defined

>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'importante']
>>> from Importante import *
>>> dir()
['Importancia', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'importante']
>>>

Vemos que el namespace del módulo, lo que refleja la orden dir() en la línea 9, no es el espacio de nombres global del intérprete. La variable Importancia aparece en aquel pero no en este. En cambio con la forma from Importance import * aunque no se ejecute el código del guión si se cargan en el espacio de nombres los identificadores definidos en aquel.

Si ya vamos teniendo claro lo que es un módulo, vamos ahora a explicar lo que es un paquete. Un paquete es un conjunto de módulos, y su reflejo en el mundo del ordenador es tan sencillo como un directorio que contiene los módulos. La única condición es que se encuentre en el directorio actual o dentro de la ruta de búsqueda que como ya vimos en el epígrafe 3.2.1 podemos conocer examinando la variable path de la biblioteca sys. En versiones anteriores de Python era preciso la existencia de un fichero "__init__.py" dentro del directorio para que el intérprete lo reconociese como un paquete, pero actualmente no es necesario, aunque si el fichero existe se ejecutará su contenido al importar el paquete, lo que nos permite realizar inicializaciones o lo que consideremos oportuno.

En nuestro directorio de Python crea una estructura de carpetas como la que sigue:

mylib
  ├─ mylib.py
  ├─ Mates
  │    ├ Factorize.py
  │    ├ Fracción.py
  │    ├ Mates.py
  │    └ primos_v2.py
  └─ CLI
└─ CLI.py

Asegúrate de mover los ficheros, no copiarlos, porque de otro modo se falsearían los resultados. Por supuesto ahora cualquier programa que importase los módulos mylib, Mates o CLI dejará de funcionar, pero pronto veremos cómo arreglarlo.

>>> import mylib
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'mylib']
>>> dir(mylib)
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__']

La sentencia import reconoce el paquete y crea un namespace de nombre mylib dentro del espacio global de nombres, pero dicho namespace está vacío, solo contiene las entradas básicas pero no las definiciones de nuestras funciones. Probemos otra forma de importación.

>>> from mylib import mylib
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'mylib']
>>> dir(mylib)
['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__main__', '__name__', '__package__', '__spec__', 'execfile', 'inIDLE', 'msvcrt', 'sys', 'waitch']

Ahora si tenemos lo que hemos definido en el fichero mylib.py, pero aunque nuestro disco vaya a estar más ordenado nos estamos complicando la vida si necesitamos importar el módulo de esta manera. La solución está en el fichero __init__.py. Creemos el siguiente programa y guardémoslo en el directorio mylib con el nombre indicado.

'''
mylib:

Nuestra biblioteca particular de Python
'''

from .mylib import *

La única línea que contiene código es la línea 7, que importa el módulo mylib de forma relativa respecto al paquete mylib (observa el punto antes del nombre). Ahora si podemos usar la misma sintaxis sencilla para importar nuestras funciones.

>>> import mylib
>>> dir(mylib)
['__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'execfile', 'inIDLE', 'msvcrt', 'mylib', 'sys', 'waitch']
>>> help(mylib)
Help on package mylib:

NAME
mylib - mylib:

DESCRIPTION
Nuestra biblioteca particular de Python

PACKAGE CONTENTS
mylib

FILE
c:\users\miguel\documents\desarrollo\python\mylib\__init__.py


>>>

Necesitaremos aportar más información en la docstring acerca de los distintos submódulos de la librería. Para acceder a la ayuda original del fichero mylib.py ahora tenemos que invocarla así:

>>> help(mylib.mylib)
Help on module mylib.mylib in mylib:

NAME
mylib.mylib - -----mylib.py-----

DESCRIPTION
Una biblioteca personal con funciones diversas

FUNCTIONS
execfile(fname)
Ejecuta un programa fname.py desde un fichero en disco

inIDLE()
Nos permite saber si estamos ejecutando
un programa en el entorno de IDLE.
Devuelve True si es así y False en caso contrario.

waitch()
Detiene la ejecución hasta que pulsemos cualquier tecla
Solo funciona desde la consola, en IDLE no tiene efecto.
No produce eco en pantalla. Está diseñada para poder ver
la salida de los programas de consola desde Windows.

FILE
c:\users\miguel\documents\desarrollo\python\mylib\mylib.py


>>>

En todo caso, se produce una curiosa circunstancia, se importa dos veces mylib, una en el namespace global y otra dentro del propio namespace de mytlib. La forma de evitarlo sería añadir una línea extra a __init__.py de la siguiente forma:

'''
Nuestra biblioteca particular de Python
'''

from .mylib import *
del mylib

Así eliminamos la referencia redundante. Hemos simplificado la docstring porque es innecesario añadir el nombre del paquete. En cuanto a los sub-paquetes, los sbudirectorios que a su vez contienen módulos, deberíamos crear en cada uno un fichero __init__.py que se encargase de importar el contenido del paquete. Copia el fichero en cada directorio y reemplaza mylib por el nombre del módulo adecuado. Como el paquete Mates incluye varios módulos hay que referirnos a todos ellos.

'''Funciones matemáticas variadas'''

from .Mates import *
from .Factorize import factorize
from .Fracción import fracción
from .primos_v2 import primo

del Mates
del Factorize
del Fracción
del primos_v2

Importamos el contenido necesario de cada módulo. En algunos módulos importamos la función o clase implementada en el módulo expresamente, así evitamos que carge otras importaciones que se realizan dentro del módulo, como por ejemplo la función primo() en el módulo Factorize. Ahora podemos importar módulos e incluso funciones individuales perfectamente. Los subpaquetes deben ser referenciados expresamente si queremos utilizarlos.

>>> from mylib import *
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'execfile', 'inIDLE', 'msvcrt', 'sys', 'waitch']
>>> from mylib import Mates
>>> dir()
['Mates', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'execfile', 'inIDLE', 'msvcrt', 'sys', 'waitch']
>>> dir(Mates)
['MCD', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__path__', '__spec__', 'factorize', 'fracción', 'mcm', 'primo']
>>> from mylib.Mates import MCD
>>> dir()
['MCD', 'Mates', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'execfile', 'inIDLE', 'msvcrt', 'sys', 'waitch']
>>> import mylib.CLI as CLI
Este programa no funciona en IDLE
>>>

Ya tenemos nuestra biblioteca de funciones organizada. Ahora necesitaremos modificar alguna importación en algunos de los programas que hemos empleado hasta la fecha, concretamente los que usen cualquier módulo de los subpaquetes CLI o Mates.

Hay un módulo de la biblioteca estándar expresamente dedicado a la importación de módulos y paquetes: importlib.

Funciones de importlib
import_module("name",pack=None) Importa el módulo "name"
Si indicamos una referencia relativa debemos añadir el paquete
invalidate_caches() Borra los cachés del sistema de importación.
Debemos utilizarlo para importar un fichero creado después del inicio de la sesión
reload(módulo) Vuelve a importar módulo
Funciones de importlib.resources
contents(pack) Devuelve un iterable con los elementos contenidos en pack
(Ficheros y directorios)
path(pack,resource) Devuelve un objeto con información sobre la trayectoria
del recurso pertenciente a pack
read_text(pack,resource) Devuelve el contenido en forma de texto del fichero resource
dentro del paquete pack

Vamos a ver las posibilidades de esta nueva biblioteca con un programa para curiosear en nuestros paquetes de Python.

'''Packotilla. Un programa para curiosear en nuestras librerías'''

from importlib.resources import contents, read_text, path

#Descomponemos la trayectoria en partes y volvemos a montarla
def basedir(path):
partes=path.parts
base=partes[0]
if len(partes)>2:
for i in partes[1:-1]:
base+="\\"+i
return base

paquete=input("Paquete: ")

módulos=list(contents(paquete))

for i in módulos:
if i.endswith(".py"):
base=basedir(list(path(paquete,i).gen)[0])
print(base)
break

for i in módulos:
if i!="__pycache__":
print("Módulo:" if i.endswith(".py") else "",i)

La función basedir() acepta un objeto de tipo Path como argumento, lo descompone en partes y vuelve a concatenar cada parte eliminando la última, que corresponde con el nombre de fichero, de este modo obtenemos el directorio donde está ese fichero.

El programa en sí comienza solicitando el nombre de un paquete, luego extrae el contenido mediante la función importlib.resources.contents().

El bucle de las líneas 18-23 busca la primera entrada que corresponda a un fichero con extensión ".py" y cuando lo encuentra utiliza la función importlib.resources.path() para obtener su trayectoria. El objeto devuelto es del tipo <class 'contextlib._GeneratorContextManager'>. Tras una árdua introspección he descubierto que tiene un atributo .gen que es un iterador que devuelve un objeto Path, convertimos el generador en una lista y extraemos el único elemento de esta y ya tenemos el objeto deseado, que pasamos como argumento a nuestra función basedir(). Imprimimos el resultado, que debe ser el directorio donde se encuentra el paquete, e interrumpimos el bucle.

El siguiente bucle imprime los elementos que nos ha devuelto contents() menos el directorio __pycache__, indicando aquellos que corresponden con ficheros de código Python. Lo ejecutamos para comprobar su funcionamiento.

============= RESTART: C:/Users/User/Documents/Python/Packotilla.py =============
Paquete: mylib
C:\\Users\Miguel\Documents\Desarrollo\Python\mylib
CLI
Mates
Módulo: mylib.py
Módulo: __init__.py

>>>

Viendo la composición del paquete podemos acceder al código.

>>> print(read_text("mylib","__init__.py"))
'''
Nuestra biblioteca particular de Python
'''

from .mylib import *
del mylib


>>>

Podemos ver el contenido de cualquier fichero.

============= RESTART: C:/Users/User/Documents/Python/Packotilla.py =============
Paquete: colorama
C:\\Users\Miguel\AppData\Local\Programs\Python\Python38\lib\site-packages\colorama
Módulo: ansi.py
Módulo: ansitowin32.py
Módulo: initialise.py
Módulo: win32.py
Módulo: winterm.py
Módulo: __init__.py

>>> print(read_text("colorama","__init__.py"))
# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file.
from .initialise import init, deinit, reinit, colorama_text
from .ansi import Fore, Back, Style, Cursor
from .ansitowin32 import AnsiToWin32

__version__ = '0.4.3'


>>> ============= RESTART: C:/Users/User/Documents/Python/Packotilla.py =============
Paquete: numpy
C:\\Users\Miguel\AppData\Local\Programs\Python\Python38\lib\site-packages\numpy
.libs
compat
Módulo: conftest.py
core
Módulo: ctypeslib.py
distutils
doc
Módulo: dual.py
f2py
fft
lib
LICENSE.txt
linalg
ma
Módulo: matlib.py
matrixlib
polynomial
random
Módulo: setup.py
testing
tests
Módulo: version.py
Módulo: _distributor_init.py
Módulo: _globals.py
Módulo: _pytesttester.py
Módulo: __config__.py
__init__.cython-30.pxd
__init__.pxd
Módulo: __init__.py

>>> print(read_text("numpy","LICENSE.txt"))

Esta última instrucción provoca la salida de 939 líneas de texto

Para comprobar subpaquetes hemos de usar la sintaxis "paquete.subpaquete" y así nivel por nivel.

Terminamos este vistazo a módulos y paquetes viendo el empleo práctico de las funciones .importmodule() y .reload()

>>> from importlib import reload
>>> import Builtfuncs
['abs', 'all', 'any', 'ascii', 'bin', 'breakpoint', 'callable', 'chr', 'compile', 'delattr', 'dir', 'divmod', 'eval', 'exec', 'format', 'getattr', 'globals', 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'open', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars']
>>> import Builtfuncs
>>> dir()
['Builtfuncs', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'import_module', 'reload']
>>>

Vemos que una vez que un módulo ha sido importado y ejecutado hasta el final se ha creado una entrada en el espacio de nombres con el nombre del módulo. Subsiguientes intentos de importarlo no tienen efecto. Incluso aunque borremos la entrada del namespace con del Builtfuncs seguirá sin funcionar, dado que los mecanismos de importación de Python tienen sus propios datos. Aquí viene en nuestro apoyo la función reload().

>>> reload(Builtfuncs)
['abs', 'all', 'any', 'ascii', 'bin', 'breakpoint', 'callable', 'chr', 'compile', 'delattr', 'dir', 'divmod', 'eval', 'exec', 'format', 'getattr', 'globals', 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'open', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars']
<module 'Builtfuncs' from 'C:\\Users\\Miguel\\Documents\\Desarrollo\\Python\\Builtfuncs.py'>

>>>

Esta vez el módulo es importado y vuelve a ejecutarse, y además la función reload() nos devuelve el módulo en forma de objeto de Pyhon. Vamos a ver ahora import_module(). Inicia un nuevo shell.

>>> from importlib import import_module
>>> módulo="Builtfuncs"
>>> import módulo
Traceback (most recent call last):
File "<pyshell#2>>", line 1, in <module>
import módulo
ModuleNotFoundError: No module named 'módulo'

>>>

La sentencia import no puede utilizarse con cadenas de caracteres. ¿Cómo importar una biblioteca cuyo nombre ha sido adquirido durante la ejecución de nuestro programa, y está en forma de cadena de texto?. Podemos probar con eval() o mejor aún, con exec().

>>> eval("import "+módulo)
Traceback (most recent call last):
File "<pyshell#3>>", line 1, in <module>
eval("import "+módulo)
File "<string>", line 1
import Builtfuncs
^
SyntaxError: invalid syntax

>>>

eval() no puede gestionar este tipo de sentencias, en cambio exec(), para mi propia sorpresa, ha podido. Primero vamos a probar el método mediante import_module() y luego mediante exec con un shell nuevo.

>>> import_module(módulo)
['abs', 'all', 'any', 'ascii', 'bin', 'breakpoint', 'callable', 'chr', 'compile', 'delattr', 'dir', 'divmod', 'eval', 'exec', 'format', 'getattr', 'globals', 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'open', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars']
<module 'Builtfuncs' from 'C:\\Users\\Miguel\\Documents\\Desarrollo\\Python\\Builtfuncs.py'>

>>>

Vemos que además de ejecutar el módulo nos devuelve un objeto módulo. Debemos asignar el objeto a una variable para que no se pierda. Lo mejor es realizar la asignación al llamar a import_module, y emplear el nombre del módulo o el alias que decidamos. Ahora sobre un shell nuevo probamos lo siguiente.

>>> módulo="Builtfuncs"
>>> exec("import "+módulo)
['abs', 'all', 'any', 'ascii', 'bin', 'breakpoint', 'callable', 'chr', 'compile', 'delattr', 'dir', 'divmod', 'eval', 'exec', 'format', 'getattr', 'globals', 'hasattr', 'hash', 'hex', 'id', 'input', 'isinstance', 'issubclass', 'iter', 'len', 'locals', 'max', 'min', 'next', 'oct', 'open', 'ord', 'pow', 'print', 'repr', 'round', 'setattr', 'sorted', 'sum', 'vars']
>>>

Además se crea automáticamente la entrada en el namespace. Pues esas son las posibilidades de importar bibliotecas sobre la marcha desde un programa

3.2.5 Interfaces gráficos: tkinter

LLegamos al fin al momento de crear nuestro primer programa en una ventana de Windows. La biblioteca estándar proporciona el módulo tkinter, que es un envoltorio para manejar desde Pyhton los protocolos Tcl/Tk. Estos constituyen un modo de comunicarse con el sistema gráfico para crear objetos como ventanas y un montón de objetos que colocaremos dentro de estas denominados widgets.

Un interfaz gráfico como Windows funciona de una manera muy diferente a los interfaces de consola que hemos empleado hasta ahora. La primera característica es que el interfaz permite la existencia simultánea de muchas aplicaciones en sus correspondientes ventanas, por eso no podemos limitarnos a funcionar de un modo secuencial. Para manejar esto el entorno de ventanas está dirigido por eventos. Cada evento es algo que sucede, provocado por el usuario, por otros programas o por el simple transcurrir del tiempo. Para gestionar los eventos el sistema operativo y los objetos del interfaz se comunican mediante mensajes.

Como tkinter está fuertemente orientado a objetos, todo lo que creemos son objetos y para definir su comportamiento lo que hemos de hacer es crear el código de respuesta a los eventos que queramos gestionar. Pero esto es abstracto y de momento no tendrá mucho sentido para tí. Vamos a aprender practicando, como siempre.

# Tkinter_Hola - El programa "Hola, mundo" en una ventana

import tkinter as tk

#Creamos la ventana principal
root=tk.Tk()
root.title("¡Hola, Mundo!")
root.geometry("600x400")

root.mainloop()

Analicemos el programa. Importamos la librería de la manera en que verás hacerlo siempre. También somos conservadores al denominar root a la ventana principal. En la línea 6 creamos la ventana, que es un objeto de clase tkinter.Tk

Empleamos un par de métodos de la ventana para configurar su título y su tamaño (fíjate en la forma de expresar este mediante una cadena), y en la línea 10 lanzamos el bucle de ventana, a partir de ahí nuestro programa pierde el control hasta que regresemos (cerrando la ventana, por ejemplo). Eso solo significa que antes de empezar el bucle hemos de definir todas las funciones que gestionarán el comportamiento de nuestra ventana y de los widgets que empleemos.

Nuestra primera ventana

Y ya puestos vamos a emplear algún widget. Para ir por orden empezaremos por un contenedor que nos permite organizar los elementos dentro de la ventana, y también añadiremos una etiqueta y un botón.

# Tkinter_Hola2 - Añadiendo widgets

import tkinter as tk

#Creamos la ventana principal
root=tk.Tk()
root.title("¡Hola, Mundo!")
root.geometry("600x400")

panel=tk.Frame(root)
etiqueta=tk.Label(panel,text="¡Hola, Mundo!")
botón=tk.Button(root,text="Terminar",command=root.destroy)

panel.pack(side="top")
etiqueta.pack(pady=150)
botón.pack(side="bottom")

root.mainloop()

Definimos la ventana principal del mismo modo que antes. A continuación creamos nuestros objetos y los guardamos en variables para poder acceder a ellos posteriormente. Los objetos de interfaz de tkinter son fáciles de entender. Todos comienzan por mayúscula y definen claramente el tipo de objeto. Los objetos de interfaz siempre pertenecen a otro objeto, que es el primer argumento del constructor.

Un objeto Frame es un contenedor, representa un espacio rectangular dentro del cual podemos colocar otros objetos, y nos sirve para distribuir los elementos dentro de la ventana.

Una etiqueta (Label) es un texto estático que podemos usar como su nombre indica. Mediante el argumento text indicamos el texto que será mostrado.

Un botón (Button) es nuestro conocido objeto de interfaz. Hemos indicado el texto que se muestra en él y la orden que será ejecutada al pulsar. En este caso usamos el método .destroy() de la ventana principal que hace lo que aparenta: destruye la ventana cerrando la aplicación.

Una vez creados los objetos hemos de dar un paso extra, que es colocarlos en la ventana y hacerlos visibles. Para eso tkinter emplea un gestor de geometría (Geometry Manager). Hay tres sistema disponibles pero de momento emplearemos pack, que es el más sencillo. Lo usamos mediante el método .pack() de los diferentes objetos, y hemos empleado un par de argumentos: side nos indica en qué lado del espacio disponible "empaquetaremos" el objeto. pady especifica el relleno vertical. Nuestro programa se muestra así:

Nuestra segunda ventana

Podemos salir cerrando la ventana o pulsado el botón. Prueba a eliminar o modificar los parámetros en las llamadas a pack() de las líneas 14-16 y comprueba el efecto.

Continuemos viendo las posibilidades de los elementos que ya tenemos entre manos.

# Tkinter_pack1 - Organizando el interfaz

import tkinter as tk

ANCHO=600
ALTO=400

#Creamos la ventana principal
root=tk.Tk()
root.title("Pack")
root.geometry(f"{ANCHO}x{ALTO}")

panel=tk.Frame(root,relief="sunken",borderwidth=3)
etiqueta=tk.Label(panel,text="Organizando los elementos",foreground="red")
menu=tk.Frame(root,height=30)
botón=tk.Button(menu,text="Terminar",width=12,command=root.destroy)

panel.pack(side="top",expand=True,fill="both")
etiqueta.pack(pady=150)
menu.pack()
botón.pack()

root.mainloop()

Esta vez empleamos dos Frames para crear un espacio principal y una barra de botones inferior. Hemos definido constantes para el tamaño de nuestra ventana, y modificado la llamada de la línea 11 para aceptarlas.

En la definición de nuestros widgets hemos ampliado los argumentos. Los paneles pueden tener relieve, hemos indicado que el aspecto de este es hundido y un ancho del borde para apreciar el efecto. Hemos definido un color del primer plano para la etiqueta, y una altura para la barra de botones. Para el botón hemos definido un ancho, pero hemos de tener en cuenta que las unidades que manejan los widgets de texto no son pixels sino que tienen que ver con el ancho de las fuentes empleadas.

Por último al empaquetar el panel principal hemos definido los argumentos expand y fill para que el panel ocupe todo el espacio disponible. He aquí el resultado.

Organizando el interfaz

Antes de continuar vamos a ver una pequeña guía de los widgets más usados de los que podemos disponer en tkinter.

Widgets
Cuadro tk.Frame Área rectangular para contener y organizar otros elementos
Etiqueta tk.Label Textos de referencia del interfaz
Botón tk.Button Objeto rectangular que responde a la pulsación
Casilla de
verificación
tk.Checkbutton Casillas con dos estados, marcado y desmarcado
Botón de
opción
tk.Radiobutton Grupo de casillas que permiten seleccionar una de ellas
Campo de entrada tk.Entry Línea de entrada de texto
Cuadro combinado ttk.Combobox Combinación de un campo de entrada y una lista desplegable
Lista de opciones tk.Listbox Lista con una línea de texto por opción
Barra de
desplazamiento
tk.Scrollbar Objeto que nos permite desplazar una página más grande que el campo de visión
Campo de texto tk.Text Recuadro de entrada de texto multilínea
Menú tk.Menu Opciones de menú, en una barra o desplegables
Lienzo tk.Canvas Superficie sobre la que podemos dibujar

tkinter incluye un submódulo con versiones adaptables de todas las clases, tkinter.ttk, que normalmente importaremos usando el alias ttk. Todas las clases de la lista están en ambas versiones excepto combobox que solo está en la versión ttk. Todas ellas tienen un amplio conjunto de opciones de configuración, algunas comunes y otras particulares para cada una. Podemos seleccionar estas opciones añadiendo el parámetro con nombre que corresponda a la opción y un argumento acorde.

Existen tres formas de configurar cada opción:

En efecto, los objetos de tkinter responden a la indexación devolviendo sus opciones de configuración, funcionando como diccionarios en este sentido. De este modo tambien podemos invocar el método .keys() para obtener todas las opciones. Otro sistema es emplear el método .config() sin argumentos que nos devolverá un diccionario con todas las opciones incluyendo para cada una el valor por defecto y el valor actual, entre otros datos, o acceder a la propiedad mediante la forma de índice: print(panel["relief"]).

Veamos cuales son
las opciones comunes
de los widgets.

Frame Label Button Checkbutton Radiobutton Entry Listbox Scrollbar Text Canvas
relief
borderwidth
bd
width
height
padx
pady
background
bg
foreground
fg
cursor

No hemos incluído los menús, que tienen un funcionamiento muy diferente, ni la clase Combobox dado que ttk incluye diferentes opciones.

La opción relief (relieve) requiere un valor de texto que corresponda a las posibles opciones:

Valores de relief
"raised" Elevado El widget se muestra en un plano más alto
"sunken" Hundido El widget se muestra en un plano más bajo
"flat" Plano El widget mantiene continuidad con el contenedor
"ridge" Cresta El widget está rodeado por un borde sobresaliente
"solid" Sólido EL widget está rodeado por un borde plano
"groove" Ranura El widget está rodeado por un borde hundido

La opción borderwidth que puede abreviarse por bd corresponde con un valor numérico que indica el ancho del borde en pixels. Para ver el relieve de la opción relief hemos de dar un ancho mayor de 1 al borde.

Las opciones width y height definen el tamaño del widget. En los widgets no textuales se definen en pixels, en los que contienen texto se definen por el tamaño de este.

Las opciones padx y pady se refieren al relleno, el espacio entre el borde y el contenido del widget, en pixels. Gestionan independientemente el relleno horizontal y el vertical.

 background y foreground (o sus abreviaturas bg y fg) indican el color del fondo y el del primer plano, respectivamente. Podemos expresar los colores mediante cadenas de texto. Para ver los numerosos colores definidos de esta forma mira la referencia de colores de Tcl/Tk. También podemos especificar los componentes RGB del color mediante cadenas de la forma: "#RRGGBB" donde debemos sustituir las letras por valores hexadecimales.

La opción cursor indica la forma que adopta el cursor del ratón al colocarse encima del widget. Se indica mediante una cadena de texto, como casi todos los valores de Tcl/Tk y tiene muchas posibilidades que puedes comprobar en la lista de cursores de Tcl/Tk.

Vamos a ver con cierto detalle algunas de las posibilidades de los widgets que hemos presentado.

Cuadros o Frames

Como ya hemos comentado, se trata de contenedores rectangulares, normalmente se usan para organizar en su interior otros widgets, de forma que consigamos la distribución adecuada. Además podemos usar el borde y el color de fondo (no tienen color de primer plano) para decorar. El gestor de geometría pack se limita a apilar objetos en sentido vertical u horizontal. Si queremos distribuir widgets en forma de casillas, tanto a lo alto como a lo ancho, podemos usar el gestor grid, o podemos emplear cuadros. Por ejemplo, vamos a generar una estructura de seis cuadros distribuidos en tres columnas por dos filas. Primero crearemos dos cuadros invisibles colocados uno sobre otro para formar las dos filas, y luego dentro de estas colocaremos lado a lado nuestros frames visibles.

# Frames1 - Ejemplo de cuadros

import tkinter as tk

ANCHO=600
ALTO=400

#Creamos la ventana principal
root=tk.Tk()
root.title("Frames 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.minsize(ANCHO,ALTO)

#Creamos dos filas
row1=tk.Frame()
row1.pack(fill="both")
row2=tk.Frame()
row2.pack(fill="both")

frames=[]
relieve=("raised","flat","sunken","ridge","solid","groove")
#Creamos seis cuadros con distinto relieve
for i,s in enumerate(relieve):
frames.append(tk.Frame(row1 if i<3 else row2, relief=s, bd=5,pady=80))
frames[i].pack(side="left",fill="both")
tk.Label(frames[i],text=s).pack()

root.mainloop()

Creamos la ventana root y usamos el método .minsize() para establecer el tamaño mínimo. De este modo podemos hacer la ventana más grande, pero no más pequeña. Observa que aquí hemos de indicar los valores numéricos y no una cadena como en .geometry().

Creamos las dos filas mediante sendos cuadros. Observa que si no indicamos el contenedor o ventana "padre" serán asignados directamente a la ventana principal. Luego usamos el método .pack() para mostrarlos en nuestra ventana. El parámetro fill indica que deben rellenar todo el espacio del contenedor. Puede adoptar los valores: "x", "y" o "both" para rellenar a lo ancho, a lo alto o en ambos sentidos. El espacio disponible se reparte equitativamente entre ambos widgets. Si no especificamos tamaños ni este argumento, los widgets adoptan el tamaño justo para que quepa su contenido.

Por último, como tenemos que crear seis cuadros de un modo repetitivo, como buenos programadores dejamos que sea el programa el que haga el trabajo mediante un bucle. Nos limitamos a indicar los valores para el argumento relief mediante una lista, y luego creamos cada cuadro colocándolo en la fila adecuada según su orden y aprovechando para poner una etiqueta. Puedes ver la notación compacta que hemos empleado en la línea 27 para ello. No necesitamos guardar la referencia de la etiqueta, así que según creamos el objeto le aplicamos el método .pack() para mostrarlo.

Este será el aspecto del programa en ejecución:

Ejemplo de Frames y la opción relief

Observa lo que ocurre si ampliamos la ventana. Nuestras filas se ensanchan en sentido horizontal, pero queda un margen vacío por debajo. Para solucionar esto solo tendríamos que indicar la opción expand en las líneas 16 y 18. Cuando se espera un valor lógico (booleano) para una opción Tcl/Tk nos permite usar un valor 0 o "no" para desactivar la opción y un valor 1 o "yes" para activarla. Como en Python False y True equivalen a 0 y 1 podemos también emplearlos.

Etiquetas o Labels

Las etiquetas, al igual que los cuadros, son elementos inactivos del interfaz, una vez presentados no interactúan con el usuario y se limitan a mostrar un texto a guisa de información. Hemos visto esta función claramente en el último ejemplo.

Sin embargo, esto no significa que tengan que ser estáticos. Pueden reaccionar a nuestras acciones y vamos a comprobarlo en el siguiente programa.

# Labels1 - Ejemplo de etiquetas

import tkinter as tk

ANCHO=600
ALTO=400

def elegido(s):
status["text"]=s
status["fg"]=colores[s]
status["bg"]="gray" if s in ("Blanco","Amarillo","Verde claro") else "SystemButtonFace"

#Creamos la ventana principal
root=tk.Tk()
root.title("Etiquetas 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.resizable(1,0)

#Creamos un panel principal
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
statusbar=tk.Frame(height=40)
statusbar.pack(side="bottom")

status=tk.Label(statusbar,text="Elige un color")
status.pack(fill="both")

botones=[]
colores={"Blanco":"white","Negro":"black","Azul":"blue","Rojo":"red","Verde":"dark green",
"Amarillo":"yellow","Marrón":"brown","Violeta":"BlueViolet","Verde claro":"#88FF88"}
#Creamos seis botones
for s in colores.keys():
botones.append(tk.Button(mainframe,bg=colores[s],text=s,height=2,\
command=lambda x=s:elegido(x),\
fg="black" if s in ("Blanco","Amarillo","Verde claro")\
else "white"))
botones[-1].pack(fill="x")

root.mainloop()

Según vamos avanzando los programas crecen. El programa principal empieza a partir de la línea 13, y sigue las líneas habituales. Creamos la ventana principal, pero esta vez usamos el método resizable() para limitar esa posibilidad al eje x.

Entre las líneas 19 y 26 creamos una estructura de panel principal con barra de estado inferior y en esta última añadimos una etiqueta, status con información.

Empleando un bucle, como en el programa anterior, creamos esta vez una serie de botones con nombres de colores. Usamos el diccionario colores para convertir los nombres en español en los nombres que definen el color en Tcl/Tk y de este modo hacemos que el fondo del botón sea del color indicado. Por otra parte, en la línea 35 asignamos un color de primer plano negro para los fondos claros y blanco para el resto.

La clave del programa está en la asignación de la opción command de cada botón en la línea 34. La opción debe recibir un objeto función, pero no podemos incluir argumentos. Podríamos haber creado una función diferente para cada botón, pero no es el mejor sistema. Aquí viene a ayudarnos la sentencia lambda que nos permite crear una función al vuelo, o, como en este caso, definir unos argumentos para la llamada. Empleamos como argumento el nombre de color que exhibe cada botón.

La función elegido que definimos en la línea 8 cambia tres opciones de la etiqueta status, el texto y los colores de primer plano y fondo (adaptando este último a la luminosidad del anterior). Observa el nombre del color por defecto: "SystemButtonFace", este emplea los colores de la configuración que hayas establecido en Windows. Este es el aspecto del programa:

Etiquetas con reacción a nuestras acciones

Podemos modificar el ancho de la ventana, pero no el alto. Cuando pulsemos uno de los botones, se mostrará el nombre del color con dicho color en la barra de estado. Observa que la etiqueta tiene el ancho justo para contener el nombre aunque hemos especificado la opción fill="both" al usar el método .pack(). En realidad es la barra de estado la que es estrecha. Añade fill="x" en los argumentos de la línea 23 y tendremos una barra del ancho de la ventana.

Si queremos ejecutar nuestros programas tkinter directamente desde Windows, basta con renombrar la extensión como ".pyw" y así no se abrirá la consola del intérprete Pyton detrás de nuestra ventana. Podremos seguir editando los programas desde IDLE normalmente.

# Labels2 - Ejemplo de etiquetas

import tkinter as tk
from tkinter import font

ANCHO=600
ALTO=400

def validate(event):
sel=lista.get(tk.ACTIVE)
mainlabel["text"]=sel
mainlabel["font"]=(sel,32)

#Creamos la ventana principal
root=tk.Tk()
root.title("Etiquetas 2")
root.geometry(f"{ANCHO}x{ALTO}")
root.resizable(1,0)

estatus=tk.StringVar()
estatus.set("Fuentes disponibles")

#Creamos un panel principal
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
statusbar=tk.Frame(root)
statusbar.pack(side="bottom",fill="both")
status=tk.Label(statusbar,textvariable=estatus,height=3)
status.pack(fill="both")

#Creamos una lista de fuentes
fuentes=font.families()
lista=tk.Listbox(mainframe,height=10,width=40)
lista.pack(side="left",fill="y")
for f in fuentes:
lista.insert("end",f)
lista.bind("<Double-Button-1>",validate)

#Creamos la etiqueta principal
mainlabel=tk.Label(mainframe,text="Elige")
mainlabel.pack(expand=1,fill="both")

root.mainloop()

Este programa tiene unas cuantas novedades. Para empezar importamos el módulo de fuentes de texto de tkinter, dado que una característica fundamental de los objetos de texto es la fuente empleada. Como en el programa anterior definimos la ventana de la aplicación y hacemos que solo podamos redimensionarla en sentido horizontal.

En las líneas 20-21 empleamos un nuevo objeto de tkinter, las variables. Por desgracia una vez lanzado el código de un programa no es factible acceder desde los objetos a las variables normales de Python. Afortunadamente tkinter tiene una clase de objetos de variables que a su vez se divide en varias clases derivadas. Una de ellas es .StringVar() que podemos suponer que contiene cadenas de texto con muchas posibilidades de acertar. Ahora bien, no podemos acceder de forma normal al contenido de dichos objetos, sino a través de los métodos .set() (para cambiar el valor) y .get() para recuperarlo. Los widgets que contienen datos pueden asociarse con una variable, de forma que si cambias el valor de uno repercute en el otro, en ambos sentidos.

De las líneas 23 a la 28 definimos nuestros paneles principal y de estado y una etiqueta para este que usa la opción texvariable para asociarse con la variable anteriormente definida.

En la línea 32 empleamos la función font.families() que nos proporciona un listado de las diferentes fuentes instaladas en nuestro ordenador. Creamos una Listbox y usamos el método .insert() de esta para rellenarla con el listado de fuentes, y la línea clave esta vez es la 37, en la que definimos una función de respuesta a un evento (callback). El método .bind() asocia un evento con una función, en este caso el evento es una doble pulsación del botón izquierdo del ratón y la función, anteriormente definida, es validate. Cuando definimos este tipo de gestión de eventos la función recibirá automáticamente un parámetro que es un objeto que contiene los datos del evento producido. En este caso no nos interesa gestionarlo de ningún modo, pero hay que contemplarlo en la declaración de la función.

La función validate() lo que hace es obtener el valor seleccionado de la lista (aquel sobre el que acabamos de hacer doble click) y muestra el texto en la etiqueta del espacio principal con la fuente correspondiente en un tamaño de 32 puntos. Una de las formas en que Tcl/Tk nos permite referirnos a una fuente es una tupla con el identificador de la fuente, el tamaño y opcionalmente atributos como que sea negrita o itálica. Ya veremos con más detalle esto en alguna sección posterior.

El programa se muestra de esta guisa:

Etiquetas con reacción a nuestras acciones

Hemos dicho que las etiquetas son objetos estáticos del interfaz, pero eso es solo media verdad. En realidad podemos asociar métodos de respuesta a eventos a cualquier objeto del interfaz, incluídas las etiquetas e incluso los cuadros. Veamos una segunda versión del programa para ver las fuentes instaladas, esta vez con un interfaz más gráfico en el que los nombres de las fuentes se muestran directamente con la fuente correspondiente.

# Labels3 - Ejemplo de etiquetas
# Versión orientada a objetos

import tkinter as tk
from tkinter import font

ANCHO=800
ALTO=450
STATUSH=30
LABELH=60
LABELCOUNT=7

#Derivamos nuestra aplicación de la clase principal
class myapp(tk.Tk):

def __init__(self):
super().__init__()
self.config()
self.bind("<MouseWheel>",self.wheel)
self.bind("<Key>",self.key)
self.mainloop()

#Gestionar la entrada y salida del cursor
def activa(e,self,x,flag):
if flag:
self.labels[x]["fg"]="red"
post=f" ({self.first+x+1}/{len(self.fuentes)})"
self.statusline["text"]=self.labels[x]["text"]+post
self.selected=x
else:
self.labels[x]["fg"]="black"
self.statusline["text"]="Fuentes disponibles"
self.selected=None

#Configuración inicial, creación del interfaz
def config(self):
self.geometry(f"{ANCHO}x{ALTO}")
self.title("Labels 3")
self.resizable(0,0)

#Marco principal
self.masterframe=tk.Frame(self,relief="groove",bd=4,cursor="hand2")
self.masterframe.pack(expand=True,fill="both")

#Barra de estado
self.statusframe=tk.Frame(self,height=STATUSH)
self.statusframe.pack(side="bottom",fill="x")

self.statusline=tk.Label(self.statusframe,text="Fuentes disponibles")
self.statusline.pack()

#Etiquetas que ocupan el marco principal
self.labels=[]
self.masterframe.update()
frameh=self.masterframe.winfo_height()
framew=self.masterframe.winfo_width()
for i in range(LABELCOUNT):
self.labels.append(tk.Label(self.masterframe))
self.labels[-1].place(x=0, y=LABELH*i, width=framew-8, height=LABELH)
#Gestionamos la entrada y salida del cursor
self.labels[-1].bind("<Enter>",lambda e,x=i:self.activa(self,x,True))
self.labels[-1].bind("<Leave>",lambda e,x=i:self.activa(self,x,False))

self.selected=None

#Obtenemos la lista de fuentes del sistema y refrescamos la ventana
self.fuentes=font.families()
self.first=0
self.changed=True
self.update()

#Movimiento de la rueda del ratón
def wheel(self, event):
if event.delta>0:
self.pasapag(True)
else:
self.pasapag(False)

#Gestión del paso de página
def pasapag(self,flag):
if flag:
if self.first>LABELCOUNT-1:
self.first-=LABELCOUNT-1
else:
self.first=0
self.changed=True
else:
if self.first<len(self.fuentes)-2*LABELCOUNT-1:
self.first+=LABELCOUNT-1
else:
self.first=len(self.fuentes)-LABELCOUNT
self.changed=True

self.update()

#Gestión del teclado
def key(self,event):
if event.keysym=="Home":
self.first=0
self.changed=True
elif event.keysym=="End":
self.first=len(self.fuentes)-LABELCOUNT
self.changed=True
elif event.keysym=="Up" and self.first>0:
self.first-=1
self.changed=True
elif event.keysym=="Down" and self.first<len(self.fuentes)-LABELCOUNT-1:
self.first+=1
self.changed=True
elif event.keysym=="Next":
self.pasapag(False)
elif event.keysym=="Prior":
self.pasapag(True)
self.update()

#Refrescar la ventana
def update(self):
if self.changed:
for i in range(len(self.labels)):
self.labels[i]["text"]=self.fuentes[self.first+i]
self.labels[i]["font"]=(self.fuentes[self.first+i],32)
self.changed=False

if self.selected!=None:
post=f" ({self.first+self.selected+1}/{len(self.fuentes)})"
self.statusline["text"]=self.labels[self.selected]["text"]+post

super().update()

#Aqui lanzamos nuestra aplicación al mundo
aplicación=myapp()

Como el programa va ganando extensión vamos a examinarlo por partes. Lo primero a resaltar es que hemos empleado la orientación a objetos, nuestra aplicación entera es una clase derivada de la clase principal de tkinter: t.Tk. El punto de entrada está al final del listado, una vez que hemos definido la clase, creamos un objeto aplicación en la línea 131 y cobra vida por si solo. En realidad ni siquiera necesitamos asignar una referencia al objeto, podríamos dejar la línea como:

myapp()

y funcionaría exactamente igual.

Vamos a echar un vistazo a la estructura de la clase myapp:

myapp
  ├─ __init__() - Incialización al crear la clase
  ├─ activa()   - Gestionar la entrada y salida del cursor en las etiquetas
  ├─ config()   - Configuración inicial, empleada por __init__()
  ├─ wheel()- Gestión de la rueda del ratón
  ├─ pasapag()  - Paso de página (con la rueda del ratón o las teclas AvPag y RePag
  ├─ key()- Gestión del teclado
  └─ update()   - Actualización de las etiquetas y de la barra de estado

Asimismo tenemos la siguiente colección de variables:

Siempre que creamos un objeto de una clase se invoca el constructor y este llama al médodo __init__(), de forma que de algún modo este es el punto de entrada.

def __init__(self):
super().__init__()
self.config()
self.bind("<MouseWheel>",self.wheel)
self.bind("<Key>",self.key)
self.mainloop()

Como nuestra clase deriva de tk.Tk hereda todos sus métodos, pero al sobreescribir uno de ellos dejamos sin efecto el original. Para que tkinter realize las tareas necesarias invocamos en primer lugar el método de inicialización original. Podríamos hacerlo usando tk.Tk.__init__(), pero es más versátil usar la función super() que nos devuelve una referencia a la clase superior. Siempre que modifiquemos métodos ya existentes conviene hacer esto para garantizar que todo funciona del modo correcto.

Una vez que la parte de nuestro objeto heredada de tkinter ha sido inicializada llamamos a nuestra función de configuración, luego añadimos la gestión de dos clases de eventos; el movimiento de la rueda del ratón y la pulsación de una tecla (líneas 19-20). Para finalizar, llamamos al bucle principal y a partir de ahí tkinter toma el control.

#Configuración inicial, creación del interfaz
def config(self):
self.geometry(f"{ANCHO}x{ALTO}")
self.title("Labels 3")
self.resizable(0,0)

#Marco principal
self.masterframe=tk.Frame(self,relief="groove",bd=4,cursor="hand2")
self.masterframe.pack(expand=True,fill="both")

#Barra de estado
self.statusframe=tk.Frame(self,height=STATUSH)
self.statusframe.pack(side="bottom",fill="x")

self.statusline=tk.Label(self.statusframe,text="Fuentes disponibles")
self.statusline.pack()

#Etiquetas que ocupan el marco principal
self.labels=[]
self.masterframe.update()
frameh=self.masterframe.winfo_height()
framew=self.masterframe.winfo_width()
for i in range(LABELCOUNT):
self.labels.append(tk.Label(self.masterframe))
self.labels[-1].place(x=0, y=LABELH*i, width=framew-8, height=LABELH)
#Gestionamos la entrada y salida del cursor
self.labels[-1].bind("<Enter>",lambda e,x=i:self.activa(self,x,True))
self.labels[-1].bind("<Leave>",lambda e,x=i:self.activa(self,x,False))

self.selected=None

#Obtenemos la lista de fuentes del sistema y refrescamos la ventana
self.fuentes=font.families()
self.first=0
self.changed=True
self.update()

La función config realiza toda la creación de objetos que forman el interfaz. Fijamos el tamaño de la ventana, ponemos un título y eliminamos la opción de redimensionarla.

Creamos la distribución habitual de marco principal y barra de estado en la zona inferior, y en esta última ponemos una etiqueta. A continuación (líneas 52-62) creamos las etiquetas que van a llenar el marco principal. En las líneas 55-56 obtenemos las dimensiones en pantalla del marco principal (a las que luego hemos de restar el borde). Como necesitamos una colocación precisa empleamos el método .place() en la línea 59 para colocar cada etiqueta en su lugar definiendo la posición y el tamaño en pixels (si empleásemos el parámetro height al crear la etiqueta funcionaría en líneas de texto). Para que las etiquetas reaccionen a la entrada y salida del cursor sobre ellas asignamos una función de respuesta a dichos eventos (líneas 61-62). Empleamos lambda para poder definir parámetros que identifiquen la etiqueta y usar una única función para todas.

Un inciso. Usamos un parámetro con valor por defecto porque la asignación de este se realiza en el momento de definir la función. Si hubiésemos utilizado i directamente como argumento de la función se evaluaría en tiempo de ejecución y, como esta se produce una vez terminado el bucle, i tendría siempre el valor LABELCOUNT.

A continuación creamos la variable selected con valor None (inicialmente el cursor no está sobre ninguna etiqueta). Para terminar la inicialización obtenemos la lista de fuentes, inicializamos first con el valor 0 y activamos la variable changed para que al llamar a update se actualize el texto de las etiquetas.

A partir de aquí todo lo que ocurra lo hará en las funciones de respuesta a eventos.

#Gestionar la entrada y salida del cursor
def activa(e,self,x,flag):
if flag:
self.labels[x]["fg"]="red"
post=f" ({self.first+x+1}/{len(self.fuentes)})"
self.statusline["text"]=self.labels[x]["text"]+post
self.selected=x
else:
self.labels[x]["fg"]="black"
self.statusline["text"]="Fuentes disponibles"
self.selected=None

Aquí gestionamos la entrada (cuando flag es True) y la salida del cursor en el área de cada etiqueta. El argumento x corresponderá con el índice de la etiqueta dentro de la lista labels. El argumento e siempre es recibido en la gestión de eventos, contiene un objeto con el evento producido. En este caso hemos de recibir el argumento pero lo ignoramos.

Cuando entramos en una etiqueta cambiamos el color de primer plano a rojo y ponemos en la barra de estado el nombre de la fuente y su número y el total de fuentes. Tambien marcamos la posición de la etiqueta "activa" en la variable selected. salir devolvemos el color negro al fondo, recuperamos el valor inicial de la barra de estado y marcamos selected como None.

La variable selected nos sirve para que si dejamos el cursor quieto y usamos las teclas para movernos por las fuentes el método update() sepa lo que tiene que mostrar en la barra de estado.

#Movimiento de la rueda del ratón
def wheel(self, event):
if event.delta>0:
self.pasapag(True)
else:
self.pasapag(False)

#Gestión del paso de página
def pasapag(self,flag):
if flag:
if self.first>LABELCOUNT-1:
self.first-=LABELCOUNT-1
else:
self.first=0
self.changed=True
else:
if self.first<len(self.fuentes)-2*LABELCOUNT-1:
self.first+=LABELCOUNT-1
else:
self.first=len(self.fuentes)-LABELCOUNT
self.changed=True

self.update()

La función wheel() gestiona el paso de "paginas" mediante el movimiento de la rueda del ratón. Lo único que hace es comprobar la dirección de dicho movimiento y delegar en la función pasapag().

pasapag() gestiona el salto de página. El argumento flag indica si hemos de pasar hacia arriba o hacia abajo. Lo que haremos será desplazar las líneas de forma que la última línea se convierta en la primera o al revés. Simplemente calculamos para asegurarnos de que al hacer el desplazamiento no excedamos los márgenes de la lista fuentes y luego cambiamos el valor de first que es el que gestiona el punto de partida de nuestra visualización y llamamos a update() para que refresque las etiquetas.

#Gestión del teclado
def key(self,event):
if event.keysym=="Home":
self.first=0
self.changed=True
elif event.keysym=="End":
self.first=len(self.fuentes)-LABELCOUNT
self.changed=True
elif event.keysym=="Up" and self.first>0:
self.first-=1
self.changed=True
elif event.keysym=="Down" and self.first<len(self.fuentes)-LABELCOUNT-1:
self.first+=1
self.changed=True
elif event.keysym=="Next":
self.pasapag(False)
elif event.keysym=="Prior":
self.pasapag(True)
self.update()

Aquí reaccionamos a las pulsaciones de teclas. En este caso empleamos el atributo .keysym() del evento para saber qué tecla ha sido pulsada, y gestionamos las teclas "Home", "End", "AvPag", "RePag", "🡱" y "🡳". En cada caso comprobamos que nos queda margen de movimiento y realizamos este actualizando first y llamando a update. En el caso del movimiento de páginas aprovechamos la función pasapag()

#Refrescar la ventana
def update(self):
if self.changed:
for i in range(len(self.labels)):
self.labels[i]["text"]=self.fuentes[self.first+i]
self.labels[i]["font"]=(self.fuentes[self.first+i],32)
self.changed=False

if self.selected!=None:
post=f" ({self.first+self.selected+1}/{len(self.fuentes)})"
self.statusline["text"]=self.labels[self.selected]["text"]+post

super().update()

update() se superpone al método original que debe actualizar la presentación en pantalla de la ventana. La variable changed indica que hemos modificado la posición de la lista de fuentes y por tanto modificamos los textos de las etiquetas para reflejar el cambio. A continuación, si el cursor está sobre alguna de las etiquetas actualizamos la barra de estado y finalmente llamamos al método original para que este cumpla su función.

El programa en funcionamiento tiene el siguiente aspecto:

Avanzando en el empleo de etiquetas

Continuaremos empleando etiquetas como parte del interfaz en el resto de nuestros programas.

Botones (Buttons)

Los botones constituyen uno de los principales medios de control de los programas GUI. Aunque cualquier elemento del interfaz puede reaccionar a los eventos de ratón (como acabamos de ver) los botones muestran claramente al usuario sus posibilidades, y además de forma natural reaccionan visualmente a eventos como la selección o la pulsación. Por otra parte, la opción command nos permite asignar una función a un botón sin tener que realizar ninguna gestión de eventos.

Además de las opciones comunes de configuración emplearemos frecuentemente las siguientes opciones propias de la clase tk.Button:

Todos los widgets que tienen un texto sencillo incluyen la opción text para indicar este. Tambien podemos emplear una variable de tkinter y usar textvariable. Este último sistema es más sencillo si queremos modificar el valor del texto a lo largo del programa. Ten en cuenta que en estos widgets las referencias de tamaño se expresan en unidades textuales y no en pixels.

Hay dos características fundamentales en un interfaz, el aspecto (look) y el comportamiento (behavior). Frecuentemente veremos la expresión look and feel para expresar esto. command es la opción propia de los botones que nos permite definir el comportamiento de estos.

La opción state nos permite fundamentalmente activar y desactivar los botones. En estado desactivado el color de primer plano es gris y no responden a las pulsaciones del ratón. Tampoco pueden ser seleccionados mediante las teclas <TAB> o <MAYUSC>+<TAB>.

La opción underline es un número entero que corresponde con un índice de la cadena de texto mostrada en el botón. El caracter indicado aparecerá subrayado. Esto se utiliza para indicar teclas rápidas que activan la función del botón, pero la opción solo aporta el subrayado, nosotros hemos de gestionar los eventos del teclado.

Aquí tenemos un ejemplo con unos cuantos botones.

#Buttons 1 - Botones de tkinter

import tkinter as tk
from tkinter import font

ANCHO=600
ALTO=400

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
return font

def changefont(f):
font=getfont()
mainlabel["font"]=(f,font[1],font[2])
buttonTimes["state"]="disabled" if f=="Times" else "normal"
buttonArial["state"]="disabled" if f=="Arial" else "normal"
buttonCourier["state"]="disabled" if f=="Courier" else "normal"

def changestyle(s):
font=getfont()
mainlabel["font"]=(font[0],font[1],s)
buttonNormal["state"]="disabled" if s=="normal" else "normal"
buttonBold["state"]="disabled" if s=="bold" else "normal"
buttonItalic["state"]="disabled" if s=="italic" else "normal"

def changesize(flag):
font=getfont()
size=int(font[1])
if flag==None:
mainlabel["font"]=(font[0],"48",font[2])
return
if flag:
size+=1
buttonMinus["state"]="normal"
elif size>10:
size-=1
else:
buttonMinus["state"]="disabled"
mainlabel["font"]=(font[0],str(size),font[2])

def changecolor(c):
mainlabel["fg"]=c
buttonBlack["state"]="disabled" if c=="black" else "normal"
buttonRed["state"]="disabled" if c=="red" else "normal"
buttonBlue["state"]="disabled" if c=="blue" else "normal"
buttonGreen["state"]="disabled" if c=="green" else "normal"


#Creamos la ventana principal
root=tk.Tk()
root.title("Botones 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.minsize(523,150)

#Creamos un panel principal y una barra de botones
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
mainlabel=tk.Label(mainframe,text="EJEMPLO",font=("Times",48),relief="ridge",bd=3)
mainlabel.pack(expand=True,fill="x")
buttonsbar=tk.Frame(root)
buttonsbar.pack(side="bottom",fill="both")

#Creamos los botones
buttonTimes=tk.Button(buttonsbar,text="Times",font=("Times",10),\
state="disabled",command=lambda f="Times":changefont(f))
buttonTimes.pack(side="left")
buttonArial=tk.Button(buttonsbar,text="Arial",font=("Arial",10),\
command=lambda f="Arial":changefont(f))
buttonArial.pack(side="left")
buttonCourier=tk.Button(buttonsbar,text="Courier",font=("Courier",10),\
command=lambda f="Courier":changefont(f))
buttonCourier.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonNormal=tk.Button(buttonsbar,text="Normal",font=("tk.TkDefaultFont",10),\
state="disabled",command=lambda s="normal":changestyle(s))
buttonNormal.pack(side="left")
buttonBold=tk.Button(buttonsbar,text="Negrita",font=("tk.TkDefaultFont",10,"bold"),\
command=lambda s="bold":changestyle(s))
buttonBold.pack(side="left")
buttonItalic=tk.Button(buttonsbar,text="Cursiva",font=("tk.TkDefaultFont",10,"italic"),\
command=lambda s="italic":changestyle(s))
buttonItalic.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonPlus=tk.Button(buttonsbar,text="\u21E7",command=lambda f=True:changesize(f))
buttonPlus.pack(side="left")
buttonMinus=tk.Button(buttonsbar,text="\u21E9",command=lambda f=False:changesize(f))
buttonMinus.pack(side="left")
buttonDefault=tk.Button(buttonsbar,text="\u2713",command=lambda f=None:changesize(f))
buttonDefault.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonBlack=tk.Button(buttonsbar,text="  ",bg="black",state="disabled",\
command=lambda c="black":changecolor(c))
buttonBlack.pack(side="left")
buttonRed=tk.Button(buttonsbar,text="  ",bg="red",\
command=lambda c="red":changecolor(c))
buttonRed.pack(side="left")
buttonBlue=tk.Button(buttonsbar,text="  ",bg="blue",\
command=lambda c="blue":changecolor(c))
buttonBlue.pack(side="left")
buttonGreen=tk.Button(buttonsbar,text="  ",bg="green",\
command=lambda c="green":changecolor(c))
buttonGreen.pack(side="left")


buttonexit=tk.Button(buttonsbar,text="Salir",command=root.destroy)
buttonexit.pack(side="right")

#Lanzamos el bucle principal
root.mainloop()

Nuestro programa arranca después de definir una serie de funciones en la línea 51. Creamos una ventana, le damos un título y un tamaño y fijamos el tamaño mínimo que puede alcanzar. Creamos un panel principal con una etiqueta centrada en él y una barra de botones en la parte inferior.

LLenamos esta última con varios grupos de botones, los tres primeros para seleccionar una fuente (observa que podemos modificar la fuente empleada en el propio botón). Estos emplearán la función changefont(). Los tres siguientes nos permiten definir el estilo de texto y usan changestyle(). A continuación otros tres con indicaciones gráficas que nos permiten ampliar el tamaño, reducirlo y volver al valor inicial, para ello llaman a changesize(). Cuatro nuevos botones nos permiten cambiar el color. No tienen ningún texto sino que muestran un fondo del color correspondiente y usan la función changecolor() para cumplir su misión. Al final el botón Salir nos permite cerrar el programa y se coloca siempre a la derecha de la barra.

Entre los grupos de botones hemos creado separaciones colocando etiquetas vacías con un valor 3 para la opción padx. Además, los botones correspondientes a las opciones ya efectivas están desactivados.

Todo el trabajo se realiza en las funciones que son ejecutadas al pulsar los botones.

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
return font

La función getfont() se usa para recuperar los datos de la fuente de la etiqueta mainlabel. Obtenemos el valor de la opción "font" que es devuelto en forma de una cadena del estilo: "Times 48 normal" y lo separamos en palabras con .split(). Es posible que no se haya definido ningún estilo y este valor esté vacío, lo comprobamos y añadimos "normal" si es necesario y luego devolvemos la lista con las tres cadenas correspondientes a fuente, tamaño y estilo.

def changefont(f):
font=getfont()
mainlabel["font"]=(f,font[1],font[2])
buttonTimes["state"]="disabled" if f=="Times" else "normal"
buttonArial["state"]="disabled" if f=="Arial" else "normal"
buttonCourier["state"]="disabled" if f=="Courier" else "normal"

La función changefont() cambia la fuente empleada. Hemos seleccionado tres alternativas muy habituales en el entorno Windows para asegurarnos de que estén disponibles; "Times", "Arial" y "Courier" . Podríamos haber obtenido la lista de fuentes y comprobado la disponibilidad, pero no necesitamos complicar el programa. Empleamos getfont() para obtener la fuente activa. Luego asignamos un nuevo valor en el que modificamos solamente el nombre de fuente, manteniendo tamaño y estilo.

Repasamos el estado de los botones de fuente para desactivar el que esté actualmente activo y activar los demás.

Las funciones changestyle() y changecolor() son un calco de la anterior. La primera obtiene los datos de la fuente, cambia el estilo y actualiza el estado de los botones. changecolor no necesita obtener ningún dato, asigna el color seleccionado y actualiza sus botones.

def changesize(flag):
font=getfont()
size=int(font[1])
if flag==None:
mainlabel["font"]=(font[0],"48",font[2])
return
if flag:
size+=1
buttonMinus["state"]="normal"
elif size>10:
size-=1
else:
buttonMinus["state"]="disabled"
mainlabel["font"]=(font[0],str(size),font[2])

changesize() tiene sus propias particularidades. El parámetro flag puede recibir los valores True, False o None. Este último valor restaura el tamaño original y termina la función con un return. Una vez comprobado este caso solo nos quedan True para aumentar el tamaño y False para disminuirlo. El primer caso incrementa el tamaño en 1 y activa el botón de reducirlo. El segundo caso comprueba si hemos llegado al valor mínimo de tamaño que es 10, de no ser así reduce el tamaño, pero si alcanzamos el mínimo desactiva el botón de reducir.

Y este es el aspecto del programa:

Empleo de botones

Casillas de verificación (Checkbuttons)

Llegamos a unos objetos de interfaz con ciertas características que los diferencian de los que hemos empleado hasta ahora. Como los botones pueden tener un texto que se muestra en forma de etiqueta informativa a la derecha de la casilla y una función que es invocada al ser pulsados, sino que poseen un estado binario que podemos asignar a una variable tkinter. Es más, resulta muy conveniente asociar siempre una variable con las casillas de verificación.

Las cuatro primeras opciones se comportan del mismo modo que en los botones normales. La oción variable indica la variable de tkinter que se asocia con la casilla (recuerda que siempre hemos de usar las variables especiales de tkinter, no podemos emplear variables normales de Python). La variable puede ser booleana o numérica pero no necesariamente. Las opciones offvalue y onvalue definen los valores que adoptará en los casos en que la casilla esté sin marcar o marcada y pueden tener valores de cualquier clase. Eso si, las variables de tkinter tienen un tipado estático, no podemos asignar a una variable de un tipo un tipo de valor diferente.

Los tipos de variables que podemos definir en tkinter son: StringVar, IntVar, DoubleVar y BooleanVar, que corresponden respectivamente a string, int, float y bool Por cierto, obtendremos un error si tratamos de crear las variables antes que la ventana principal.

Aprendamos con la práctica, vamos a modificar el programa anterior ligeramente:

#Checks 1 - Casillas de verificación

import tkinter as tk
from tkinter import font

ANCHO=600
ALTO=400

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
if len(font)>3:
font[2]=font[2][1:]+" "+font[3][:-1]
del font[3]
return font

def changefont(f):
font=getfont()
mainlabel["font"]=(f,font[1],font[2])
buttonTimes["state"]="disabled" if f=="Times" else "normal"
buttonArial["state"]="disabled" if f=="Arial" else "normal"
buttonCourier["state"]="disabled" if f=="Courier" else "normal"

def changestyle():
font=getfont()
if not (bold.get() or italic.get()):
mainlabel["font"]=(font[0],font[1],"normal")
else:
mainlabel["font"]=(font[0],font[1],\
("bold" if bold.get() else "")+\
(" italic" if italic.get() else ""))

def changesize(flag):
font=getfont()
size=int(font[1])
if flag==None:
mainlabel["font"]=(font[0],"48",font[2])
return
if flag:
size+=1
buttonMinus["state"]="normal"
elif size>10:
size-=1
else:
buttonMinus["state"]="disabled"
mainlabel["font"]=(font[0],str(size),font[2])

def changecolor(c):
mainlabel["fg"]=c
buttonBlack["state"]="disabled" if c=="black" else "normal"
buttonRed["state"]="disabled" if c=="red" else "normal"
buttonBlue["state"]="disabled" if c=="blue" else "normal"
buttonGreen["state"]="disabled" if c=="green" else "normal"

#Creamos la ventana principal
root=tk.Tk()
root.title("Casillas de verificación 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.minsize(523,150)

#Nuestras variables
bold=tk.BooleanVar()
italic=tk.BooleanVar()

#Creamos un panel principal y una barra de botones
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
mainlabel=tk.Label(mainframe,text="EJEMPLO",font=("Times",48),relief="ridge",bd=3)
mainlabel.pack(expand=True,fill="x")
buttonsbar=tk.Frame(root)
buttonsbar.pack(side="bottom",fill="both")

#Creamos los botones
buttonTimes=tk.Button(buttonsbar,text="Times",font=("Times",10),\
state="disabled",command=lambda f="Times":changefont(f))
buttonTimes.pack(side="left")
buttonArial=tk.Button(buttonsbar,text="Arial",font=("Arial",10),\
command=lambda f="Arial":changefont(f))
buttonArial.pack(side="left")
buttonCourier=tk.Button(buttonsbar,text="Courier",font=("Courier",10),\
command=lambda f="Courier":changefont(f))
buttonCourier.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

#Reemplazamos los botones de estilos por casillas de verificación
buttonBold=tk.Checkbutton(buttonsbar,text="Negrita",font=("tk.TkDefaultFont",10,"bold"),\
command=changestyle,variable=bold)
buttonBold.pack(side="left")
buttonItalic=tk.Checkbutton(buttonsbar,text="Cursiva",font=("tk.TkDefaultFont",10,"italic"),\
command=changestyle,variable=italic)
buttonItalic.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonPlus=tk.Button(buttonsbar,text="\u21E7",command=lambda f=True:changesize(f))
buttonPlus.pack(side="left")
buttonMinus=tk.Button(buttonsbar,text="\u21E9",command=lambda f=False:changesize(f))
buttonMinus.pack(side="left")
buttonDefault=tk.Button(buttonsbar,text="\u2713",command=lambda f=None:changesize(f))
buttonDefault.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonBlack=tk.Button(buttonsbar,text="  ",bg="black",state="disabled",\
command=lambda c="black":changecolor(c))
buttonBlack.pack(side="left")
buttonRed=tk.Button(buttonsbar,text="  ",bg="red",\
command=lambda c="red":changecolor(c))
buttonRed.pack(side="left")
buttonBlue=tk.Button(buttonsbar,text="  ",bg="blue",\
command=lambda c="blue":changecolor(c))
buttonBlue.pack(side="left")
buttonGreen=tk.Button(buttonsbar,text="  ",bg="green",\
command=lambda c="green":changecolor(c))
buttonGreen.pack(side="left")


buttonexit=tk.Button(buttonsbar,text="Salir",command=root.destroy)
buttonexit.pack(side="right")

#Lanzamos el bucle principal
root.mainloop()

Prácticamente no necesita explicación. Empleamos dos variables booleanas de tkinter definidas en las líneas 60-61 y cambiamos los botones de estilo por casillas de verificación y las vinculamos con nuestras variables booleanas. Mantenemos la asociación con la función changestyle(). El cambio fundamental está en esta última y en getfont().

Veamos primero cómo cambia getfont, debido a la forma de gestionar las opciones del sistema Tcl/Tk.

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
if len(font)>3:
font[2]=font[2][1:]+" "+font[3][:-1]
del font[3]
return font

Si el estilo incluye más de un valor, la cadena de texto que nos devuelve la opción mainlabel["font"] tendrá el siguiente aspecto:

"Courier 48 {bold italic}"
Las llaves son las que nos complican la vida, puesto que al emplear una tupla para definir la fuente no podemos poner llaves en ningún elemento o lo tomará por un diccionario y se producirá un error. De este modo, cuando getfont() encuentra más de tres elementos lo que hace es combinar en la tercera posición los dos últimos sin las llaves y eliminar el cuarto.

def changestyle():
font=getfont()
if not (bold.get() or italic.get()):
mainlabel["font"]=(font[0],font[1],"normal")
else:
mainlabel["font"]=(font[0],font[1],\
("bold" if bold.get() else "")+\
(" italic" if italic.get() else ""))

Recuperamos la fuente y características activas mediante getfont(), luego si ninguna de las casillas está activada ponemos en texto en estilo normal, y si alguna lo está empleamos la expresión de la línea 27 para incluir el o los estilos activos, dado que podemos usar ambos a la vez. El nuevo look del programa es así:

Casillas de verificación

Botones de opción (Radiobuttons)

Los botones de opción tienen un comportamiento muy parecido al de las casillas de verificación, con la salvedad de que debe haber un mínimo de dos botones asociados a la misma variable y que cada uno define un único valor para esta. Las opciones son casi las mismas:

Todas ellas funcionan de la misma forma que para Checkbutton, vamos a emplearlas en nuestro último programa:

#Radio 1 - Botones de opción

import tkinter as tk
from tkinter import font

ANCHO=600
ALTO=400

fonts=("Times","Arial","Courier")

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
if len(font)>3:
font[2]=font[2][1:]+" "+font[3][:-1]
del font[3]
return font

def changefont():
font=getfont()
mainlabel["font"]=(selfont.get(),font[1],font[2])

def changestyle():
font=getfont()
if not (bold.get() or italic.get()):
mainlabel["font"]=(font[0],font[1],"normal")
else:
mainlabel["font"]=(font[0],font[1],\
("bold" if bold.get() else "")+\
(" italic" if italic.get() else ""))

def changesize(flag):
font=getfont()
size=int(font[1])
if flag==None:
mainlabel["font"]=(font[0],"48",font[2])
return
if flag:
size+=1
buttonMinus["state"]="normal"
elif size>10:
size-=1
else:
buttonMinus["state"]="disabled"
mainlabel["font"]=(font[0],str(size),font[2])

def changecolor(c):
mainlabel["fg"]=c
buttonBlack["state"]="disabled" if c=="black" else "normal"
buttonRed["state"]="disabled" if c=="red" else "normal"
buttonBlue["state"]="disabled" if c=="blue" else "normal"
buttonGreen["state"]="disabled" if c=="green" else "normal"

#Creamos la ventana principal
root=tk.Tk()
root.title("Botones de opción 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.minsize(523,150)

#Nuestras variables
bold=tk.BooleanVar()
italic=tk.BooleanVar()
selfont=tk.StringVar()
selfont.set("Times")

#Creamos un panel principal y una barra de botones
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
mainlabel=tk.Label(mainframe,text="EJEMPLO",font=("Times",48),relief="ridge",bd=3)
mainlabel.pack(expand=True,fill="x")
buttonsbar=tk.Frame(root)
buttonsbar.pack(side="bottom",fill="both")

#Creamos los botones de opción
tk.Radiobutton(buttonsbar,text="Times",font=("Times",10),variable=selfont,\
value="Times",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Arial",font=("Arial",10),variable=selfont,\
value="Arial",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Courier",font=("Courier",10),variable=selfont,\
value="Courier",command=changefont).pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

#Reemplazamos los botones de estilos por casillas de verificación
buttonBold=tk.Checkbutton(buttonsbar,text="Negrita",font=("tk.TkDefaultFont",10,"bold"),\
command=changestyle,variable=bold)
buttonBold.pack(side="left")
buttonItalic=tk.Checkbutton(buttonsbar,text="Cursiva",font=("tk.TkDefaultFont",10,"italic"),\
command=changestyle,variable=italic)
buttonItalic.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

#Creamos el resto de botones
buttonPlus=tk.Button(buttonsbar,text="\u21E7",command=lambda f=True:changesize(f))
buttonPlus.pack(side="left")
buttonMinus=tk.Button(buttonsbar,text="\u21E9",command=lambda f=False:changesize(f))
buttonMinus.pack(side="left")
buttonDefault=tk.Button(buttonsbar,text="\u2713",command=lambda f=None:changesize(f))
buttonDefault.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonBlack=tk.Button(buttonsbar,text="  ",bg="black",state="disabled",\
command=lambda c="black":changecolor(c))
buttonBlack.pack(side="left")
buttonRed=tk.Button(buttonsbar,text="  ",bg="red",\
command=lambda c="red":changecolor(c))
buttonRed.pack(side="left")
buttonBlue=tk.Button(buttonsbar,text="  ",bg="blue",\
command=lambda c="blue":changecolor(c))
buttonBlue.pack(side="left")
buttonGreen=tk.Button(buttonsbar,text="  ",bg="green",\
command=lambda c="green":changecolor(c))
buttonGreen.pack(side="left")


buttonexit=tk.Button(buttonsbar,text="Salir",command=root.destroy)
buttonexit.pack(side="right")

#Lanzamos el bucle principal
root.mainloop()

Los candidatos para la substitución eran los botones de selección de fuente o los de color, hemos optado por los primeros. En las líneas 64-65 añadimos una variable de texto de tkinter y la inicializamos.

#Creamos los botones de opción
tk.Radiobutton(buttonsbar,text="Times",font=("Times",10),variable=selfont,\
value="Times",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Arial",font=("Arial",10),variable=selfont,\
value="Arial",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Courier",font=("Courier",10),variable=selfont,\
value="Courier",command=changefont).pack(side="left")

Resulta mucho más sencillo crear los botones de opción, ni siquiera guardamos la referencia, dado que no es necesaria, según los definimos los mostramos con el método .pack(). Los tres botones se asocian con la variable selfont y cada uno aporta el valor adecuado. El último cambio está en la función changefont() que se ve también muy simplificada al eliminar el trámite de activar o desactivar los botones.

def changefont():
font=getfont()
mainlabel["font"]=(selfont.get(),font[1],font[2])

Simplemente obtenemos el nuevo valor de la fuente de la variable selfont empleando el método .get() de esta (Recuerda que las variables de tkinter no pueden emplearse del modo habitual sino mediante los métodos .set() o .get() para asignar un valor o para recuperarlo).

Aquí está la nueva versión del programa en acción:

Botones de opción

Campos de entrada (Entry)

Un campo de entrada se presenta en el interfaz como una línea de texto sobre la que podemos escribir y editar su contenido. Se trata de un elemento con muchas características nuevas.

La opción validate indica el método de validación para el texto. Los valores que puede tomar son:

 validatecommand indica la función que será llamada para realizar la validación, inicialmente no existe ninguna. El concepto de foco es una de las características del GUI: En un momento dado siempre hay un elemento que tiene el foco, sobre el cual recaerán los eventos de teclado, por ejemplo. Podemos modificarlo mediante las teclas <TAB> o <MAYUS>+<TAB> o mediante acciones del ratón o del propio programa. La función debe devolver un valor True si la entrada es válida y False en caso contrario.

Si la función de validación devuelve el valor False y se ha asignado una función a la opción invalidcommand se llamará a esta función.

Hay un estado nuevo que es readonly. En este caso no podemos modificar el texto pero si seleccionar partes (o todo ello) y copiarlo.

 textvariable asocia el campo de entrada con una variable de texto de tkinter. Cualquier cambio se reflejará en ambas direcciones. Es la forma más sencilla de emplear este widget.

La opción show puede contener un texto. Si es así se mostrará el primer caracter de este en lugar de cualquier otro. Típicamente se usa asignando un asterisco para la introducción de claves.

Con todo esto vamos a incorporar un campo de entrada de texto a nuestro programita, para poder modificar el texto que muestra la etiqueta del panel principal:

#Entrada 1 - Campo de entrada de texto

import tkinter as tk
from tkinter import font

ANCHO=600
ALTO=400

fonts=("Times","Arial","Courier")

def getfont():
font=mainlabel["font"].split()
if len(font)<3:
font.append("normal")
if len(font)>3:
font[2]=font[2][1:]+" "+font[3][:-1]
del font[3]
return font

def changefont():
font=getfont()
mainlabel["font"]=(selfont.get(),font[1],font[2])

def changestyle():
font=getfont()
if not (bold.get() or italic.get()):
mainlabel["font"]=(font[0],font[1],"normal")
else:
mainlabel["font"]=(font[0],font[1],\
("bold" if bold.get() else "")+\
(" italic" if italic.get() else ""))

def changesize(flag):
font=getfont()
size=int(font[1])
if flag==None:
mainlabel["font"]=(font[0],"48",font[2])
return
if flag:
size+=1
buttonMinus["state"]="normal"
elif size>10:
size-=1
else:
buttonMinus["state"]="disabled"
mainlabel["font"]=(font[0],str(size),font[2])

def changecolor(c):
mainlabel["fg"]=c
buttonBlack["state"]="disabled" if c=="black" else "normal"
buttonRed["state"]="disabled" if c=="red" else "normal"
buttonBlue["state"]="disabled" if c=="blue" else "normal"
buttonGreen["state"]="disabled" if c=="green" else "normal"

#Creamos la ventana principal
root=tk.Tk()
root.title("Campos de entrada 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.minsize(523,150)

#Nuestras variables
bold=tk.BooleanVar()
italic=tk.BooleanVar()
selfont=tk.StringVar()
selfont.set("Times")
texto=tk.StringVar()
texto.set("EJEMPLO")

#Creamos un panel principal y una barra de botones
mainframe=tk.Frame(relief="sunken",bd=3)
mainframe.pack(expand=1,fill="both")
mainlabel=tk.Label(mainframe,textvariable=texto,font=("Times",48),relief="ridge",bd=3)
mainlabel.pack(expand=True,fill="x")
buttonsbar=tk.Frame(root)
buttonsbar.pack(side="bottom",fill="both")

#Creamos el campo de entrada:
entrada=tk.Entry(mainframe,textvariable=texto)
entrada.pack(side="bottom",pady=10)

#Creamos los botones de opción
tk.Radiobutton(buttonsbar,text="Times",font=("Times",10),variable=selfont,\
value="Times",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Arial",font=("Arial",10),variable=selfont,\
value="Arial",command=changefont).pack(side="left")
tk.Radiobutton(buttonsbar,text="Courier",font=("Courier",10),variable=selfont,\
value="Courier",command=changefont).pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

#Reemplazamos los botones de estilos por casillas de verificación
buttonBold=tk.Checkbutton(buttonsbar,text="Negrita",font=("tk.TkDefaultFont",10,"bold"),\
command=changestyle,variable=bold)
buttonBold.pack(side="left")
buttonItalic=tk.Checkbutton(buttonsbar,text="Cursiva",font=("tk.TkDefaultFont",10,"italic"),\
command=changestyle,variable=italic)
buttonItalic.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

#Creamos el resto de botones
buttonPlus=tk.Button(buttonsbar,text="\u21E7",command=lambda f=True:changesize(f))
buttonPlus.pack(side="left")
buttonMinus=tk.Button(buttonsbar,text="\u21E9",command=lambda f=False:changesize(f))
buttonMinus.pack(side="left")
buttonDefault=tk.Button(buttonsbar,text="\u2713",command=lambda f=None:changesize(f))
buttonDefault.pack(side="left")

tk.Label(buttonsbar).pack(side="left",padx=3)

buttonBlack=tk.Button(buttonsbar,text="  ",bg="black",state="disabled",\
command=lambda c="black":changecolor(c))
buttonBlack.pack(side="left")
buttonRed=tk.Button(buttonsbar,text="  ",bg="red",\
command=lambda c="red":changecolor(c))
buttonRed.pack(side="left")
buttonBlue=tk.Button(buttonsbar,text="  ",bg="blue",\
command=lambda c="blue":changecolor(c))
buttonBlue.pack(side="left")
buttonGreen=tk.Button(buttonsbar,text="  ",bg="green",\
command=lambda c="green":changecolor(c))
buttonGreen.pack(side="left")


buttonexit=tk.Button(buttonsbar,text="Salir",command=root.destroy)
buttonexit.pack(side="right")

#Lanzamos el bucle principal
root.mainloop()

Casi no requiere explicación. Añadimos una nueva variable de tkinter en la línea 66 y le asignamos el valor "EJEMPLO" en la línea 67. Luego asociamos dicha variable con la etiqueta mainlabel en la línea 72 y añadimos el campo de entrada en las líneas 78-79, asociado con la misma variable. Lo colocamos en la zona inferior del marco principal dejando un pequeño espacio de relleno para mejorar el aspecto. El programa ahora se ve de esta forma:

Campo de entrada

Veamos como utilizar las funciones de validación para establecer las reglas de lo que podemos introducir en un campo de entrada. Lo primero que hemos de entender es el papel de tkinter dentro de la estructura de capas entre Pyton y el interfaz gráfico.

En nuestro ordenador con Sistema Operativo Windows, es este el que gestiona todos los aspectos del interfaz. Los protocolos Tcl/Tk se han incorporado como uno de los posibles sistemas de comunicación entre las aplicaciones y el sistema de ventanas. De hecho Tcl es un lenguaje de script que utiliza cadenas de texto como comandos. Tkinter establece una fina capa que traduce nuestras instrucciones Python a comandos Tcl.

Esquema de capas del interfaz

Dentro de este esquema, el sistema de validación de campos de entrada de Tcl incluye el paso de argumentos de una forma especial, totalmente ajena a los conceptos de Python. No podemos pasar directamente un identificador de Python a Tcl ni a la inversa, de forma que para emplear una función de validación y poder recoger los argumentos que nos pasa Tcl hemos de traducir el identificador de nuestra función de validación. Tcl emplea unos códigos de texto para saber qué argumentos ha de pasar a la función de validación, estos se añadirán al comando después del nombre de la función cuando definamos la opción validatecommand.

Definición de argumentos para las funciónes de validación
"%d" Código de acción: "0"=borrado, "1"=inserción,
"-1":cambio de foco o cambio de textvariable
"%i" Índice de la posición de borrado o inserción, "-1" para otros supuestos
"%P" Contenido del texto si validamos la acción
"%s" Contenido anterior del texto
"%S" Texto que estamos borrando o insertando, si es el caso
"%v" Valor de la opción validate
"%V" Causa de la llamada: focusin, focusout, key o forced
El último valor indica un cambio de textvariable
"%W" Nombre (para Tcl) del widget

Necesitamos convertir nuestro identificador de función en un nombre válido para Tcl e incluirlo en la cadena de la opción, para eso usamos el método universal de Tcl (un método que todos los widgets poseen) .register() que hace exactamente eso.

>>> import tkinter as tk
>>> root.Tk()
>>> root.register(print)
'2212740852224print'
>>>

Una vez entendido el concepto es sencillo, solo necesitamos un nuevo identificador para nuestra función que sea reconocido por el intérprete de Tcl. Una vez obtenido podemos crear una cadena con dicho identificador seguido de los códigos de argumentos que deseemos (separados entre sí por espacios) o podemos usar una tupla con el nombre y los argumentos como elementos. Vamos a crear un campo de entrada que no nos permita introducir el caracter punto.

#Entry2 - Validación de campos de entrada 1

ANCHO=300
ALTO=100

#No aceptamos puntos
def valida(s):
return s!="."

import tkinter as tk

root=tk.Tk()
root.title("Validación 1")
root.geometry(f"{ANCHO}x{ALTO}")

textvar=tk.StringVar()

entrada=tk.Entry(root,validate="all",width=40,textvariable=textvar)
entrada["validatecommand"]=(entrada.register(valida),"%S")
entrada.pack(pady=20)

root.mainloop()

La función valida() rechaza un valor correspondiente a un punto. En la línea 19 vinculamos nuestra función de validación e indicamos el argumento que debe recibir. Si lo pones en acción verás que puedes teclear cualquier valor excepto un punto...

Validación de entradas 1

Ya ves que hay formas de rodear nuestras restricciones. Sencillamente si copias y pegas algo que contenga un punto y más texto la comparación de la línea 8 se ve superada. Hazlo y luego intenta borrar los puntos. No nos deja borrarlos si lo intentamos uno por uno, pero si seleccionamos texto podemos de nuevo engañar al programa.

Vamos a evitar esta posibilidad con un simple cambio, y además vamos a hacerlo solo para la inserción y no para el borrado.

#Entry2b - Validación de campos de entrada 1b

ANCHO=300
ALTO=100

#No aceptamos puntos de ningún modo
def valida(act,s):
if act=="1":
return "." not in s
return True

import tkinter as tk

root=tk.Tk()
root.title("Validación 1b")
root.geometry(f"{ANCHO}x{ALTO}")

textvar=tk.StringVar()
textvar.set("Veamos...")

entrada=tk.Entry(root,validate="all",width=40,textvariable=textvar)
entrada["validatecommand"]=(entrada.register(valida),"%d","%S")
entrada.pack(pady=20)

root.mainloop()

Añadimos un contenido con puntos a la variable para que puedas comprobar que podemos borrarlo. En la línea 22 añadimos el argumento que indica la acción a realizar y en la definición de valida() añadimos el correspondiente parámetro. Comprobamos el valor (en Tcl todo se transmite mediante cadenas de texto) y si es una inserción y hay puntos la evitamos.

Ahora no hay puertas traseras para introducir puntos, si intentamos pegar un contenido que contiene algún punto no nos deja pero tampoco nos explica qué es lo que ocurre, y eso resulta muy molesto para cualquier usuario de un programa (y con razón). Vamos a emplear la opción invalidcommand para evitar esto.

#Entry2c - Validación de campos de entrada 1c

ANCHO=300
ALTO=100

#No aceptamos puntos de ningún modo pero somos educados
def valida(act,s):
infolabel["fg"]="SystemButtonFace"
if act=="1":
return "." not in s
return True

def aviso():
infolabel["fg"]="red"

import tkinter as tk

root=tk.Tk()
root.title("Validación 1c")
root.geometry(f"{ANCHO}x{ALTO}")

textvar=tk.StringVar()

entrada=tk.Entry(root,validate="all",width=40,textvariable=textvar)
entrada["validatecommand"]=(entrada.register(valida),"%d","%S")
entrada["invalidcommand"]=(entrada.register(aviso))
entrada.pack(pady=20)

infolabel=tk.Label(root,text="¡No aceptamos puntos!",fg="SystemButtonFace")
infolabel.pack()

root.mainloop()

Hemos creado una etiqueta con el texto ¡No aceptamos puntos! y le hemos dado el color "SystemButtonFace" con lo cual es invisible. Asignamos la función aviso() a la opción invalidcommand de forma que es invocada cuando la función valida() devuelve False. La función aviso() hace visible el texto cambiando el color a rojo. Dentro de la función de validación recuperamos la invisibilidad de nuestra etiqueta hasta nuevo aviso (nunca mejor dicho). Así queda:

Validación educada de entradas

Otra opción habría sido cambiar el contenido de la opción text de la etiqueta por una cadena vacía para lograr la invisibilidad. En programación pocas veces hay un único medio para conseguir lo que queremos, es uno de los motivos que la hacen tan interesante. Vamos a echar un último vistazo a otras posibilidades de los campos de entrada.

#Entry3 - Repaso a otras opciones de los campos de entrada

import tkinter as tk

ANCHO=500
ALTO=300

#Admitimos caracteres alfabéticos y espacio
def nameval(s):
for ch in s:
if not ch.isalpha() and ch!=" ":
return False
return True

#Comprobación poco formal
def mailval(s):
#No puede haber más de una arroba
if s.count("@")>1:
return False
#Dividimos por la arroba
tmp=s.split("@")
if tmp[0]!="":
#Debemos empezar por una letra
if not tmp[0][0].isalpha():
return False
#Filtramos los caracteres válidos
for ch in tmp[0]:
if not ch.isalnum() and ch not in ".-_":
return False
if len(tmp)>1:
if tmp[1]!="":
#Debemos empezar por una letra
if not tmp[1][0].isalpha():
return False
#Filtramos los caracteres válidos
for ch in tmp[1]:
if not ch.isalnum() and ch not in ".-_":
return False
return True

def edadval(s):
return s.isdigit()

def claveval(act,pos,s):
if act=="1":
if pos=="0" and s[0].isdigit():
return False
return s.isalnum()
return True

root=tk.Tk()
root.title("Campos de entrada: repaso")
root.geometry(f"{ANCHO}x{ALTO}")
root.resizable(0,0)

namevar=tk.StringVar()
mailvar=tk.StringVar()
edadvar=tk.StringVar()
clavevar=tk.StringVar()

#Margen izquierdo
leftspace=tk.Frame(root)
leftspace.pack(side="left",fill="y")
tk.Label(leftspace,width=10).pack()

#Creamos dos columnas mediante marcos
leftframe=tk.Frame(root)
leftframe.pack(side="left",fill="y")
rightframe=tk.Frame(root)
rightframe.pack(side="left",expand=True,fill="both")

tk.Label(leftframe,text="Nombre:").pack(anchor="ne",pady=20,padx=5)
nameentry=tk.Entry(rightframe,width=40,textvariable=namevar,validate="all")
nameentry["validatecommand"]=(nameentry.register(nameval),"%S")
nameentry.pack(anchor="nw",pady=20)

tk.Label(leftframe,text="Mail:").pack(anchor="ne",padx=5)
mailentry=tk.Entry(rightframe,width=40,textvariable=mailvar,validate="all")
mailentry["validatecommand"]=(mailentry.register(mailval),"%P")
mailentry.pack(anchor="nw")

tk.Label(leftframe,text="Edad:").pack(anchor="ne",padx=5,pady=20)
edadentry=tk.Entry(rightframe,width=4,textvariable=edadvar,validate="all")
edadentry["validatecommand"]=(edadentry.register(edadval),"%S")
edadentry.pack(anchor="nw",pady=20)

#Separador vertical
tk.Label(leftframe,height=4).pack(anchor="ne",padx=5)
tk.Label(rightframe,height=4).pack(anchor="nw",padx=5)

tk.Label(leftframe,text="Clave:").pack(anchor="ne",padx=5)
claveentry=tk.Entry(rightframe,width=40,textvariable=clavevar,show="*",validate="all")
claveentry["validatecommand"]=(claveentry.register(claveval),"%d","%i","%S")
claveentry.pack(anchor="nw")

tk.Entry(rightframe,width=40,textvariable=clavevar,\
state="readonly",fg="blue").pack(anchor="nw",pady=20)


root.mainloop()

Vamos a seguir el orden del listado. Nuestra primera función, nameval() admite solo caracteres alfabéticos y espacios para el nombre.

 mailval() debe validar una entrada correspondiente a una dirección de correo electrónico. En realidad la validación formal debe hacerse una vez completado en campo, pero así vemos posibilidades de la validación de entrada. El argumento siempre es la cadena completa tal y como quedará si aceptamos la última entrada. Podemos introducir una arroba, pero una vez introducida no nos deja poner más. Dividimos la cadena en dos mitades y comprobamos que empiecen por una letra y que los caracteres sean alfanuméricos, admitiendo también puntos, subrayados y guiones. No seguimos las especificaciones reales de un email pero como práctica es suficiente.

Para validar el campo edad admitimos solamente dígitos decimales, y para el password una letra en primera posición y el resto alfanuméricos.

A partir de la línea 51 creamos el interfaz. Una ventana principal de tamaño fijo y las variables que contienen el valor de los campos de entrada. Todas ellas son cadenas, porque es lo que produce un campo de entrada de texto. Si quisiésemos usar el valor numérico de edad deberíamos convertirlo a int.

Como el gestor de geometría .pack() no permite ciertos ajustes hemos creado un marco que ocupa el lugar izquierdo de la ventana para organizar mejor el contenido. Para que un marco ocupe espacio tiene que tener algo dentro, así que colocamos una etiqueta sin texto. De otra forma, independientemente del valor width del marco al emplear .pack() se estrecharía hasta no ocupar nada, y precisamente lo que queremos es que ocupe un espacio.

A partir de la línea 66 definimos dos columnas mediante sendos marcos, para contener en una las etiquetas y en otra los campos de entrada.

En la línea 72 empezamos a crear los elementos visibles. Para cada campo colocamos una etiqueta (observa la opción anchor que empleamos en el método .pack()) y un campo de texto de forma que queden alineados.

Como queremos que el campo de la clave quede separado introducimos dos etiquetas vacías para crear la separación y luego colocamos el campo de entrada de la clave, en el cual hemos definido la opción show para que oculte los caracteres. Debajo colocamos un campo que está asociado con la misma variable, y que es de solo lectura. Así podemos observar cómo funciona la relación del contenido de un widget con la variable. Este campo mostrará el mismo contenido que la clave pero "desnmascarado".

El programa presenta la apariencia de la imagen:

Diversos ejempos de tk.Entry

Listas de opciones (Listbox)

Dejamos de lado el widget Combobox que es una mezcla del campo de entrada y la lista de opciones, y que pertenece a la variedad de widgets temáticos incluídos en el módulo tinter.ttk que gestionan de otro modo los detalles de apariencia y vamos directamente a ver una lista de opciones.

De trata de un widget que contiene múltiples líneas de texto y nos permite elegir entre ellas. Veamos las opciones particulares que hemos de tener en cuenta.

Si asociamos la Listbox con una variable esta puede contener una cadena con saltos de línea o una lista de cadenas. En cada línea del widget se mostrará una línea o un elemento de la lista, respectivamente.

El alto corresponde con el número de líneas que se muestran. Podemos incluir más items en la Listbox y podremos desplazar las líneas en sentido vertical para acceder a ellos.

La opción selectmode puede tener los valores:

Tambien podemos movernos por la lista con las teclas de cursores y seleccionar con espacio, y en el caso de extended manteniendo la tecla <MAYUSC> pulsada. La opción por defecto es browse.

La opción activestyle admite los valores "underline" (el elemento activo aparece subrayado, es la opción por defecto en Windows), "dotbox" (el elemento activo aparece rodeado de una caja punteada) o "none" (no se resalta el elemento activo).

La opción yscrollcommand se utiliza para asociar una barra de desplazamiento con un widget. Cuando veamos las barras de desplazamiento la emplearemos.

Métodos del widget Listbox
.activate(index) Define el elemento activo según index
.curselection() Devuelve los índices de los elementos selecionados en forma de tupla
.delete(first[,last]) Borra los elementos entre first y last (incluídos)
Si indicamos un solo valor borra el elemento
.get(first[,last]) Obtiene los elementos entre first y last (incluídos)
.index(index) Devuelve el valor numérico de index
Normalmente lo emplearemos con códigos textuales
.insert(index,elements) Inserta los elementos a partir de la posición index
.itemconfig(index,options) Configura las opciones de un elemento
Podemos configurar los colores de fondo y primer plano
.selection_set(first[,last]) Selecciona los elementos entre first y last (incluídos)
.selection_get() Devuelve los elementos seleccionados en forma de cadena
con un elemento en cada línea
.selection_includes(index) Devuelve True si el elemento está incluído en la selección
.selection_clear(first[,last]) Elimina de la selección los elementos indicados
.size() Devuelve el número de elementos
.yview([index]) Devuelve los valores de las posiciones visibles arriba y abajo de la lista
Si especificamos un índice ajusta el elemento visible superior
.yview_moveto(fracción) Ajusta el elemento visible superior
.yview_scroll(valor,units) Desplaza la lista en vertical según valor
units puede ser "units" o "pages"

Excepto .curselection() que devuelve una tupla con todos los índices de los elementos seleccionados, que pueden ser discontinuos, los métodos que afectan a múltiples elementos lo hacen sobre un rango contiguo. Además, al contrario de lo habitual en Python se incluyen tanto el elemento de índice inferior como el superior. En un momento dado hay un único elemento activo, pero puede haber múltiples seleccionados.

Cuando utilizemos índices podemos emplear las palabras "active" (o tk.ACTIVE) y "end" (o tk.END), que se refieren respectivamente al elemento activo y al último elemento.

El método .yview() sin argumentos devuelve una tupla con los valores que representan los elementos visibles en la parte superior e inferior de la lista. Los valores se calculan entre 0 y 1 como una fracción del total de elementos, de forma que el primer elemento es 0 y el último es 1. El elemento central sería .5. .iview_moveto() emplea este mismo sistema de referencia, en cambio .yview(index) emplea el índice entero entre 0 y .size().

Si empleamos valores de índices fuera de rango se ajustarán a los valores exixtentes más próximos.

#Listbox 1 - Ejemplos de listas de selección

import tkinter as tk

ANCHO=500
ALTO=300

root=tk.Tk()
root.title("Listas de opciones 1")
root.geometry(f"{ANCHO}x{ALTO}")

continentes=("Europa","Asia","África","América","Oceanía")

varlista=tk.StringVar(value=continentes)
varlista2=tk.StringVar(value="Uno\nDos\nTres")

lista=tk.Listbox(root,height=5,listvariable=varlista)
lista.pack(side="left")
lista.itemconfig(0,bg="green",fg="yellow",
selectbackground="yellow",
selectforeground="green")

lista2=tk.Listbox(root,listvariable=varlista2,selectmode="single",\
activestyle="none"
lista2.pack(side="left")

lista3=tk.Listbox(root,selectmode="multiple",activestyle="dotbox")
for i in range(40):
lista3.insert("end",str(i))
lista3.pack(side="left")

lista4=tk.Listbox(root,selectmode="extended")
for i in range(ord("A"),ord("Z")+1):
lista4.insert("end",chr(i))
lista4.pack(side="left")

root.mainloop()

Empezamos el programa como habitualmente, creando la ventana principal. Creamos una lista con los nombres de los cinco continentes y luego dos valores StringVar a los que asignamos un valor en el momento de la creación con la opción value. Observa que la primera variable ha sido inicializada directamente con una lista y la segunda con una cadena con saltos de línea.

A continuación creamos las cuatro listas de prueba probando distintas opciones de configuración en cada una.

En la primera hemos definido una altura y hemos vinculado la variable con la lista de continentes. Además hemos modificado los colores de la primera opción. Además de los colores en estado normal hemos modificado los colores en estado de selección. El modo de selección es por defecto, es decir, "browse".

La segunda lista tiene dimensiones por defecto y hemos vinculado la segunda variable. El modo de selección es "single" y el estilo del elemento activo "none".

La tercera ha sido rellenada con valores numéricos (convenientemente convertidos en cadenas) mediante un bucle y el método .insert() y la cuarta con los caracteres A-Z, cada una con sus propias opciones para que puedas verlas en acción. He aquí el programa en funcionamiento:

Ejempos de Listbox

Observarás que al seleccionar elementos de una lista se pierden las selecciones del resto. Si queremos evitar esto y preservar cada selección debemos emplear la opción exportselection y darle el valor False (por defecto Listbox exporta la seleccion). Es una modificación sencilla que puedes comprobar por tu cuenta. En la imagen de la derecha puedes ver el resultado.

#Listbox 2 - Más métodos de Listbox

import tkinter as tk

ANCHO=500
ALTO=300

def añade(event):
if varentry.get()!="":
lista1.insert("end",varentry.get())
lista2.insert(0,"AÑADE: "+varentry.get())
lista2.itemconfig(0,fg="blue")
varentry.set("")

def borra(event):
if lista1.size()>0:
lista2.insert(0,"BORRA: "+lista1.get(lista1.curselection()))
lista2.itemconfig(0,fg="red")
lista1.delete(lista1.curselection())

root=tk.Tk()
root.title("Listas de opciones 2")
root.geometry(f"{ANCHO}x{ALTO}")

varentry=tk.StringVar()
varlista2=tk.StringVar()

#Franja superior
frameup=tk.Frame()
frameup.pack(side="top")

entrada=tk.Entry(frameup,textvariable=varentry)
entrada.bind("<Return>",añade)
entrada.pack(side="left",pady=10)

#Campo "fantasma" para alinear correctamente el otro
tk.Entry(frameup,bg="SystemButtonFace",relief="flat",
state="readonly",takefocus=False).pack(side="left")

#Franja central
framemid=tk.Frame()
framemid.pack(side="top",expand=True,fill="y")

lista1=tk.Listbox(framemid,exportselection=False)
lista1.bind("<Key-Delete>",borra)
lista1.bind("<Key-BackSpace>",borra)
lista1.pack(side="left",expand=True,fill="y")

lista2=tk.Listbox(framemid,listvariable=varlista2,exportselection=False)
lista2.pack(side="left",expand=True,fill="y")

#Franja inferior
framedown=tk.Frame(relief="raised",pady=5)
framedown.pack(side="bottom")

botón=tk.Button(framedown,text="EXIT",width=10,command=root.destroy)
botón.pack(side="bottom")

#root.mainloop()

Este nuevo ejemplo presenta una ventana con tres secciones horizontales, la primera para un campo de entrada (hemos usado un segundo campo invisible e inactivo para poder alinearlo con las listas de debajo). La segunda presenta dos listas de opciones lado a lado y la inferior el botón de salida.

Hemos creado funciones de respuesta al evento <Return> en el botón, de forma que al pulsar la tecla INTRO añada su contenido al final de la primera lista y una entrada de registro al comienzo de la segunda mediante la función añade().

En la primera lista hemos asignado también la función borra() como respuesta a los eventos <Key-Delete> que corresponde a la tecla SUPR y <Key-BackSpace> que corresponde a RETROCESO. Esta función borra de la lista el elemento seleccionado añadiendo la correspondente entrada de registro. Puedes ver que es posible asignar la misma función a diferentes eventos. Un objeto que describe el evento es siempre enviado como primer argumento, de forma que podríamos saber por qué motivo se ha invocado la llamada, y también desde qué widget.

Más posibilidades de Listbox

Vamos a emplear esta circunstancia para usar la misma función de borrado en ambas listas con una nueva versión.

#Listbox 2b - Una vuelta de tuerca más

import tkinter as tk

ANCHO=500
ALTO=300

def añade(event):
if varentry.get()!="":
lista1.insert("end",varentry.get())
lista2.insert(0,"AÑADE: "+varentry.get())
lista2.itemconfig(0,fg="blue")
varentry.set("")

def borra(event):
#Empleamos el atributo .widget del objeto event
if event.widget is lista1:
if lista1.size()>0 and lista1.curselection()!=():
lista2.insert(0,"BORRA: "+lista1.get(lista1.curselection()))
lista2.itemconfig(0,fg="red")
lista1.delete(lista1.curselection())
else:
#Como cada vez que borramos hay un elemento menos
#hemos de ajustar los índices
for n,i in enumerate(event.widget.curselection()):
event.widget.delete(i-n)

root=tk.Tk()
root.title("Listas de opciones 2b")
root.geometry(f"{ANCHO}x{ALTO}")

varentry=tk.StringVar()
varlista2=tk.StringVar()

#Franja superior
frameup=tk.Frame()
frameup.pack(side="top")

entrada=tk.Entry(frameup,textvariable=varentry)
entrada.bind("",añade)
entrada.pack(side="left",pady=10)

#Campo "fantasma" para alinear correctamente el otro
tk.Entry(frameup,bg="SystemButtonFace",relief="flat",
state="readonly",takefocus=False).pack(side="left")

#Franja central
framemid=tk.Frame()
framemid.pack(side="top",expand=True,fill="y")

lista1=tk.Listbox(framemid,exportselection=False)
lista1.bind("<Key-Delete>",borra)
lista1.bind("<Key-BackSpace>",borra)
lista1.pack(side="left",expand=True,fill="y")

#Esta vez permitimos selección múltiple en lista2
lista2=tk.Listbox(framemid,listvariable=varlista2,
exportselection=False,selectmode="multiple")
lista2.bind("<Key-Delete>",borra)
lista2.bind("<Key-BackSpace>",borra)

lista2.pack(side="left",expand=True,fill="y")

#Franja inferior
framedown=tk.Frame(relief="raised",pady=5)
framedown.pack(side="bottom")

botón=tk.Button(framedown,text="EXIT",width=10,command=root.destroy)
botón.pack(side="bottom")

#root.mainloop()

Hay pocas modificaciones y están comentadas, de forma que no se requiere mucha explicación. Añadimos la gestión de eventos de borrado a la lista2, y en la función borra() empleamos un atributo del objeto Event que corresponde al objeto de interfaz que ha realizado la llamada.

Si el objeto es la lista1 funciona como en la versión anterior, de otro modo tiene que ser la lista2. Como tenemos selección múltiple hemos de utilizar todos los valores de la tupla .curselection(), que están en orden numérico. Hemos ido ajustando los índices para reflejar este cambio. Podríamos haberlo hecho en sentido contrario, desde los índices mayores, y habría sido aún más sencillo. Prueba a realizar esa modificación.

El aspecto del programa no cambia, solo su comportamiento.

Barras de desplazamiento(Scrollbar)

Las barras de desplazamiento son un elemento de interfaz bastante sencillo. Normalmente se emplean en combinación con otros objetos para poder desplazar el contenido de estos a lo largo del área de visión. Poseen las siguientes opciones de configuración exclusivas de esta clase:

En cuanto a los métodos que por el momento nos interesan tenemos .set() y .get() que sirven para definir y obtener, respectivamente, la posición y tamaño del deslizador; el recuadro que representa la parte visible y que puede ser desplazado a lo largo de la barra. Reciben dos valores entre 0 y 1 que representan la posición de inicio y de final sobre un valor total de para el tamaño de la barra.

Si indicamos (0,1) como argumentos el deslizador debería cubrir el largo total de la barra, pero como eso significa que todo sería visible la barra se desactivará. Si indicamos (0,.5) el deslizador ocupará la mitad superior de la barra, y con (.33,.66) ocupará el tercio central.

Efecto de Scrollbar.set()

Un empleo típico de las barras de desplazamiento es junto con listas de opciones, para movernos cuando el contenido de estas es más largo de la capacidad de visualización de la lista. Ambos widgets están preparados para cooperar. Solo hemos de emplear una opción de cada elemento:

Veamos un ejemplo.

#Scrollbar 1 - Barras de desplazamiento

import tkinter as tk
import unicodedata as ud

root=tk.Tk()
root.title("Barras de desplazamiento 1")

panel=tk.Frame(root)
panel.pack(expand=True,fill="both")

lista=tk.Listbox(panel)
lista.pack(side="left",expand=True,fill="both")

for i in range(32,128):
lista.insert("end"," "+chr(i)+"  -  "+ud.name(chr(i),""))

scroll=tk.Scrollbar(panel,orient="vertical")
scroll.pack(expand=True,fill="y")
scroll["command"]=lista.yview

lista["yscrollcommand"]=scroll.set

buttonbar=tk.Frame(root,relief="raised")
buttonbar.pack(fill="x")

tk.Button(buttonbar,text="Salir",command=root.destroy).pack()

root.mainloop()

La única peculiaridad es que recuperamos el módulo unicodedata para rellenar la lista con los caracteres ASCII imprimibles y sus nombre. Conectamos de la manera indicada arriba la lista con la barra y todo funciona. Como la lista no tiene opción padx hemos añadido unos espacios al principio de cada elemento para que quede visualmente mejor. Así es el efecto:

Lista con barra de desplazamiento

En realidad, podemos emplear las barras de desplazamiento con cualquier widget o para otros propósitos siempre que asignemos la función de respuesta a la opción "command" de la barra y ajustemos la respuesta de esta mediante su método .set(). Mediante un poco de introspección comprobamos que la función asignada a "command" recibe dos tipos de respuesta en función de si pulsamos sobre las flechas o el espacio de la barra o si movemos el deslizador. Podemos recibir tres argumentos en el primer caso y dos en el segundo:

No es extraño que se ajusten a los métodos .yview_scroll() e .yview.moveto() de las listas, que en realidad son accesos de tkinter al comando yview de Tk. El caso es que nuestra función debe aceptar dos o tres argumentos (lo mejor es usar una lista variable con *args) y comprobar el primero para saber lo que debe hacerse. Después debe usar el método .set() de la barra de desplazamiento para que esta refleje los cambios producidos.

Vamos a ver un ejemplo en el que movemos un marco con imágenes mediante una barra de desplazamiento.

#Scrollbar 1 - Barras de desplazamiento

import tkinter as tk

ANCHO=600
ALTO=300

X=-200
Y=40

def scrollmetod(*args):
global X
if args[0]=="scroll":
if args[2]=="units":
X=X-10*int(args[1])
else:
X=X-40*int(args[1])
else:
X=-float(args[1])*1000
if X>0:
X=0
else:
X=max(X,-400)
panel.place(x=X,y=Y,width=1000,height=200)
start=-X/1000
scroll.set(start,start+0.60)


root=tk.Tk()
root.title("Barras de desplazamiento 2")
root.geometry(f"{ANCHO}x{ALTO}")
root.resizable(0,0)

panel=tk.Frame(root,bd=3,relief="groove")
panel.place(x=X,y=Y,width=1000,height=200)

label1=tk.Label(panel)
im1=tk.PhotoImage(file="Batman.png")
label1["image"]=im1
label1.place(x=10,y=40)

label2=tk.Label(panel)
im2=tk.PhotoImage(file="Capitán América.png")
label2["image"]=im2
label2.place(x=240,y=35)

label3=tk.Label(panel)
im3=tk.PhotoImage(file="Flash.png")
label3["image"]=im3
label3.place(x=370,y=10)

label4=tk.Label(panel)
im4=tk.PhotoImage(file="Superman.png")
label4["image"]=im4
label4.place(x=550,y=50)

label5=tk.Label(panel)
im5=tk.PhotoImage(file="Wonder Woman.png")
label5["image"]=im5
label5.place(x=750,y=10)

scroll=tk.Scrollbar(root,orient="horizontal")
scroll.pack(side="bottom",fill="x")
scroll["command"]=scrollmetod
scroll.set(.2,.80)

root.mainloop()

Las variables globales X e Y indican la posición del marco, en realidad solo modificaremos la primera. Dejando la función scrollmetod() de momento, empezamos en la línea 29 el proceso habitual de crear y colocar los objetos de interfaz. Tenemos la ventana principal y sobre esta un marco y la barra de desplazamiento. Dentro del marco colocaremos 5 etiquetas que contienen imágenes en lugar de texto.

Como necesitamos controlar las posiciones del cuadro y de las etiquetas usaremos siempre el método .place() para colocarlas y definir su tamaño en el caso del cuadro (si no lo hacemos así el cuadro se limita a adaptarse al contenido).

El sistema empleado para adquirir las imágenes es sencillo. Creamos instancias de la clase tkinter.PhotoImage indicando en el constructor la ruta del fichero gráfico. Luego asignamos el objeto a la opción "image" de las etiquetas y está todo hecho. Más abajo tienes las imágenes empleadas para descargarlas si quieres. Están editadas para que el fondo coincida con el color de la ventana y así no necesitar código adicional para gestionar transparencias.

El trabajo se realiza en la función scrollmetod(*args). Para poder modificar la variable X hemos de declararla como variable global. Comprobamos el primer argumento para diferenciar entre scroll y moveto. En el primer caso desplazamos 10 pixels si se trata de units o 40 si son pages. En el segundo empleamos la fracción para colocar el origen X. Como el cuadro tiene 1000 pixels de ancho solo hemos de multiplicar por 1000.

En las líneas 20-23 limitamos el movimiento para no exceder los límites de tamaño y en la línea 24 colocamos el marco en la nueva posición. Después ajustamos los valores de la barra, teniendo en cuenta que el punto de partida corresponde a X/1000 y el final son 600 pixels más (el ancho de la ventana), que corresponden a la fracción: .6. En realidad será un poco menos, porque al ancho de la ventana habría que restarle los bordes de esta, deberíamos emplear el ancho del área cliente de la ventana, pero la aproximación funciona. A continuación tienes la salida del programa y los gráficos empleados:

Desplazando un marco con Scrollbar
Los gráficos del programa

Existe un objeto de interfaz que nos habría facilitado la vida porque se puede relacionar directamente con las barras de desplazamiento igual que las listas de opciones, y es el lienzo. Por naturaleza un lienzo es también un contenedor, y podríamos colocar en él los cuadros o directamente los gráficos, pero no habríamos aprendido a hablar en persona con los métodos y opciones de Scrollbar, así que de esta manera es más productivo para un curso de programación.

Campos de texto (Text)

El siguiente elemento de nuestra lista es el campo de entrada, que nos permite la introducción de múltiples líneas de texto con ciertas capacidades de edición. Con este widget podemos prácticamente crear un sencillo editor de texto con muy poco esfuerzo. Las opciones de configuración particulares de este elemento son:

La opción blockcursor está desactivada por defecto. Si la activamos el cursor tendrá en tamaño de un bloque de caracter entero. En este caso conviene usar insertbackground para asignar un color que nos deje ver el texto de debajo.

Las opciones startline y endline definen cual es la primera y la última líneas que podemos ver del contenido del widget. De esta manera podemos mantener un texto oculto al principio y/o al final con la información que queramos para el empleo del campo.

La opción wrap define cómo se realiza la división de líneas cuando son demasiado largas; "none" no divide la línea, los caracteres que no quepan no son mostrados. Las opciones "char" y "word" dividen la línea en un caracter cualquiera o en una palabra. Por defecto se divide por caracteres.

Las opciones spacing(1|2|3) crean espacio adicional entre las líneas, se diferencian en el modo en que gestionan las líneas que han sido divididas; spacing1 añade espacio solo sobre la primera línea dividida (es decir, la línea real). spacing3 añade espacio bajo la última línea dividida (también la línea real, al estar hablando de debajo). spacing2 en cambio solo añade espacio en el interior de las líneas divididas.

Por último, las opciones (x|y)scrollcommand sirven para conectar barra de desplazamiento tanto horizontal como verticalmente.

Empezemos con un ejemplo para ir entendiendo algo de todo lo dicho:

#tk.text1 - Campo de texto

import tkinter as tk

ANCHO=800
ALTO=600

def spacing():
ctext["spacing1"]=0 if not sp1.get() else 10
ctext["spacing2"]=0 if not sp2.get() else 10
ctext["spacing3"]=0 if not sp3.get() else 10

def wrapping():
ctext["wrap"]=wrap.get()

def hide(event):
if event.widget==START:
ctext["startline"]=start.get()
else:
ctext["endline"]=end.get()

def cuenta():
ITEMS["text"]=str(ctext.count(0.0,"end",items.get())[0])

#Ventana principal
root=tk.Tk()
root.title("Campos de texto 1")
root.geometry(f"{ANCHO}x{ALTO}")
root.resizable(0,0)

sp1=tk.BooleanVar()
sp2=tk.BooleanVar()
sp3=tk.BooleanVar()
wrap=tk.StringVar(value="char")
start=tk.IntVar()
end=tk.IntVar()
items=tk.StringVar(value="lines")

#Marco principal donde ponemos el campo de texto
mainframe=tk.Frame(relief="sunken",bd=3,padx=20,pady=40)
mainframe.place(x=0,y=0,width=ANCHO,height=ALTO-26)

#Campo de texto
ctext=tk.Text(mainframe,width=80,height=30,relief="raised",bd=3)
ctext.pack()

#Leemos un texto con suficientes líneas para llenar el campo
with open("tk.Text.txt","r",encoding="utf-8") as f:
tkreference=f.read()
ctext.insert("insert",tkreference)

#Barra de botones
buttonframe=tk.Frame()
buttonframe.pack(side="bottom",fill="x")

#Checkbuttons para espaciado de línea
tk.Checkbutton(buttonframe,text="SP1",command=spacing,variable=sp1).pack(side="left")
tk.Checkbutton(buttonframe,text="SP2",command=spacing,variable=sp2).pack(side="left")
tk.Checkbutton(buttonframe,text="SP3",command=spacing,variable=sp3).pack(side="left")

#Radiobuttons para wrapping
tk.Radiobutton(buttonframe,text="None",variable=wrap,value="none",
command=wrapping).pack(side="left")
tk.Radiobutton(buttonframe,text="Char",variable=wrap,value="char",
command=wrapping).pack(side="left")
tk.Radiobutton(buttonframe,text="Word",variable=wrap,value="word",
command=wrapping).pack(side="left")

#Entry para startline
tk.Label(buttonframe,text="startline:").pack(side="left")
START=tk.Entry(buttonframe,textvariable=start,width=3)
START.bind("<Return>",hide)
START.pack(side="left")

#Entry para endtline
tk.Label(buttonframe,text="endline:").pack(side="left")
END=tk.Entry(buttonframe,textvariable=end,width=3)
END.bind("<Return>",hide)
END.pack(side="left")

#Separador
tk.Label(buttonframe,text="",padx=5).pack(side="left")

#Botón para contar items
tk.Button(buttonframe,text="Count",command=cuenta).pack(side="left")
tk.Radiobutton(buttonframe,text="Lines",variable=items,
value="lines",).pack(side="left")
tk.Radiobutton(buttonframe,text="Chars",variable=items,
value="chars",).pack(side="left")
ITEMS=tk.Label(buttonframe,text="",width=6,bg="white",relief="sunken")
ITEMS.pack(side="left")

root.mainloop()

Aunque el programa tiene cierta longitud es sencillo de entender para nosotros llegados a esta alturas. Mostramos el campo de texto encuadrado en un marco superior, y abajo los botones. A la izquierda tres casillas de verificación para poder conmutar un espaciado de 10 pixels con las tres opciones de spacing. Luego tenemos tres botones de opción para controlar las opciones de división de líneas, y dos campos de entrada para seleccionar el valor de las opciones startline y endline. Por último, mediante un botón y dos botones de opción podemos contar (ver el método .count() más adelante) las líneas del texto o los caracteres y mostrar el resultado en una etiqueta con una presentación especial.

Aquí tienes el aspecto del programa, podemos además editar el contenido del campo de texto.

Ejemplo de campo de texto

No hemos añadido una validación de la entrada de los campos de texto. Puedes probar por tu cuenta a limitar la entrada a valores numéricos como ejercicio.

Los campos de texto tienen numerosas posibilidades. Prueba a moverte con distintas teclas de edición y verás que tiene una amplia capacidad de respuesta. Además la lista de métodos es de las largas. Antes de echar un vistazo sobre una selección vamos a ver el concepto de los índices que hemos de emplear para designar una posición del campo.

El widget text no se vincula con ninguna variable, sino que posee su propio sistema de almacenamiento, adaptado a las necesidades concretas. El contenido se indexa con el concepto de líneas y caracteres, y para indicar una posición hemos de indicar ambos valores en la forma linea.caracter. Como el desarrollo de Tcl/Tk se produce en sistemas UNIX, por coherencia con otras aplicaciones de ese SO, la numeración de las líneas empieza por 1, sin embargo los caracteres de cada línea se cuentan a partir de 0.

Índices de campos de texto
line.char Línea y caracter. Podemos indicar "end" para este último
En este caso debemos hacerlo mediante una cadena "line.end"
@x.y Caracter en las cordenadas de pixels x e y del campo
"end" Última posición del campo
"current" Posición más próxima al puntero del ratón
"sel.first" Primer elemento de la selección
"sel.last" Último elemento de la selección
"insert" Posición del cursor

Existen además cuatro sistemas de anotar el texto: etiquetas, marcas, ventanas incrustadas e imágenes incrustadas. De momento no vamos a entrar en ello, ya lo haremos cuando estudiemos la referencia completa.

Una vez que sabemos los valores que podemos emplear como índices vamos a ver los métodos más útiles que usaremos por el momento.

Mëtodos de tk.Text
.compare(index1,index2) Compara ambos índices empleando los operadores relacionales habituales
.count(idx1,idx2,option) Cuenta elementos entre idx1 e idx2
option indica el tipo de elemento: "lines" o "chars"
.delete(index1[,index2]) Borra el caracter en index1 o entre ambos índices si se indican los dos
.get(index1[,index2]) Obtiene el caracter en index1 o la cadena entre ambos índices si se indican los dos
.index(index) Devuelve el valor del argumento index en la forma "line.col"
.insert(index,str) Inserta el contenido de str en la posición inmediatamente anterior a index
.replace(index1,index2,str) Reemplaza el contenido entre ambos índices por str
.search(pattern,index) Busca la subcadena pattern a partir de la posición index
Devuelve una cadena vacía si no se encuentra o el índice si se encuentra
(Detallamos otros parámetros más adelante)
.see(index) Ajusta el contenido para hacer visible la posición index si no lo es ya
Si está próxima la muestra en el borde más cercano
si está más lejos la muestra centrada en el campo
.xview([index]) Devuelve los valores de las columnas visibles a izquierda y derecha del campo
Si especificamos un índice ajusta el elemento visible superior
.yview([index]) Devuelve los valores de las filas visibles superior e inferior del campo
Si especificamos un índice ajusta la fila visible superior

Los dos últimos métodos permiten asociar barras de desplazamiento al campo. También existen las versiones (x|y)view_moveto() y (x|y)view_scroll(), como en las listas de opciones.

El método .search() acepta varios parámetros con nombre para ajustar su comportamiento, salvo stopindex todos ellos son conmutadores que aceptan True o False como valor.

Por defecto las búsquedas se realizan hacia delante. Si no especificamos stopindex, en caso de no encontrar el patrón se da la vuelta en el sentido adecuado hasta volver a llegar al punto de partida.

Menús en tkinter

Los menús son un tipo de objetos radicalmente diferentes de los vistos hasta ahora. Podemos organizarlos en forma de barra de menú en la parte superior de la ventana o como menús contextuales. Los menús se construyen en forma de árbol a partir de un objeto menú básico. Cada menú posee una serie de items que pueden ser a su vez menús.

Para asignar una barra de menú a una ventana hemos de hacer dos cosas, crear el objeto menú raiz y asignarlo a la propiedad "menu" de la ventana.

#Menu 1 - Menús de tkinter

import tkinter as tk

ANCHO=600
ALTO=400

root=tk.Tk()
root.title("Menús 1")
root.geometry(f"{ANCHO}x{ALTO}")

rootmenu=tk.Menu(root)
root["menu"]=rootmenu

root.mainloop()

Ocurre lo mismo que con un marco, tenemos el objeto, pero hasta que no lo dotemos de contenido no veremos ningún reflejo en el interfaz. Para añadir contenido al menú este dispone de varios métodos.

Métodos de tk.Menu
.add_cascade() Añade un submenú
.add_command(options...) Añade una orden de menú
.add_separator(options...) Añade un separador
.add_checkbutton(options...) Añade una opción conmutable
.add_radiobutton(options...) Añade opciones seleccionables
.insert(index,type,options...) Inserta un elemento en la posición indicada
.delete(index[,index2...>]) Elimina el elemento de la posición indicada
.index(index) Devuelve el índice numérico correspondiente a index
.type(index) Devuelve el tipo del elemento indicado
.entrycget(index,option) Devuelve la opción correspondiente del elemento indicado
.entryconfigure(index,options...) Configura la opción u opciones del elemento indicado
.post() Presenta el menú como un menú emergente

Vamos a agregar unos elementos a nuestro menú base:

menufile=tk.Menu(rootmenu)
rootmenu.add_cascade(label="Archivo",menu=menufile)
menuedit=tk.Menu(rootmenu)
rootmenu.add_cascade(label="Edición",menu=menuedit)

root.mainloop()

Ahora ya vemos un par de opciones en la barra de menús. Para desactivar la opción que aparece en ellos y que no queremos hemos de insertar una única línea después de la linea 13:

root.option_add("*tearOff",False)

Ya va cobrando forma, vamos ahora a dotar de contenido a las opciones Archivo y Edición.

menufile.add_command(label="Nuevo",accelerator="CTRL+N")
menufile.add_separator()
menufile.add_command(label="Cargar",accelerator="CTRL+O")
menufile.add_command(label="Guardar",accelerator="CTRL+S")

root.mainloop()

Hemos añadido tres opciones y un separador entre la primera y la segunda. Además hemos empleado la opción accelerator para presentar en el menú una combinación de teclas empleada como acelerador. Esto es como los subrayados de los textos de los botones, tkinter proporciona el soporte gráfico, pero nosotros hemos de dotarlos de contenido gestionando los eventos del teclado. Así va nuestro incipiente programa:

Nuestro primer menú

Añadamos contenido al menú Edición para probar otras posibilidades. Esta vez añadiremos un submenú con sus correspondientes opciones. También vamos a completar el menú archivos añadiendo una opción real, que realiza un trabajo.

menufile.add_separator()
menufile.add_command(label="Terminar",command=root.destroy)

menuselect=tk.Menu(menuedit)
menuselect.add_command(label="Todo")
menuselect.add_command(label="Nada")
menuedit.add_cascade(label="Seleccionar",menu=menuselect)

root.mainloop()

Para asignar una función a un elemento de menú usamos la opción command, que funciona de la misma forma que para los botones y demás. Para añadir menús en cascada, creamos el submenú y luego empleamos add_cascade de la misma forma que hicimos con el menú raíz. Así se ve el menú Edición con las nuevas líneas.

Menús en cascada

También te habrás fijado en que podemos añadir casillas de verificación y botones de opción a los menús. Funcionan de la misma manera que los objetos que ya hemos visto pero su aspecto es diferente, se muestran como las demás opciones de menú con la posibilidad de incluir una marca al lado. Vamos a inventar un menú para ponerlos a prueba. Incluímos el listado completo de la versión actual del programa.

#Menu 1 - Menús de tkinter

import tkinter as tk

ANCHO=600
ALTO=400

def setresizable():
if resizable.get():
root.resizable(1,1)
else:
root.resizable(0,0)

root=tk.Tk()
root.title("Menús 1")
root.geometry(f"{ANCHO}x{ALTO}")

resizable=tk.BooleanVar(value=True)
color=tk.StringVar(value="SystemButtonFace")

rootmenu=tk.Menu(root)
root["menu"]=rootmenu
root.option_add("*tearOff",False)

menufile=tk.Menu(rootmenu)
rootmenu.add_cascade(label="Archivo",menu=menufile)
menuedit=tk.Menu(rootmenu)
rootmenu.add_cascade(label="Edición",menu=menuedit)

menufile.add_command(label="Nuevo",accelerator="CTRL+N")
menufile.add_separator()
menufile.add_command(label="Cargar",accelerator="CTRL+O")
menufile.add_command(label="Guardar",accelerator="CTRL+S")
menufile.add_separator()
menufile.add_command(label="Terminar",command=root.destroy)

menuselect=tk.Menu(menuedit)
menuselect.add_command(label="Todo")
menuselect.add_command(label="Nada")
menuedit.add_cascade(label="Seleccionar",menu=menuselect)

menuaspect=tk.Menu(rootmenu)
rootmenu.add_cascade(label="Aspecto",menu=menuaspect)
menuaspect.add_checkbutton(label="Redimensionable",
variable=resizable,offvalue=False,onvalue=True,
command=setresizable)
menuaspect.add_separator()
menuaspect.add_radiobutton(label="Normal",variable=color,value="SystemButtonFace",
command=lambda:root.config(bg=color.get()))
menuaspect.add_radiobutton(label="Blanca",variable=color,value="white",
command=lambda:root.config(bg=color.get()))
menuaspect.add_radiobutton(label="Azul",variable=color,value="blue",
command=lambda:root.config(bg=color.get()))

root.mainloop()

Los cambios son la definición de la función setresizable, las dos variables en las líneas 18-19, necesarias para gestionar los nuevos elementos del menú, y el añadido de estos a partir de la línea 42.

Un elemento con la etiqueta: Redimensionable permite conmutar esta propiedad mediante la variable booleana resizable y la función antedicha. Luego intercalamos un separador y añadimos tres elementos opcionales, que nos permiten cambiar el color del fondo de la ventana.

Opciones conmutables y seleccionables en menús

Este el nuestro programa en funcionamiento. Ya hemos visto las posibilidades de creación de menús. Ahora solo faltaría dotarlos del código necesario para que cumplan su función.

Podemos editar los menús una vez creados con los métodos .insert() y delete(), además de poder añadir siempre nuevos elementos con cualquiera de los métodos .add_tipodeelemento().

Vamos a dejar de momento las demás posibilidades esbozadas para pasar al siguiente objeto de interfaz.

Lienzos (Canvas)

Aquí tenemos uno de los más versátiles objetos. Un lienzo es lo que indica el nombre, una superficie sobre la que podemos dibujar y también colocar otros objetos de interfaz. Posee los métodos xview() e yview() por lo que se combina con naturalidad con las barras de desplazamiento. Al contrario que los cuadros, que se adaptan al contenido, tiene entidad y dimensiones propias, con lo cual tenemos un tremendo control sobre ellos.

Toda la potencia y versatilidad se traduce también, lógicamente, en cierta complejidad. Para dominar los lienzos tenemos que enfrentarnos a una gran variedad de métodos, aunque después de haber manejado librerías como Pillow nos resultarán bastante familiares.

Todos los objetos que pintemos o coloquemos sobre un lienzo tienen un identificador mediante un índice que se crea con el elemento o mediante etiquetas que podemos asignar. Los índices son únicos para cada objeto y no son reutilizados durante la existencia del liezo. Un objeto puede recibir varias etiquetas, y una etiqueta puede ser aplicada a múltiples objetos.

La etiqueta predefinida all hace referencia a todo el contenido del lienzo, la etiqueta current es asignada automáticamente al objeto sobre el cual está el puntero del ratón. Como puede haber objetos sobre otros, siempre se refiere al objeto que ocupa la posición superior de la pila.

Las coordenadas del lienzo se expresan mediante valores en punto flotante. Un valor sin ningún sufijo se refiere a pixels, el sufijo m se refiere a milímetros, c indica centímetros, i pulgadas y p puntos tipográficos. El origen de coordenadas por defecto está en la esquina superior izquierda.

#Canvas 1 - Lienzos de tkinter

import tkinter as tk

ANCHO=600
ALTO=400

root=tk.Tk()
root.title("Un lienzo en blanco")
root.geometry(f"{ANCHO}x{ALTO}")

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

root.mainloop()

Aquí presentamos el primer programa con un lienzo. Sencillamente llenamos toda la ventana con el objeto, al que hemos asignado un marco decorativo y un color blanco de fondo. Por defecto el color sería el de la superficie normal de las ventanas.

Un lienzo en blanco

El lienzo dispone de una serie de primitivas de creación de objetos gráficos parecida a la de Pillow. Podemos trazar líneas, polígonos, arcos, rectángulos, óvalos, polígonos, texto, imágenes y unas cuantas opciones más. Vamos a alegrar el contenido del nuestro con algún gráfico.

#Canvas 1b - Lienzos de tkinter

import tkinter as tk

ANCHO=800
ALTO=600

root=tk.Tk()
root.title("Pintando en el lienzo")
root.geometry(f"{ANCHO}x{ALTO}")

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

lienzo.create_rectangle(200,200,600,400,outline="green",fill="#DDDDDD")
lienzo.create_line(100,100,700,500,fill="red",arrow="both",width=2)
lienzo.create_text(400,100,text="Lienzo",font=("times",48),anchor="center",fill="green")
lienzo.create_arc(200,200,600,400,start=-60,extent=300,style="arc",width=3)

tk.Button(lienzo,text="BOTÓN",padx=20,pady=10).place(x=400,y=550,anchor="center")

root.mainloop()

Los métodos crean objetos gráficos, y empiezan todos por create_objeto(). Vamos a revisar las opciones más interesantes para emplearlos

Las opciones comunes a la mayoría de los objetos configuran aspectos como el color o el grueso del trazo, o el estilo de este:

El más sencillo son las líneas. Se definen por una lista de coordenadas x e y de los puntos a unir (con un mínimo de dos) y a continuación las opciones.

El objeto rectángulo se crea indicando las coordenadas de las esquinas superior-izquierda e inferior-derecha y las opciones estándar relativas a color, relleno, trazado del perímetro, etc.

Los arcos se definen por un área rectangular que contiene la línea, el ángulo de comienzo (en grados): start, la extensión a partir del punto inicial: extent y la forma de la línea: sytle que puede adoptar los valores "pieslice" (por defecto), "chord" y "arc".

Para trazar textos indicamos un punto de anclaje y podemos usar las siguientes opciones específicas:

Un lienzo con cierto color

Puedes ver que hemos colocado un botón. Mediante el método .place() y empleando la opción "anchor" podemos ajustar pixel a pixel la posición de cada objeto.

Vamos a crear un muestrario de las opciones descritas hasta el momento mediante varios pequeños programas. Aquí presentamos las opciones arrow, dash y width aplicadas a diversas líneas:

#Canvas 2a - Líneas 1

import tkinter as tk

ANCHO=800
ALTO=600

root=tk.Tk()
root.title("Líneas 1")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Arial",12)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

flechas=("none","first","last","both")
lienzo.create_text(125,50,text="arrow",font=("Arial",20))
for i,j in enumerate(flechas):
x=50*(i+1)
lienzo.create_line(x,100,x,500,arrow=j)
lienzo.create_text(x,80,text=f"\"{j}\"",font=f)


tramas=((1,1),(10,5),(5,10),(20,20),(5,7,2,7))
colores=("red","blue","green","gray","magenta")

for i in range(len(tramas)):
y=100+100*i
lienzo.create_line(300,y,700,y,dash=tramas[i],fill=colores[i],width=i)
lienzo.create_text(500,y-i,anchor="s",fill=colores[i],font=f,
text=f"dash = {tramas[i]}, fill = \"{colores[i]}\", widht = {i}")

root.mainloop()

Con el resultado siguiente:

Opciones de líneas 1

Y aquí las opciones jointstyle y splinestep (esta última siempre va con la opción smooth activada, o no tendrá ningún efecto):

#Canvas 2b - Líneas 2

import tkinter as tk

ANCHO=700
ALTO=600

root=tk.Tk()
root.title("Líneas 2")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Arial",20)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

juntas=("round","bevel","miter")
color=("#CC8844","#88CC44","#8844CC","#FF4488","#44FF88","#4488FF")

for i in range(len(juntas)):
X=100+200*i
puntos=[[X,200],[X+50,100],[X+100,200]]
lienzo.create_line(puntos,joinstyle=juntas[i],width=20)
lienzo.create_text(X+50,250,text=f"jointstyle = \"{juntas[i]}\"")

puntos2=[[x[0],2*x[1]+50] for x in puntos]
lienzo.create_line(puntos2,width=12,smooth=True,splinesteps=2*(i+1))
lienzo.create_text(X+50,520,text="smooth = True ")
lienzo.create_text(X+50,500,text=f"splinesteps = {2*(i+1)}")

root.mainloop()

Y así queda al ejecutar el programa:

Opciones de líneas 2

Los rectángulos son mucho más simples.

#Canvas 2c - Rectángulos

import tkinter as tk

ANCHO=550
ALTO=460

root=tk.Tk()
root.title("Rectángulos")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Arial",15)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

X=50
Y=100

lienzo.create_rectangle(X,Y,X+200,Y+120)
lienzo.create_rectangle(X+250,Y,X+450,Y+120,width=10,outline="red")
lienzo.create_rectangle(X,Y+170,X+200,Y+290,width=10,dash=(20,20))
lienzo.create_rectangle(X+250,Y+170,X+450,Y+290,width=0,fill="green")

lienzo.create_text(275,50,text="RECTÁNGULOS",font=("Arial",36))
lienzo.create_text(X+350,Y+45,text="widht = 10",font=f)
lienzo.create_text(X+350,Y+75,text="outline = \"red\"",font=f)
lienzo.create_text(X+100,Y+215,text="widht = 10",font=f)
lienzo.create_text(X+100,Y+245,text="dash = (20, 20)",font=f)
lienzo.create_text(X+350,Y+215,text="width = 0",font=f,fill="white")
lienzo.create_text(X+350,Y+245,text="fill = \"green\"",font=f,fill="white")

root.mainloop()

Este es el aspecto:

Opciones de rectángulos

Los arcos tampoco tienen mucho misterio:

#Canvas 2d - Arcos

import tkinter as tk

ANCHO=800
ALTO=600

COLS=3
LINS=3
MARGIN=50

def casilla(index,anchor=""):
colwidth=(ANCHO-(MARGIN*(COLS+1)))//COLS
colheight=(ALTO-(MARGIN*(LINS+1)))//LINS

X=MARGIN+((index%COLS)*(colwidth+MARGIN))
Y=MARGIN+((index//COLS)*(colheight+MARGIN))

if anchor=="":
return (X,Y,X+colwidth,Y+colheight)
elif anchor.upper()=="N":
return (X+colwidth//2,Y-20)
elif anchor.upper()=="S":
return (X+colwidth//2,Y+colheight+20)
elif anchor.upper()=="W":
return (X-20,Y+colheight//2)
elif anchor.upper()=="E":
return (X+colwidth+20,Y+colheight//2)

root=tk.Tk()
root.title("Arcos")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Arial",12)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

#Marcamos las casillas
for i in range(9):
lienzo.create_rectangle(casilla(i),outline="gray",dash=(1,1))

estilos=("pieslice","chord","arc")
ángulos=((45,90),(0,270))
for i in range(6):
lienzo.create_arc(casilla(i),style=estilos[i%3],
start=ángulos[i//3][0],extent=ángulos[i//3][1])

for i,s in enumerate(estilos):
lienzo.create_text(casilla(i,"N"),text=s,font=f)
for i,a in enumerate(ángulos):
lienzo.create_text(casilla(i*3,"W"),text=f"start={a[0]}, extent={a[1]}",
angle=90)

lienzo.create_arc(casilla(6),start=0,extent=-180,width=5,fill="red")
lienzo.create_text(casilla(6,"S"),text="width=5, fill=\"red\"",font=f)

lienzo.create_arc(casilla(7),start=0,extent=-180,width=5,style="arc",
dash=(4,4),outline="blue")
lienzo.create_text(casilla(7,"S"),text="dash=(4,4), outline=\"blue\"",
font=f)

lienzo.create_arc(casilla(8),start=0,extent=-180,outline="white",
style="pieslice",fill="yellow")
lienzo.create_text(casilla(8,"S"),text="fill=\"yellow\", outline=\"white\"",
font=f)

root.mainloop()

La única peculiaridad es la función casillas, que divide la ventana en espacios iguales con el márgen indicado por las variables globales. Además puede devolver las coordenadas de los recuadros o los cuatro puntos cardinales para colocar rótulos. Y así es la ventana resultante:

Arcos

Hay un par de cosas que no podemos hacer; la primera es trazar todo la extensión del el círculo, si indicamos para extent un ángulo de 360º el punto inicial y el punto final coinciden y no aparece nada. Si que podemos indicar ángulos negativos. La segunda cosa es eliminar el borde. Aunque usemos width=0 sigue saliendo una línea de 1 pixel de ancho. El truco es usar una cadena vacía como indicación de color, de forma que este será transparente.

En cuanto al texto, las opciones que empleamos por el momento son también sencillas.

#Canvas 2e - Texto

import tkinter as tk

ANCHO=800
ALTO=600

COLS=3
LINS=3
MARGIN=10

def point(x,y,r=5,color="black"):
lienzo.create_oval(x-r/2,y-r/2,x+r/2,y+r/2,fill=color,outline=color)


def casilla(index,anchor=""):
colwidth=(ANCHO-(MARGIN*(COLS+1)))//COLS
colheight=(ALTO-(MARGIN*(LINS+1)))//LINS

X=MARGIN+((index%COLS)*(colwidth+MARGIN))
Y=MARGIN+((index//COLS)*(colheight+MARGIN))

if anchor=="":
return (X,Y,X+colwidth,Y+colheight)
elif anchor.upper()=="N":
return (X+colwidth//2,Y-20)
elif anchor.upper()=="S":
return (X+colwidth//2,Y+colheight+20)
elif anchor.upper()=="W":
return (X-20,Y+colheight//2)
elif anchor.upper()=="E":
return (X+colwidth+20,Y+colheight//2)
elif anchor.upper()=="X":
return (X+colwidth//2,Y+colheight//2)

root=tk.Tk()
root.title("Colocanto texto")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Times",24)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

#Marcamos las casillas
for i in range(9):
lienzo.create_rectangle(casilla(i),outline="gray",dash=(1,1))

ancla=("center","n","s","w","e","nw","ne","sw","se")

for i in range(9):
lienzo.create_text(casilla(i,"x"),text=f"TEXTO {i}",font=f,anchor=ancla[i])
x,y=casilla(i,"s")
y-=40
lienzo.create_text(x,y,text=f"anchor = \"{ancla[i]}\"",font=("Arial",12))
point(*casilla(i,"x"),color="red")


root.mainloop()

Presentamos aquí las posibilidades de la opción anchor. Hemos definido una nueva función que traza puntos, para marcar el origen sobre el que aplicamos el anclaje:

Textos con distinto anclaje

Eligiendo adecuadamente el origen y el anclaje podemos colocar el texto con gran flexibilidad. El anclaje es importante también si aplicamos rotación al texto, porque la rotación se efectúa usando el origen como eje.

#Canvas 2f - Texto 2

import tkinter as tk

ANCHO=800
ALTO=600

COLS=3
LINS=3
MARGIN=10

def point(x,y,r=5,color="black"):
lienzo.create_oval(x-r/2,y-r/2,x+r/2,y+r/2,fill=color,outline=color)


def casilla(index,anchor=""):
colwidth=(ANCHO-(MARGIN*(COLS+1)))//COLS
colheight=(ALTO-(MARGIN*(LINS+1)))//LINS

X=MARGIN+((index%COLS)*(colwidth+MARGIN))
Y=MARGIN+((index//COLS)*(colheight+MARGIN))

if anchor=="":
return (X,Y,X+colwidth,Y+colheight)
elif anchor.upper()=="N":
return (X+colwidth//2,Y-20)
elif anchor.upper()=="S":
return (X+colwidth//2,Y+colheight+20)
elif anchor.upper()=="W":
return (X-20,Y+colheight//2)
elif anchor.upper()=="E":
return (X+colwidth+20,Y+colheight//2)
elif anchor.upper()=="X":
return (X+colwidth//2,Y+colheight//2)

root=tk.Tk()
root.title("Rotando texto")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Times",24)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

#Marcamos las casillas
for i in range(9):
lienzo.create_rectangle(casilla(i),outline="gray",dash=(1,1))

ancla=("center","s","w")
rotación=(0,45,180)

for i in range(9):
anclaje=ancla[i%3]
rotar=rotación[i//3]
lienzo.create_text(casilla(i,"x"),text=f"TEXTO {i}",font=f,anchor=anclaje,angle=rotar)
x,y=casilla(i,"s")
y-=40
lienzo.create_text(x,y,text=f"anchor=\"{anclaje}\", angle={rotar}º",font=("Arial",12))
point(*casilla(i,"x"),color="red")


root.mainloop()

Aquí presentamos tres ángulos diferentes de rotación aplicados con tres sentidos de anclaje diferentes.

Rotación de textos

La última opción que vamos a ver es la justificación.

#Canvas 2g - Texto 3

import tkinter as tk

ANCHO=800
ALTO=600

COLS=2
LINS=3
MARGIN=10

def casilla(index,anchor=""):
colwidth=(ANCHO-(MARGIN*(COLS+1)))//COLS
colheight=(ALTO-(MARGIN*(LINS+1)))//LINS

X=MARGIN+((index%COLS)*(colwidth+MARGIN))
Y=MARGIN+((index//COLS)*(colheight+MARGIN))

if anchor=="":
return (X,Y,X+colwidth,Y+colheight)
elif anchor.upper()=="N":
return (X+colwidth//2,Y-20)
elif anchor.upper()=="S":
return (X+colwidth//2,Y+colheight+20)
elif anchor.upper()=="W":
return (X-20,Y+colheight//2)
elif anchor.upper()=="E":
return (X+colwidth+20,Y+colheight//2)
elif anchor.upper()=="X":
return (X+colwidth//2,Y+colheight//2)

root=tk.Tk()
root.title("Justificando texto")
root.geometry(f"{ANCHO}x{ALTO}")

f=("Times",24)

lienzo=tk.Canvas(bg="white",relief="groove",bd=4)
lienzo.pack(expand=True,fill="both")

#Marcamos las casillas
for i in range(9):
lienzo.create_rectangle(casilla(i),outline="gray",dash=(1,1))

justi=("left","right","center")

for i in range(6):
if i%2==0:
lienzo.create_text(casilla(i,"x"),font=f,justify=justi[i//2],
text=f"TEXTO\n{i}\njustify = \"{justi[i//2]}\"")
else:
lienzo.create_text(casilla(i,"x"),font=f,justify=justi[i//2],
text=f"TEXTO\n{i}")

root.mainloop()

Este nuevo programa nos muestra el efecto de los tres posibles valores.

Justificación

En realidad solo percibimos el efecto si imprimimos textos de varias líneas. La justificación depende de la longitud de la línea más larga. El bloque de texto comprendido por todas las líneas es utilizado para ajustar el anclaje. En el caso actual todos los bloques están centrados en el punto medio de las casillas.

Vamos a aplicar lo aprendido a algo divertido. Una pequeña aplicación para dibujar bocetos a mano alzada. En el estupendo tutorial de TkDocs hay una versión muy sencilla a la que añadiremos un toque propio.

#Sketch0 - Dibujar en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

def addline(e):
canvas.create_line(lastx.get(),lasty.get(),e.x,e.y)
lastx.set(e.x)
lasty.set(e.y)

root = tk.Tk()
root.title("Sktech 0.5")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()

canvas = tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>", lambda e:lastx.set(e.x) or lasty.set(e.y))
canvas.bind("<B1-Motion>", addline)

root.mainloop()

El cambio principal respecto al original está en el uso de variables de tkinter para guardar las posiciones, sustituyendo una función por la lambda de la línea 22, y luego detalles como la forma de importar la biblioteca o el uso del gestor de geometría pack. Observa cómo hemos usado en la función lambda una expresión or que garantiza que se evaluarán ambos lados. Así podemos invocar más de una función desde la única sentencia que nos permite la palabra clave lambda.

El programita guarda la posición en que pulsamos el botón izquierdo del ratón y cuando movemos este traza líneas desde esa posición, actualizándola continuamente. El efecto es el de pintar a mano alzada como si usásemos un lápiz.

Primera versión del programa Sketch

Prueba lo que ocurre si pintas saliéndote de la ventana y luego agrándala. Resulta que el lienzo se hace mayor y hemos seguido pintando sobre él. Además al pintar así podemos lucir mejor nuestra creatividad, pero introducimos muchísimas líneas en el trazado. Lo primero que vamos a hacer es evitar pintar fuera de la ventana.

#Sketch0a - Dibujar en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

def addline(e):
if fuera.get():
canvas.create_line(lastx.get(),lasty.get(),e.x,e.y)
lastx.set(e.x)
lasty.set(e.y)

root = tk.Tk()
root.title("Sktech 0.6")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()
fuera=tk.BooleanVar(value=False)

canvas = tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastx.set(e.x) or\
lasty.set(e.y) or fuera.set(True))
canvas.bind("<B1-Motion>",addline)
canvas.bind("<Leave>", lambda e:fuera.set(False))

root.mainloop()

Hemos añadido la variable booleana fuera que sirve para indicar que nos hemos salido de la ventana, y mediante un nuevo or en la función lambda de la línea 25 hacemos que su valor sea True dado que si pulsamos en la ventana significa que estamos dentro. En cambio, el evento <Leave> en la línea 28 hace que si salimos del área del lienzo la variable recupere el valor False. Para terminar, en la función addline comprobamos que estamos dentro del lienzo antes de pintar. El programa funciona igual que el anterior pero interrumpe el trazo si nos salimos fuera.

Vamos a comprobar la cantidad de líneas que generamos pintando de esta manera, añadiendo un contador y un lugar para mostrarlo en el programa.

#Sketch0b - Dibujar en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

def addline(e):
if fuera.get():
canvas.create_line(lastx.get(),lasty.get(),e.x,e.y)
cuenta.set(cuenta.get()+1)
lastx.set(e.x)
lasty.set(e.y)

root = tk.Tk()
root.title("Sktech 0.7")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()
fuera=tk.BooleanVar(value=False)
cuenta=tk.IntVar(value=0)

canvas = tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastx.set(e.x) or\
lasty.set(e.y) or fuera.set(True))
canvas.bind("<B1-Motion>",addline)
canvas.bind("<Leave>", lambda e:fuera.set(False))

#Añadimos una barra de estado:
statusbar=tk.Frame(root,relief="raised")
statusbar.pack(side="bottom",fill="x")
muestracuenta=tk.Entry(statusbar,textvariable=cuenta,state="disabled",
width=5,disabledbackground="white")
muestracuenta.pack(side="right")
tk.Label(statusbar,text="Líneas:").pack(side="right")

root.mainloop()

Añadimos una variable para el contador, que vamos incrementando en cada trazado de la función addline() y una barra de estado para mostrar el resultado. Lo más simple es asociar la variable con un campo de entrada, y desactivar este para evitar poder modificar el valor desde ahí. Para mejorar el aspecto hemos cambiado el color del fondo cuando está desactivado a blanco (si, hay opciones que no hemos presentado aún, pero esto es solo una introducción, no una referencia completa, todo llegará). También ponemos una etiqueta para indicar lo que nos muestra el campo de entrada. Y el programa funciona así:

Programa Sketch en progreso

Vamos a modificar la filosofía de Sketch en la línea de un programa de CADComputer Aided Design
Diseño asistido por computador
, a convertirlo en un programa más técnico en vez de artístico. Para ello en vez de dibujar a mano alzada usaremos líneas rectas.

#Sketch0c - Líneas en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

def addLine(event):
if fuera.get() and (lastx.get()!=event.x or\
lasty.get()!=event.y):
canvas.create_line((lastx.get(), lasty.get(),
event.x, event.y))

root=tk.Tk()
root.title("Sktech 0.8")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()
fuera=tk.BooleanVar(value=False)

canvas=tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastxset(e.x) or\
lastyset(e.y) or fueraset(True))
canvas.bind("<ButtonRelease-1>", addLine)
canvas.bind("<Leave>", lambda e:fueraset(False))

root.mainloop()

Sigue siendo un programa sencillo, pero resultón. Ahora hay que pulsar, deslizar el ratón y soltar para que se cree una línea entre el punto donde pulsaste el botón y el punto donde lo liberaste. La clave está en que empleamos el evento <ButtonRelease-1 en vez de <B1-Motion. Una vez pintada la línea, como hemos de volver a pulsar no necesitamos guardar la última posición.

Sketch un poco más serio

El programa funciona pero pintamos a ciegas, no aparece nada hasta el momento de soltar el botón. Esto lo solucionamos rápida y elegantemente en la próxima versión.

#Sketch0d - Líneas en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

draft=None

def addLine(event):
if fuera.get() and (lastx.get()!=event.x or\
lasty.get()!=event.y):
canvas.create_line((lastx.get(), lasty.get(),
event.x, event.y))
def boceto(event):
global draft
canvas.delete(draft)
if fuera.get():
draft=canvas.create_line((lastx.get(), lasty.get(), event.x, event.y),
fill="gray",dash=(4,4))

root=tk.Tk()
root.title("Sktech 0.9")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()
fuera=tk.BooleanVar(value=False)

canvas=tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastxset(e.x) or\
lastyset(e.y) or fueraset(True))
canvas.bind("<B1-Motion>",boceto)
canvas.bind("<ButtonRelease-1>", addLine)
canvas.bind("<Leave>", lambda e:fueraset(False))

root.mainloop()

Hemos añadido la variable global draft y el método boceto() que asignamos al evento <B1-Motion>. La variable almacena la referencia de la línea provisional, que borramos en la línea 17 y a continuación volvemos a dibujar. El resultado es que tenemos una línea punteada de color gris para indicarnos como quedará nuesta línea cuando soltemos el botón. Esto es otra cosa.

Sketch subiendo peldaños

Lo útimo que añadiremos a este programa educativo es un poco de gestión de color y grosor de trazo. Para ello añadiremos nuevamente una barra inferior y en ella colocaremos algún widget oportuno.

#Sketch1 - Líneas en un canvas

import tkinter as tk

ANCHO=600
ALTO=400

draft=None
selected={}

#Función que añade líneas
def addLine(event):
colorbar.place_forget()
if fuera.get() and (lastx.get()!=event.x or\
lasty.get()!=event.y):
canvas.create_line((lastx.get(), lasty.get(),
event.x, event.y),fill=color.get(),
width=trazo.get())
canvas.delete(draft)

#Función que marca el movimiento del ratón
def boceto(event):
colorbar.place_forget()
global draft
canvas.delete(draft)
if fuera.get():
draft=canvas.create_line((lastx.get(),lasty.get(),event.x,event.y),
fill=color.get(),width=trazo.get(),
dash=(4,4))

#Función que hace visible el panel de colores
def opencolor(e):
colorbar.place(x=1,y=root.winfo_height()-27,anchor="sw")

#Función que modifica el grosor
def settrazo(*args):
colorbar.place_forget()
if args[0]=="scroll":
if args[1]=="1":
if trazo.get()<15:
trazo.set(trazo.get()+1)
elif trazo.get()>1:
trazo.set(trazo.get()-1)

#Función de selección con el botón derecho
def seleccionar(e):
colorbar.place_forget()
global selected
item=canvas.find_withtag("current")
if len(item)>0:
item=item[0]
if item in selected:
valor=selected[item]
canvas.itemconfig(item,fill=valor[0],width=valor[1],dash=())
del selected[item]
else:
valor=(canvas.itemcget(item,"fill"),canvas.itemcget(item,"width"))
canvas.itemconfig(item,fill="gray",dash=(1,1))
selected[item]=valor

if selected=={}:
delbutton["state"]="disabled"
else:
delbutton["state"]="normal"

#Función que borra la selección
def borrar():
colorbar.place_forget()
global selected
if selected!={}:
for i in selected.keys():
canvas.delete(i)
selected={}
delbutton["state"]="disabled"

########### PROGRAMA PRINCIPAL ###########
root=tk.Tk()
root.title("Sketch 1.0")
root.geometry(f"{ANCHO}x{ALTO}")

lastx=tk.IntVar()
lasty=tk.IntVar()
fuera=tk.BooleanVar(value=False)
color=tk.StringVar(value="black")
trazo=tk.IntVar(value=1)

canvas=tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastx.set(e.x) or\
lasty.set(e.y) or fuera.set(True))
canvas.bind("<B1-Motion>",boceto)
canvas.bind("<ButtonRelease-1>", addLine)
canvas.bind("<Leave>", lambda e:fuera.set(False))
canvas.bind("<Button-3>",seleccionar)

#Añadimos la barra de botones
buttonbar=tk.Frame()
buttonbar.pack(side="bottom",fill="x")

#Creamos el menú de colores
colores=["black","red","blue","green","yellow","magenta","cyan","brown","orange"]
colorbar=tk.Frame(root)
colorbuttons=[]
for i in colores:
colorbuttons.append(tk.Button(colorbar,width=10,bg=i,
command=lambda x=i:color.set(x) or\
colorbutton.config(bg=x) or\
colorbar.place_forget()))
colorbuttons[-1].pack()

#Creamos el botón que activa el menú de colores
colorbutton=tk.Button(buttonbar,width=3,bg="black")
colorbutton.bind("<Button-1>",opencolor)
colorbutton.pack(side="left")

#Espaciador
tk.Label(buttonbar,text="",padx=10).pack(side="left")

#Creamos un invento mutante para gestionar el trazo
trazolabel=tk.Label(buttonbar,text="123456789012",fg="SystemButtonFace")
trazolabel.pack(side="left")
trazobutton=tk.Scrollbar(trazolabel,orient="horizontal",command=settrazo)
trazobutton.place(x=0,y=-5,width=70,height=26)
trazoentry=tk.Entry(trazolabel,width=4,textvariable=trazo,state="disabled",
disabledbackground="white")
trazoentry.place(x=17,y=-5,height=26,width=36)

#Espaciador
tk.Label(buttonbar,text="",padx=10).pack(side="left")

#Botón de borrado
delbutton=tk.Button(buttonbar,text="Borrar",padx=10,state="disabled",command=borrar)
delbutton.pack(side="left")

#Botón de salir
tk.Button(buttonbar,text="SALIR",padx=10,command=root.destroy).pack(side="right")

root.mainloop()

Ha dejado de ser un programa sencillo, pero ahora podemos elegir entre una paleta de colores, cambiar el grosor del trazo y además nos hemos dejado llevar por el entusiasmo y hemos añadido la posibilidad de seleccionar/deseleccionar líneas con el botón derecho y borrar la selección mediante el botón <Borrar>.

El programa ahora presenta este flamante aspecto:

Sketch en una versión funcional

Analicemos el código, tenemos las siguientes funciones y variables:

FuncionesVariables
addLine Añade una línea respondiendo a la
liberación del botón izquierdo
draft Almacena los trazados provisionales
boceto Traza las líneas provisionales selected Un diccionario que guarda las líneas
seleccionadas y sus opciones
opencolor Abre el panel de colores lastx
lasty
Almacenan el punto de comienzo de las líneas
settrazo Modifica el grosor del trazo fuera Indica si nos salimos de la ventana
seleccionar Selecciona o deselecciona líneas color Almacena el color activo
borrar Borra la selección trazo Almacena el grosor del trazo activo

Veamos los entresijos de cada función:

#Función que añade líneas
def addLine(event):
colorbar.place_forget()
if fuera.get() and (lastx.get()!=event.x or\
lasty.get()!=event.y):
canvas.create_line((lastx.get(), lasty.get(),
event.x, event.y),fill=color.get(),
width=trazo.get())
canvas.delete(draft)

El método .place_forget() de la línea 13 es el opuesto a .place(). Si la ventana estaba presente en el gestor de geometría hace que sea "olvidada", lo que causa su desaparición del interfaz. Si el panel de colores estuviese desplegado así haremos que se oculte, de lo contrario tampoco se producen errores. Comprobamos si estamos dentro de la ventana y si es así si alguna coordenada ha cambiado desde que pulsamos el botón. Si todo es así trazamos la línea entre las coordenadas iniciales y las actuales, empleando las opciones de color y grosor actuales. Además en la línea 19 borramos la línea provisional si esta existe, de no ser así tampoco se produce ningún error.

#Función que marca el movimiento del ratón
def boceto(event):
colorbar.place_forget()
global draft
canvas.delete(draft)
if fuera.get():
draft=canvas.create_line((lastx.get(),lasty.get(),event.x,event.y),
fill=color.get(),width=trazo.get(),
dash=(4,4))

Aquí empezamos también cerrando el panel de colores (siempre lo haremos así). Empleamos la variable draft que guarda la línea provisional en caso de existir y la borramos. A continuación, si estamos dentro de la ventana trazamos una nueva línea provisional y guardamos su referencia en draft. La línea provisional tiene las mismas características que tendrá la definitiva (color y grueso) pero está punteada.

#Función que hace visible el panel de colores
def opencolor(e):
colorbar.place(x=1,y=root.winfo_height()-27,anchor="sw")

El panel de colores corresponde al marco colorbar que hemos llenado con botones coloreados. Al invocar el método place lo colocamos sobre el objeto "padre" que es la ventana principal. La posición es un pixel a la derecha del borde izquierdo y 27 pixels más arriba del borde inferior. Obtenemos este mediante el método .winfo_height() que nos devuelve la altura en pixels de un objeto, en este caso root. Empleamos la opción anchor="sw" para que el panel se despliegue justo a la derecha y encima del punto de anclaje.

#Función que modifica el grosor
def settrazo(*args):
colorbar.place_forget()
if args[0]=="scroll":
if args[1]=="1":
if trazo.get()<15:
trazo.set(trazo.get()+1)
elif trazo.get()>1:
trazo.set(trazo.get()-1)

Aquí llegamos al pulsar las flechas de la barra de desplazamiento. Como ya vimos en su momento, estas provocan unos argumentos del tipo: "scroll" x "units", donde la x puede adoptar los valores ±1. Comprobamos que el primer argumento es el esperado y de ser así comprobamos si el segundo es el valor positivo. En tal caso, incrementamos la variable trazo si es menor que 15, que es el valor límite. En caso contrario (hemos de disminuir el valor) comprobamos si el valor es mayor que 1 y en tal caso lo reducimos restando 1.

#Función de selección con el botón derecho
def seleccionar(e):
colorbar.place_forget()
global selected
item=canvas.find_withtag("current")
if len(item)>0:
item=item[0]
if item in selected:
valor=selected[item]
canvas.itemconfig(item,fill=valor[0],width=valor[1],dash=())
del selected[item]
else:
valor=(canvas.itemcget(item,"fill"),canvas.itemcget(item,"width"))
canvas.itemconfig(item,fill="gray",dash=(1,1))
selected[item]=valor

if selected=={}:
delbutton["state"]="disabled"
else:
delbutton["state"]="normal"

La función de selección es lanzada por la pulsación del botón derecho del ratón sobre el lienzo. La variable selected es un diccionario que contiene los elementos seleccionados (si los hay). En la linea 49 usamos el método .find_withtag() que busca un objeto dentro del lienzo por su identificador o por una etiqueta. La etiqueta "current" es una referencia al objeto que está bajo el cursor del ratón, con lo cual si hemos pulsado sobre una línea obtenemos su identificador en forma de tupla, o una tupla vacía si no hay nada bajo el ratón. Comprobamos la última posibilidad en la línea 51, y si hay un valor en la tupla lo extraemos en la siguiente. Ahora tenemos un número que identifica de manera inequívoca cada trazo dentro del lienzo.

Comprobamos (linea 52) si ya está dentro de la selección y de ser así, obtenemos las opciones correspondientes que estarán en el diccionario, volvemos a aplicarlas a la línea (restituímos el color y el ancho y eliminamos el punteado). Luego, eliminamos la entrada del diccionario.

Si la línea no estaba seleccionada, llegamos al bloque else de la línea 56 y hacemos lo contrario; obtenemos los valores de color y ancho con el método itemgetc(), luego damos color gris a la línea y la punteamos. Por último creamos la correspondiente entrada en el diccionario selected guardando en ella los valores obtenidos.

Las líneas 61-64 son un detalle. Si la selección está vacía desactivamos el botón de borrado, y en caso contrario lo activamos

#Función que borra la selección
def borrar():
colorbar.place_forget()
global selected
if selected!={}:
for i in selected.keys():
canvas.delete(i)
selected={}
delbutton["state"]="disabled"

La función borrar emplea el diccionario selected, comprobando por seguridad que no esté vacío para no producir un error (aunque así fuese la aplicación de ventana no se cerraría, solo lo veríamos en la consola). En el bucle de las líneas 72-73 borramos todos los elementos seleccionados, a continuación borramos el contenido del diccionario y desactivamos el botón de borrado.

El resto del código, como es habitual, define y coloca los elementos del interfaz. Empezamos por la ventana principal como es ya costumbre. Luego creamos las variables de tkinter (recuerda que hemos de hacerlo cuando ya haya una ventana principal, o se producirá un error). Creamos el lienzo con fondo blanco en la linea 87 y lo colocamos en la ventana raíz llenando el espacio disponible (eso quiere decir lo que queda despues de colocar los demás objetos, que en este caso solo será la barra de botones). En las líneas 89-94 asignamos la gestión de los eventos que controlan nuestro programa.

canvas=tk.Canvas(root,bg="white")
canvas.pack(expand=True,fill="both")
canvas.bind("<Button-1>",lambda e:lastx.set(e.x) or\
lasty.set(e.y) or fuera.set(True))
canvas.bind("<B1-Motion>",boceto)
canvas.bind("<ButtonRelease-1>", addLine)
canvas.bind("<Leave>", lambda e:fuera.set(False))
canvas.bind("<Button-3>",seleccionar)

La pulsación del botón izquierdo (líneas 89-90) produce el almacenamiento de las coordenadas actuales del ratón y la activación de la indicación de que estamos dentro de la ventana. Hemos utilizado la construcción lógica para incorporar tres llamadas en una sola expresión. El resultado es indiferente.

El evento <Leave> indica que hemos salido de la ventana. En ese caso nos limitamos a marcarlo en la variable fuera (que deberíamos haber llamado "dentro").

En las líneas 96-98 creamos la barra de botones y la colocamos en la parte baja de la ventana principal, llenando todo el ancho, y a continuación creamos todos los botones y objetos que irán en ella.

#Creamos el menú de colores
colores=["black","red","blue","green","yellow","magenta","cyan","brown","orange"]
colorbar=tk.Frame(root)
colorbuttons=[]
for i in colores:
colorbuttons.append(tk.Button(colorbar,width=10,bg=i,
command=lambda x=i:color.set(x) or\
colorbutton.config(bg=x) or\
colorbar.place_forget()))
colorbuttons[-1].pack()

Tenemos una lista con los colores de nuestra paleta. Creamos el panel colorbar y mediante un bucle los botones. Estos los añadimos a la lista colorbuttons para luego poder mostralos en el panel con la línea 109. A los botones les damos un ancho de 10 (recuerda que se trata de caracteres) y un fondo del color en cuestión. Luego les asignamos un comando y empleamos una expresión relacional dentro de una función lambda para hacer tres cosas. Guardar el color en la variable color, cambiar el color del botón colorbutton para que refleje el color activo y por último cerrar el panel de colores. Todo esto ocurrirá cuando el panel se muestre y pulsemos un botón, de momento solo queda programado el comportamiento. No mostramos el panel.

#Creamos el botón que activa el menú de colores
colorbutton=tk.Button(buttonbar,width=3,bg="black")
colorbutton.bind("<Button-1>",opencolor)
colorbutton.pack(side="left")

Creamos un botón sin texto de tres espacios de ancho y asignamos el color inicial negro al fondo. Asignamos la gestión del evento de pulsación del botón izquierdo. Esto ha ocurrido así por diversas variantes durante el desarrollo del programa. En realidad podríamos añadir la opción command=opencolor al crear el botón, quitando el parámetro de opencolor() en la línea 32. En todo caso funciona del mismo modo.

#Creamos un invento mutante para gestionar el trazo
trazolabel=tk.Label(buttonbar,text="123456789012",fg="SystemButtonFace")
trazolabel.pack(side="left")
trazobutton=tk.Scrollbar(trazolabel,orient="horizontal",command=settrazo)
trazobutton.place(x=0,y=-5,width=70,height=26)
trazoentry=tk.Entry(trazolabel,width=4,textvariable=trazo,state="disabled",
disabledbackground="white")
trazoentry.place(x=17,y=-5,height=26,width=36)

El objeto mutante tiene por objetivo colocar una barra de desplazamiento con un campo de texto dentro de forma que al pulsar las flechas modifiquemos el valor del campo. Las barras de desplazamiento tienen anchura, pero no longitud, así que necesitamos un objeto que tenga dimensiones propias (no nos vale ni Frame ni Canvas) y que podamos colocar con .pack() para no tener que hacer calculos con el tamaño de la barra de botones y del botón de colores. El objeto elegido ha sido una etiqueta, en las líneas 120-121 le hemos adjudicado un texto del ancho adecuado y con un color que lo hace invisible y lo colocamos junto al botón de selección de color. En las líneas 122-123 creamos la barra de desplazamiento en sentido horizontal adjudicando la función settrazo() para responder a las pulsaciones. Luego la colocamos sobre la etiqueta usando .place() con unas dimensiones obtenidas con mucho ensayo y error. Usamos el gestor de geometría place porque nos da la posibilidad de colocar el objeto en un lugar y con unas dimensiones exactos. Además no se ve afectado por la colocación de otros objetos, de ese modo podemos colocar encima el campo de entrada en las líneas 124-126. Este está vinculado con la variable trazo para mostrar el valor de esta, además está desactivado para no poder alterar el valor y con el fondo de color blanco para no mostrar el aspecto de un campo desactivado. Lo colocamos cuidadosamente en medio de las flechas de la barra de desplazamiento.

El resto es totalmente claro, creamos los botones de borrado y de terminación de la forma normal.

Y aquí dejamos por el momento los interfaces y pasamos a nuevos campos para la exploración de Python