Python es un lenguaje de programación:
En python, las estructuras de control no están delimitadas por caracteres como los tradicionales corchetes:
if (2 + 3 == 5) {
x = 5 + 3;
y = 2 + 3;
} else {
x = 5 - 3;
y = 2 - 3;
}
En Python los caracteres de espacio importan. En lugar de los corchetes, Python usa una indentación o sangría, por defecto de 4 espacios, para denotar un bloque de código subordinado a una estructura de control:
In [1]:
if 2 + 3 == 5:
x = 5 + 3
mensaje = "Verdadero!"
else:
x = 5 - 3
mensaje = "Falso!"
print(x)
print(mensaje)
De esta manera, Python estandariza el aspecto del código desde la definición del lenguaje.
Nota:
:
";
" al final de cada expresión si es la única en la linea. Pero puede ser usada para colocar múltiples en una sola línea.x = 1 + 1; print(x);
Adicionalmente Python cuenta con un conjunto de palabras, llamadas palabras clave o reservadas que tienen un significado especial dentro del lenguaje, las cuales no pueden ser utilizadas como nombre de variables:
False class finally is return None continue for lambda try True def from nonlocal while and del global not with as elif if or yield assert else import pass break except in raise
In [2]:
type(True)
Out[2]:
Los operadores para variables booleanas son sólo 3
Operation | Result |
---|---|
x or y |
si x es falso, entonces y, en caso contrario x |
x and y |
si x es falso, entonces x, en caso contrario y |
not x |
si x es falso, entonces True ,
en caso contrario False |
Fuente: Python docs.
In [3]:
type(5)
Out[3]:
In [4]:
type(3.1416)
Out[4]:
La siguiente tabla muestra las operaciones posibles entre variables numéricas:
Operación | Resultado |
---|---|
x + y |
suma de x e y |
x - y |
diferencia de x e y |
x * y |
producto de x e y |
x / y |
cociente de x e y |
x // y |
cociente of x e y con redondeo hacia abajo |
x % y |
resto de x / y |
-x |
cambio de signo de x |
+x |
x sin modificación |
abs(x) |
valor absoluto o magnitud de x |
int(x) |
x covnertido a entero (integer) |
long(x) |
x convertido a entero largo (long integer) |
float(x) |
x convertido a punto flotante (floating point) |
complex(re,im) |
un número complejo con parte real re, parte imaginaria im. im es por defecto cero. |
c.conjugate() |
la conjugación del número complejo c. |
divmod(x, y) |
el par (x // y, x % y) |
pow(x, y) |
x elevado a la y |
x ** y |
x elevado a la y |
Fuente: Python docs.
Python maneja distintos dipos de secuencias: listas, tuplas y rangos. Cada una tiene sus características, sin embargo la gran mayoría de las siguientes operaciones puede ser utilizada con ellas.
Operación | Resultado |
---|---|
x in s |
True si un elemento de s es
igual a x, en caso contrario False |
x not in s |
False si un elemento de s es
igual a x, en caso contrario True |
s + t |
la concatenación de s y t |
s * n ó
n * s |
equivalente a añadir s a sí mismo n veces |
s[i] |
i-ésimo elemento de s, con origen 0 |
s[i:j] |
subsecuencia (slice) de s desde posición i hasta posición j |
s[i:j:k] |
subsecuencia (slice) de s desde posición i hasta posición j con pasos de longitud k |
len(s) |
longitud de s |
min(s) |
elemento de menor valor en s |
max(s) |
elemento de mayor valor en s |
s.index(x[, i[, j]]) |
índice de la primera ocurrencia de x en s (en o después del índice i y antes del índice j) |
s.count(x) |
cantidad total de ocurrencias de x en s |
Fuente: Python docs.
Adicionalmente las secuencias pueden ser mutables o inmutables. Las secuencias mutables permiten las siguientes operaciones:
Operación | Resultado |
---|---|
s[i] = x |
elemento i de s es reemplazado por x |
s[i:j] = t |
slice (subsección) de s desde i hasta j es reemplazada por los componentes del iterable t |
del s[i:j] |
igual que s[i:j] = [] |
s[i:j:k] = t |
los elementos de s[i:j:k] son reemplazados por los de t |
del s[i:j:k] |
remueve los elementos de s[i:j:k] de la lista |
s.append(x) |
añade x al final de la secuencia (igual que s[len(s):len(s)] = [x] ) |
s.clear() |
remueve todos los elementos de s (igual que del s[:] ) |
s.copy() |
crea una copia superficial de s (igual que s[:] ) |
s.extend(t) ó s += t |
extiende s con los contenidos de t (por lo general igual que s[len(s):len(s)] = t ) |
s *= n |
actualiza s con sus contenidos repetidos n veces |
s.insert(i, x) |
inserta x en s en la posición i inserts (igual que s[i:i] = [x] ) |
s.pop([i]) |
obtiene y remueve el elemento en la posición i de s |
s.remove(x) |
remueve el primer elemento de s donde s[i] == x |
s.reverse() |
invierte los lugares de los elementos de s |
Fuente: Python docs.
In [5]:
lista_vacia = []
print(lista_vacia)
#O equivalentemente
lista_vacia = list()
print(lista_vacia)
También pueden crearse directamente con valores adentro:
In [6]:
semana = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
print(semana)
Las listas no están restringidas a tener elementos del mismo tipo:
In [7]:
cosas_aleatorias = [1+2, "Donald Trump", None, 3.5]
print(cosas_aleatorias)
Para acceder a algun valor, solo hace falta indizarlo haciendo uso de la siguiente sintaxis con corchetes:
In [8]:
#En Python, los índices inician en 0
print (cosas_aleatorias[0])
print (cosas_aleatorias[1])
Adicionalmente se pueden seleccionar subsecciones de las listas haciendo slicing.
Para el slicing, el primer índice indica el inicio de la sublista (inclusivo), el segundo indica el fin (exclusivo), y el tercer número opcional indica cada cuantos elementos coger.
La selección de sublistas adicionalmente tienen reglas adicionales ilustradas a continuación:
In [9]:
print (semana[0:3]) #Desde el primero hasta el tercer elemento
print (semana[0:7:2]) #Toda la lista, pero cada dos elementos
print (semana[:3]) #Desde el inicio de la lista hasta el tercer elemento
print (semana[5:]) #Desde el quinto elemento hasta el final de la lista
print (semana[:-2]) #Desde el primer elemento hasta 2 espacios antes del final de la lista
print (semana[:]) #Toda la lista
Nota: Hay que tener cuidado con un detalle. Las sublistas seleccionadas de esta manera son un reflejo de la lista original. En otras palabras, si se modifica esta sublista, se modifica la lista original.
In [10]:
cosas_aleatorias[:3] = [1, 2, 3]
print(cosas_aleatorias)
Una forma rápida de definir una lista conteniendo una secuencia de enteros es haciendo uso de los rangos.
In [11]:
dias_en_diciembre = list(range(1, 32))
print (dias_en_diciembre)
Pero si las reglas son un poco más complejas, es necesario hacer uso de los llamados list comprehensions. Las list comprehensions son expresiones que son usadas para describir un conjunto de valores, de forma similar a como se describen los conjuntos por comprensión en matemática:
$S = \{x^2 : x > 0 \wedge x \le 5\}$
Lo cual, expresado por extensión, resulta:
$S = \{1, 2, 4, 8, 16\}$
De la misma manera podemos definir una lista haciendo uso de list comprehensions:
In [12]:
[2**x for x in range(5)]
Out[12]:
In [13]:
[x for x in dias_en_diciembre if x % 2 == 0] #Días pares en diciembre
Out[13]:
Los list comprehensions tienen tres partes:
Expresado de la siguiente manera:
[{Funcion(x)} for x in {dominio} if {condicion}]
Y se puede entender como "aplica la función a todo elemento del dominio que cumpla con la condición y devuelve los resultados en una nueva lista".
In [53]:
#Las tuplas se crean haciendo uso de paréntesis en lugar de corchetes
semana = ("Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo")
semana[0] = "Enero" #Intentar modificar un valor de una tupla genera un error
In [15]:
pangrama = "El veloz murciélago hindú comía feliz cardillo y kiwi. La cigüeña tocaba el saxofón detrás del palenque de paja"
print(pangrama[3:8])
pangrama[3:8] = "lento"
Adicionalmente tienen muchos métodos adicionales (que pueden ver en la referencia oficial), como por ejemplo upper()
In [16]:
print(pangrama.upper())
In [17]:
numeros_pares = {0, 2, 4, 6, 8, 10, 12, 14, 16, 18} #La creación de conjuntos es con llaves o con la funcion set()
print ("numeros_pares:", numeros_pares)
multiplos_de_tres = set(range(0, 20, 3)) #La función set puede recibir otros iterables o secuencias
print ("multiplos_de_tres:", multiplos_de_tres, "\n")
print("Intersección:", numeros_pares & multiplos_de_tres)
print("Union:", numeros_pares | multiplos_de_tres)
print("Diferencia:", numeros_pares - multiplos_de_tres)
Adicionalmente, dentro de un set no pueden existir elementos duplicados. Transformar una lista o tupla en set remueve los duplicados, lo cual a menudo resulta muy útil.
In [18]:
semana = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
semana *= 3
print("Lista semana: ", semana, "\n")
semana = set(semana)
print("Set semana:", semana)
In [19]:
#Todas estas maneras de crear diccionarios son equivalentes, definiendo la capital de tres países
capitales_1 = dict(Peru = 'Lima', Ecuador = 'Quito', Argentina = 'Buenos Aires')
capitales_2 = {'Peru': 'Lima', 'Ecuador': 'Quito', 'Argentina': 'Buenos Aires'}
capitales_3 = dict(zip(['Peru', 'Ecuador', 'Argentina'], ['Lima', 'Quito', 'Buenos Aires']))
capitales_4 = dict([('Ecuador', 'Quito'), ('Peru', 'Lima'), ('Argentina', 'Buenos Aires')])
capitales_5 = dict({'Argentina': 'Buenos Aires', 'Peru': 'Lima', 'Ecuador': 'Quito'})
capitales_1 == capitales_2 == capitales_3 == capitales_4 == capitales_5
Out[19]:
Los valores de los diccionarios se acceden a través de su llave usando la sintaxis
diccionario[llave]
In [20]:
print("'Peru' ->", capitales_1['Peru'])
print("'Ecuador' ->", capitales_1['Ecuador'])
print("'Argentina' ->", capitales_1['Argentina'])
Es posible recorrer un diccionario, valor por valor, pero cabe mencionar que no se garantiza ninguna clase de orden, a diferencia de las secuencias:
In [21]:
for capital in capitales_1: #En breve veremos que significa 'for'
print(capital)
El verdadero poder de los lenguajes de programación imperativo se encuentra en la posibilidad de utilizar las llamadas estructuras de control, lo que nos permite escribir programas más complejos y más útiles de los que podríamos crear sin ellas.
Hasta ahora sólo hemos podido escribir programas secuenciales, donde cada estructura se ejecuta una tras de otra hasta el final del programa (salvo que se encuentre un error).
Mediante las palabras clave if
y else
podemos escribir instrucciones condicionales. Es decir, partes del programa que se ejecutarán únicamente si se cumple con cierta condición. De esta manera podemos responder a situaciones no conocidas antes de ejecutar un programa (por ejemplo: el input recibido, los valores que se lean de un archivo o base de datos, etc.)
La estructura de un condicional puede ser de tres maneras:
if ({expresion condicional}):
{codigo a ejecutar si la condicion evalua a True}
{mas codigo...}
if ({expresion condicional}):
{codigo a ejecutar si la expresion condicional evalua a True}
{mas codigo...}
else:
{codigo a ejecutar en caso contrario (si la expresion condicional evalua a False)}
{mas codigo...}
La palabra reservada elif
es una abreviación de else if, lo cual traducido significa en caso contrario, si, y sirve para encadenar condiciones adicionales en caso las anteriores no se hayan cumplido.
if ({expresion condicional 1}):
{codigo a ejecutar si la expresion condicional 1 evalua a True}
{mas codigo...}
elif ({expresion condicional 2}):
{codigo a ejecutar si la expresion condicional 1 evalua a False
y la expresion condicional 2 evalua a True}
{mas codigo...}
elif ({expresion condicional 3}):
{codigo a ejecutar si la expresion condicional 1 evalua a False
y la expresion condicional 2 evalua a False
y la expresion condicional 3 evalua a True}
else:
{codigo a ejecutar si ninguna de las expresiones condicionales anteriores evalua a True}
{mas codigo...}
Notar el uso de indentación para delimitar el código que se ejecutará en cada caso.
In [22]:
x = int(input("Por favor ingrese un entero: "))
if x < 0:
print("El numero ingresado es negativo")
elif x == 0:
print("El numero ingresado es cero")
else:
print("El numero ingresado es positivo")
Adicionalmente, podemos contar con código que repita un grupo de instrucciones una cantidad de veces, dependiendo de una condición, utilizando la palabra clave while
. De esta manera, al igual que con if
y else
, podemos crear programas que funcionen de forma distinta dependiendo de las condiciones en las que se ejecuta el programa.
La sintaxis es similar a la de if
.
while({expresion condicional}):
{(a) codigo a ejecutar mientras la expresion condicional evalua a True}
{(b) mas codigo}
{(c) codigo exterior}
El flujo del programa seguiría de la siguiente manera:
False
, el código interno no se ejecuta. En otras palabras, la próxima instrucción a ejecutar en el caso anterior será {(c) código exterior} y se habrá salido del bucle.True
, se ejecuta el código interno (a) y (b).
In [23]:
#Fibonacci
a, b = 0, 1
while b < 1000:
print(b, end=',')
a, b = b, a + b
También podemos salir de un bucle en cualquier momento utilzando la palabra reservada break
. Ejecutar esta instrucción implica terminar la ejecución del bucle más cercano.
In [24]:
password = ""
while True: # <- Con True como condición, el bucle se ejecutaría permanentemente
password = input("Por favor ingrese la contraseña: ")
if password == "secret":
print("Gracias. Ha ingresado la contraseña correcta.")
break # <- Pero con break podemos salir del bucle
else:
print("Lo sentimos, la contraseña es incorrecta - inténtelo de nuevo.")
Asimismo podemos utilizar la palabra reservada continue
para terminar temparanamente una iteración y continuar con la siguiente
In [25]:
tareas = []
while True:
#Formateo de strings https://docs.python.org/3/library/string.html#format-string-syntax
tarea = input("%d tareas ingresadas. Ingrese una tarea o 'exit' para terminar: " % len(tareas))
if len(tarea) == 0:
print ("Por favor ingrese una tarea")
continue # <- Esta palabra reservada termina inmediatamente la actual iteración y continúa con la siguiente
if tarea == "exit": #
break # Todo este código es ignorado durante la actual iteración si
# anteriormente se ejecuta una instrucción continue
tareas.append(tarea) #
print("Su lista de tareas:")
print("\r\n".join(tareas))
La palabra reservada for
es similar a while
en cuanto nos permite ejecutar código de forma iterativa. La diferencia está en que for nos permite realizar fácilmente operaciones por cada elemento de una colección iterable (listas, tuplas, sets, etc).
La sintaxis es como sigue:
for ({elemento} in {coleccion}):
{(a) codigo a ejecutar por cada elemento en la coleccion}
#Aquí se puede acceder al elemento actual bajo el nombre dado en {elemento}
Nota: Todo lo que se puede hacer con for
se puede hacer con while
, sin embargo, for es más apropiado para las tareas para las que se ha creado, por ser más comprensible y elegante.
In [30]:
#Añdadir número de tarea a la lista definida anteriormente
i = 1
for tarea in tareas:
print ("%d. %s" % (i, tarea))
i += 1
El bucle anterior puede ser resumido con el uso de la función predefinida enumerate
In [32]:
for (i, tarea) in enumerate(tareas):
print ("%d. %s" % (i+1, tarea))
Asimismo podemos utilizar for
para realizar una operación una cantidad determinada de veces haciendo uso de la función predefinida range()
In [38]:
#Diez primeros dígitos de la secuencia Fibonacci
a, b = 0, 1
for _ in range(10):
print(b, end=',')
a, b = b, a + b
También es factible usar break
y continue
dentro de los bucles for
.
In [42]:
#Diez primeros dígitos de la secuencia Fibonacci, o hasta encontrar un múltiplo de 7
a, b = 0, 1
for _ in range(10):
print(b, end=',')
if b % 7 == 0:
break
a, b = b, a + b
Python permite definir funciones, las cuales son bloques de código reutilizable (tales como las ya utilizadas print()
, input()
, list()
, dict()
, set()
, range()
y enumerate()
).
En algunos lenguajes de programación las funciones están obligadas a devolver valores, pero en python pueden no hacerlo. Adicionalmente las funciones en Python no están obligadas a devolver un solo valor, sino que pueden devolver más de uno.
La sintaxis básica para definir una función es como sigue:
def nombre_de_funcion( {parametros} ):
"""Documentación del código"""
{Codigo ejecutable}
return {expresion}
Por ejemplo, definimos una función que devuelva el número n de la secuencia de Fibonacci.
In [19]:
#Función que devuelve el elemento n de la serie Fibonacci. Tiene un solo parámetro, el número de elemento deseado.
def fib(n):
"""Escribe la serie Fibonacci hasta el número n."""
a, b = 0, 1
for _ in range(n):
a, b = b, a+b
return a
Una vez definida la función podemos llamarla como sigue:
In [21]:
x = fib(20)
print("Nnúmero Fibonacci #20:", x)
Es importante notar que todas las variables declaradas dentro de una función son accesibles únicamente dentro de la misma función. Por ejemplo si intentamos obtener el valor de b
, obtendremos un error que indica que la variable no está definida:
In [33]:
print(b)
Esto nos permite mantener los contextos de declaración y llamada de la función separados, de tal forma de que no haya que preocuparse por el conflicto de nombres de variables en dichas situaciones. Esto se ilustra en el siguiente ejemplo, donde además se declara una función con más de una variable:
In [39]:
import math
def magnitud(x, y):
x = x**2
y = y**2
mag = x + y #Declarando variable interna mag
mag = math.sqrt(mag)
return mag
mag = 0 #Declarando variable externa mag para demostrar que es una variable completamente diferente a la interna
print ("Magnitud del vector [4, 3]:", magnitud(4, 3))
#Aquí podemos notar que las variables x e y son independientes de aquellas declaradas en la función
print ("Las variables declaradas y manipuladas fuera de la función siguen teniendo sus valores originales: mag =", mag)
Podemos también definir funciones con valores por defecto. De esta manera volvemos dichos parámetros opcionales, y cuando no se ingrese un valor específico durante una llamada, la función se ejecute con el valor indicado en lugar de echar un error.
In [26]:
def saludar(nombre, saludo="Hola"):
print("{0} {1}!".format(saludo, nombre))
saludar("Kenyi") #Llamando a la función sin el parámetro saludo hace que la función se ejecute con el valor por defecto "Hola"
saludar("Kenyi", "Buenos días") #Pero si especificamos el valor, se utiliza ese en su lugar
Cuando la función tiene más de un parámetro, es posible llamar a la función nombrando específicamente a cual parámetro va cada valor, para facilitar la lectura en caso de que hayan muchos.
In [31]:
print( magnitud(x = 12, y = 9) )
#Otro beneficio de llamar funciones de esta forma es que no es necesario llamar a los argumentos en orden
saludar(saludo = "Buenos días", nombre = "Kenyi")
In [32]:
#Pero si se especifican parámetros con nombre, todas los parámetros subsecuentes deben ser llamados con nombre también
saludar(saludo = "Buenos días", "Kenyi")
También podemos definir funciones con una cantidad variable de parámetros, de la misma forma que trabaja print()
, anteponiendo un asterisco * al último nombre de variable (es importante que sea el último), el cual será tratado como tupla dentro de la función.
In [28]:
#En este caso el parámetro sumandos contendrá todos los parámetros ingresados
def sumatoria(*sumandos):
total = 0
#sumandos es una tupla
for elemento in sumandos:
total += elemento
return total
print (sumatoria(3, 5, 6))
print (sumatoria(1, 2))
La siguiente función devuelve dos valores:
In [13]:
def cuadrado_y_cubo(n): # devuelve el cuadrado y el cubo del número ingresado
cuadrado = n**2
cubo = n**3
return cuadrado, cubo
x, y = cuadrado_y_cubo(4)
print("Cuadrado:", x, "- Cubo:", y)
Podemos también definir funciones que no devuelvan un valor (Nota: Estrictamente hablando, las funciones sin valor de retorno especificado devuelven None
).
In [14]:
def fib(n): # escribir los primeros n elementos de la serie Fibonacci
"""Escribe la serie Fibonacci hasta el número n."""
a, b = 0, 1
for _ in range(n):
a, b = b, a+b
print(a, end=' ')
print()
# Ahora podemos llamar la función que acabamos de definir:
fib(20)
#0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181
Una última nota, si se modifica un parámetro de tipo mutable dentro de una función, se modificará tambien fuera de ella. No así con las variables de tipo inmutable:
In [48]:
def mutabilidad(entero, lista):
entero += 10
lista.append("Hola")
lista.append("Mundo")
entero = 0
lista = []
mutabilidad(entero, lista)
print(entero) #Entero sigue conteniendo el valor 0, y no 10
print(lista) #Lista ahora tiene 2 nuevos elementos, añadidos dentro de la función
Adicionalmente se pueden definir funciones anónimas cortas haciendo uso de la sintaxis lambda:
lambda {variables} : {expresion de retorno}
Cuyo equivalente declarativo sería:
def funcion_anonima ( {variables} ):
return {expresion de retorno}
Por ejemplo, consideremos esta función que duplica el número ingresado:
In [61]:
#En este caso, la variable al_cuadrado contendrá una función, y podrá ser llamada posteriormente como tal
al_cuadrado = lambda x: x**2
print(al_cuadrado(4))
print(al_cuadrado(10))
Python nos ofrece tres funciones muy útiles para manejar secuencias de datos de forma concisa y legible: map()
, filter()
y reduce()
. Estas funciones forman parte del paradigma de programación funcional que python contiene. Usar estas funciones puede ser complicado al principio, pero funciones de este tipo son utilizadas ampliamente en entornos de alta paralelización, especialmente en manejos de grandes cantidades de datos.
La función map()
aplica una función a todos los elementos de una colección de datos iterables (por ejemplo listas, tuplas, sets, diccionarios), y devuelve una lista con todos los valores modificados.
map({funcion_a_aplicar}, {coleccion_iterable})
Por ejemplo si deseamos volver mayúsculas todas las cadenas de caracteres en una lista, podríamos utilizar un bucle for
:
In [56]:
semana_mayusculas = []
for i in semana:
semana_mayusculas.append(i.upper())
print(semana_mayusculas)
O podríamos utilizar map.
In [63]:
semana_mayusculas = []
semana_mayusculas = map(str.upper, semana)
print (list(semana_mayusculas))
Comúnmente también veremos map utilizado con lambdas.
In [65]:
enteros = [1, 2, 3, 4, 5]
cuadrados = map(lambda x: x**2, enteros)
print(list(cuadrados))
La función filter()
permite, como indica su nombre, filtrar elementos de una colección iterable que no cumplan con determinada condición.
filter({funcion_condicional}, {coleccion_iterable})
La funcion_condicional debe ser una función que devuelva True
, en caso el elemento cumpla con la condición, o False
, en caso contrario.
Por ejemplo, si deseamos filtrar los números positivos de una lista.
In [70]:
lista = [3, -5, -6, 1, 2, -9, 7, -2]
lista_filtrada = filter(lambda x: x <= 0, lista)
#La función lambda devuelve True si es que el número en cuestión es menor o igual a 0
print(list(lista_filtrada))
La función reduce()
es un poco más complicada, pero extremadamente útil. Reduce aplica una función a dos elementos de una colección iterable, y luego aplica la misma función al resultado del cálculo anterior con el siguiente elemento, y así sucesivamente hasta terminar.
La sintaxis es muy similar a map()
y filter()
:
reduce({funcion_a_aplicar(x, y)}, {coleccion_iterable})
Por ejemplo, la función reduce()
con una función de suma y una lista de enteros:
In [73]:
from functools import reduce
lista = [47, 11, 42, 13]
sumatoria = reduce(lambda x, y: x + y, lista)
print(sumatoria)
Internamente el proceso que está ocurriendo es como lo muestra el gráfico:
Aún falta mucho por ver en Python, por ejemplo programación orientada a objetos, librerías estándar de python, creación de librerías, manipulación de texto y archivos, reflectividad, etc.
Para referencia futura, no hay lugar con información más exhaustiva que la misma documentación de Python:
In [ ]:
#Styling del notebook
from IPython.core.display import HTML
def css_styling():
styles = open("./styles/custom.css", "r").read()
return HTML(styles)
css_styling()