Transición a Python3

Como irse quitando 💉 de Python2

David García Garzón

La transición 🐍

Por qué transición

y no migración?

2020: Acaba soporte a Python 2

Gisce no tendrá el ERP migrado en enero

Aún estaremos con Python 2 por un tiempo

Cuando se pongan las pilas, no tendremos
capacidad de seguirles el ritmo con nuestro código.
Hay que ir haciéndolo ya

Propuesta

Migrar el código para que funcione en ambos
Manual: Idiomas de compatibilidad

Se proponen diversos idiomas para cada problema
Escoger el idioma más parecido al Py3 final

Intento concretar propuestas, no escritas en piedra Unifiquemos criterios con diálogo

Importante: Cobertura previa de tests

Estrategia: Testing

Código aguantado con tests.

¿No hay tests? Montamos B2B de campaña.

Objetivo: que no dejen de pasar en Py2 mientras avanzamos con Py3.

A falta de CI, los que podamos en Travis
(tests que no usen datos personales, ni erp)

Ejemplos travis multiversion: intercoop, generation

Estrategia: conversores

2to3: Convierte a Py3, pero no portable.

Aplica una bateria de fixers (arregladores) (Lista)

Algunos son portables, los aplicaremos tal cual.

Otros no, los aplicamos a parte para detectar incompatibilidades.

Conversores portables

Parte de la libreria future

futurize: Genera Py3 backward compatible Py2

pasteurize: Genera Py2 forward compatible Py3

Aún así hay que revisar el código.

Conversion automàtica

Pasamos el 2to3 con los fixes portables

$ 2to3 -w -f raise -f except -f asserts -f paren \
    -f raw_input -f reduce -f import

Test, commit y, el resto, sin sobreescribir:

$ 2to3 -nw --add-suffix=3 src/ # Genera ficheros py3
$ futurize -nw --add-suffix=23 src/ # py23, Py3 backward compatible

Analizamos los cambios propuestos caso a caso

$ find -name \*.py3 |
  while read a; do vimdiff $a ${a%3} ${a%3}23; done

Novedades destacadas

  • Strings, unicode, bytes, entrada y salida
  • Función print
  • Iterables: (x)range, (iter)items
  • except Exception as e
  • API de algunas librerías standard
  • Otras features que no usamos directamente

Iterables

Problema

keys/iterkeys, values/itervalues, items/iteritems, range/xrange, readlines/xreadlines, map/imap, zip/izip, filter/ifilter, …

En Py2 retornan listas, la alternativa retorna generadores.

En Py3 los primeros retornan generadores, los segundos no existen.

Estrategia

2to3 convierte:

  • d.iteritems()d.items()
  • d.items()list(d.items())

Propuesta: usar items() sin list casi siempre

En Py2, listas grandes intermedias son lentas. Usar future.utils.iteritems en esos casos.

En Py3, los generadores se gastan. Si hay manipulaciones o más una sola iteración, convertir a lista o volver a llamarlo.

Ejemplo

for k,v in d.items() # Original
for k,v in list(d.items()) # 2to3
for k,v in d.items() # Nuestra propuesta

# Peligro 1: Si la lista es grande y en Py2 ralentiza
from future.builtins import iteritems
for k in iteritems(d)

# Peligro 2: Lista guardada en Py2, rompe Py3
items = d.items()
for k in (items): ....
for k in sorted(items): ... # generador ya consumido en Py3
# Solución: Llamar la función cada vez
# Si se vuelve costoso en Py2, ver Peligro 1

Ejemplo

# Peligro 3: El resultado se manipula como lista

d.items()[:2] # Funciona en Py2 pero casca en Py3
d.values() + ['additional'] # Funciona en Py2 pero casca en Py3

# Solucion, aquí si, pasar a lista

list(d.items())[:2] # las dos primeras parejas
list(d.values()) + ['additional'] # Lista explicita

Eficiencia para Py2

Solo en caso que Py2 sea muy ineficiente usaremos:

$ pip install future # includes `builtins` package for Py2
from future.utils import range
from future.utils import map
from future.utils import zip
from future.utils import filter
from future.utils import itervalues
from future.utils import iteritems
# iterkeys no se necesita, iterar sobre el dict
# xreadlines no se necesita, iterar sobre el file

Gestion excepciones ⚠️

Sintaxis obsoleta

# Sintaxis deprecada en Py2, obsoleta en Py3
try:
    ...
    raise MyException, 'Ha petado' # Obsoleto
except MyException, e: # Obsoleto
    ...

# Forma correcta en Py2 y P3
try:
    ...
    raise MyException('Ha petado') # Correcto
except MyException as exception: # Correcto
    ...

Solución

Estrategia: Dejar a 2to3 que haga lo suyo

Ejemplo de fix totalmente portable.

Funciona perfectamente en Py2
y es el definitivo para Py3

raise from

Interesante para encadenar contexto

# Python 3 only
class FileDatabase:
    def __init__(self, filename):
        try:
            self.file = open(filename)
        except IOError as exception:
            raise DatabaseError('failed to open') from exception

# Python 2 and 3:
from future.utils import raise_from

class FileDatabase:
    def __init__(self, filename):
        try:
            self.file = open(filename)
        except IOError as exception:
            raise_from(DatabaseError('failed to open'), exception)

# Testing the above:
try:
    fd = FileDatabase('non_existent_file.txt')
except Exception as e:
    assert isinstance(e.__cause__, IOError)    # FileNotFoundError on Py3.3+ inherits from IOError

Excepciones nuevas

Algunas excepciones han cambiado:

TODO

Funcion print

Comportamiento nuevo

En Py2 el print era una cláusula como el return.

Ahora es una función built-in como len().

Estrategia

# Solo una cosa: Entre parentesis.
print('Hola mundo') # Portable

# Varias cosas: Usamos format
print('Hola', name) # imprime la tupla en Py2!!
print('Hola {}'.format(name)) # Portable

# Standard error: usamos consolemsg
print sys.stderr << "La has cagado", name # Py2 only
print("Fastidiate {}".format(name)", file=sys.stderr) # Py3 only

from consolemsg import error, out # Errores en colorines
error("Fastidiate {}", name) # No hace falta format

# Tambien tenemos el substituto para stdout
out("Hola {}", name) # Funciona con unicodes y pipes

Sin fin de linea

Hemos evitado hacer from __future__ import print_function

Pero ¿y los prints acabados en coma que no hacen salto de linia?

Usar el write y, si cal, flush.

Otros

Imports relativos

En Py2 los imports eran implicitamente relativos.

En Py3 solo se puede hacer de forma explícita.

Como la forma explícita es común, usaremos esa en ambos.

import sibbiling_module # Solo funciona en Py2

from . import sibbiling_module # Funciona en ambos

Fixer portable de 2to3: import.

Libreria standard

Se han reestructurado las librerias:
itertools, collections, queue, socketserver
http(lib), url(lib). xmlrpc(lib)

Se resuelve:

Usando el import de Py3

Antes de hacer el import, poner:

from future import standard_library
standard_library.install_aliases()

División entera

Atención: Falla silenciosamente. ¡Bugs latentes!

Py2: a/b es división entera con operadores enteros

En muchos sitios, float(a)/b para evitarlo.

Py3: a/b siempre es división real

3 / 2 # Retorna 1 en Py2, 1.5 en Py3

# Si queremos entero, usamos operador explicito
3 // 2 # Retorna 1 en Py2 y Py3

# No queremos comportamiento antiguo de / asi que:
from __future__ import division
3 / 2 # Retorna 1.5 en Py2 y Py3

Bye long

El sufijo L para los long es implicito en Py2, ilegal en Py3, lo quitamos

El tipo long de Py2 es el int de Py3, si necesitas discriminarlos:

Constantes octales

Atención: oculta bugs latentes

0644 solo funciona en Py2, 0o644 en ambos.

Usar la segunda forma siempre.

Estrategia, pasar el fix numliterals de 2to3, y revisar que realmente queriamos una constante octal donde proponga cambios.

Metodos __mágicos__ 🎩

Atención: ¡Fallo silencioso!

Nuestro código no los suele redefinir.

Si lo hemos hecho, cuidado con estos:

__nonzero__ y __bool__ y

next y __next__

__unicode__ y __str__

__nonzero__

# Old Py2 only code
class MyClass:
    def __nonzero__(self): return truth value
a = MyClass()
if a: ....

# Compatible code
from builtins import object # the only added code for portability
class MyClass(object):
    def __bool__(self): return truth value
a = MyClass()
if a: ....

__next__

# Old Py2 only code
class myiterable:
    def __iter__(self): return self
    def next(self): ... # bad 
it.next() # explicit use, Py2 only

# Portable code, looking almost like Py3
from builtins import object # the only added code for portability
class customiterator(object):
    def __iter__(self): return self # same
    def __next__(self): ... # same as next
next(it) # explicit use portable

__str__ y __unicode__

Unicode HELL

Sinceramente

Unicode ya es complicado con una sola versión.

Un código que funcione en las dos… es posible.

A veces cuesta hacer portable porque no lo estamos gestionando bien en Py2 o en Py3

Aprovechemos para aclarar conceptos y establecer premisas que podamos seguir en ambas.

Dos semánticas

unicode (str en Py3): texto multilenguaje abstracto, sin ninguna codificación explícita.

bytes (str en Py2): bytes que lo representan usando una codificación concreta, ASCII, UTF-8…
o qualquier tira de bytes aleatoria que no sea texto.

¡Ojo! Aunque tienen la misma semántica, bytes en Py3 tiene una API muy diferente a str en Py2.

Cambios

strbytes

unicodestr

'text' o b'text'b'text'

u'text''text' o u'text'

basestringremoved

(str y bytes ya no comparten API en Py3)

Invariante

Los prefijos explícitos mantienen la misma semántica

No disponibles hasta 2.7.1 (b) y 3.3 (u)

type(u'') es unicode en Py2, str en Py3

type(b'') es str en Py2, bytes en Py3

type('') es str en ambos pero semanticamente diferentes

Premisas

Nosotros manejamos todo el texto como unicode.

Si nos llegan cosas como bytes: decode('utf8')
Si nos piden cosas como bytes: encode('utf8')

Todos los bytes codificados en UTF-8, por defecto.

Solo se aplica decode a bytes
Solo se aplica encode a unicodes

Py2 permite aplicarlos indistintamente
¡No lo hagamos!

Why ASCII? WHY!?

Si aplicamos encode o decode al tipo que no toca,
Py2 convierte al tipo que toca usando ASCII.
Si contiene no-ASCII, peta 🙇

u'Castaña'.decode('utf8')
# En Py3 no hay decode para unicodes
# En Py2 sería equivalente a:
u'Castaña'.encode('ascii').decode('utf8')
# Como no puede encodear a ascii peta

# Analogamente:
b'Castaña'.encode('utf8')
# En Py3 no hay encode para bytes
# En Py2 sería equivalente a
b'Castaña'.decode('ascii').encode('utf8')

Unicode Sandwitch 🍔

Bytes: Nunca se manipulan, suman, formatean… al menos que estemos hackeando hardware.

Minimizaremos o aislaremos las fuentes y destinos de bytes para no tener que hacer encodes y decodes en todos sitios

Aseguradores

Para cuando no sabemos qué nos llega
(aunque deberíamos saber qué nos llega)

def u(text):
    if type(text) == type(u''): return text
    if type(text) == type(b''): return text.decode('utf8')
    return type(u'')(text) # unicode formating

def b(text):
    if type(text) == type(b''): return text
    if type(text) == type(u''): return text.encode('utf8')
    return type(u'')(text).encode('utf8') # format and encode

En la última versión de consolemsg 😄

Código fuente

El código fuente se guarda como bytes
¿Qué codificación?

En Py3 se supone UTF8

En Py2 se supone ASCII (again!)

Por Py2, siempre en la cabecera:

# -*- encoding: utf8 -*-

Y configurar editor con encoding UTF8 defecto.

Literales

Ante la duda explicitar prefijo u siempre.

Como mínimo, si el literal:

  • contiene carácteres no ASCIII
  • o es un template para format

La comunidad desaconseja usar
un aparentemente conveniente
from __future__ import unicode_literals

Literales: No ASCII

¿Por qué explicitar u en las no ASCII?

Cuando Py2 combina unicode con otra cosa,

  • lo promociona todo a unicode. ¡Bien!
  • con un decode implicito ASCII ¡Aggh!

Si las partes no ASCII ya están en unicode, lo irá promocionando bien.

Literales: Templates

¿Por qué explicitar u en templates de format?

En Py2 el format mantiene el tipo del template.
Segun toque codifica o decodifica los argumentos usando ASCII (again!)

name1 = u'María' # prefijo por la norma de no-ascii
name2 = 'Pedro' # podriamos poner el prefijo, no se requiere

# Py2 implícitamente codifica name1 en ASCII
'Hola {} y {}'.format(name1,name2) # Peta, hay no ASCII
# Py2 implícitamente decodifica name2 de ASCII
u'Hola {} y {}'.format(name1,name2) # Funciona

Future unicode_literals

Podríamos ahorrarnos las u’s con:

from __future__ import unicode_literals

En la práctica dicen que da problemas y todo el mundo lo descaconseja.

No entiendo porqué (ver notas)

El problema de open

Cuando abrimos en modo texto:

Py2 lee y escribe bytes.
Si le pasamos unicode, codifica a ASCII implicito
Hemos de codificar explícitamente, si no va bien.

En modo texto, lee y escribe unicode
Especificamos a open el parámetro encoding
Ojo que por defecto es ascii

codecs.open

En Py2 cuando queríamos serializar unicode usabamos codecs.

Overkill en Py3, builtin open ya lo hace.

import codecs
with codecs.open(filename, 'r', encoding='utf8') as f:
    content = f.read() # siempre unicode

with codecs.open(filename, 'w', encoding='utf8') as f:
    f.write(b'Pedro') # En Py2 se lo traga
    f.write(b'María') # No se lo traga nadie
    f.write(u'María') # Se lo traga todo el mundo

Más simple io.open

Misma interfaz que el open builtin en Py3.
Cuando caiga Py2, simplemente quitar el import.

content=u'María'
from io import open
with open(filename, 'w', encoding='utf8') as f:
    f.write(content)

    # complains in Py2: must be unicode not str
    # complains in Py3: must be str not bytes
    f.write(b'María')

with open(filename, encoding='utf8') as f:
    result = f.read() # u'María'

Pipes y print

Cuando metemos nuestro script en pipes de shell el locale de stdin/stdout pasa a ser C (ASCII)

Py2: funciona en consola pero en pipe peta

La solución en todo caso es definir un environment.

PYTHONIOENCODING='utf8'

O usar exclusivamente las funciones de la última versión de consolemsg que gestionan esto ellas solas sin requerir que el usuario defina el environ.

Simplified Sandwitch

Sin Python 2

Más allà de Python 2

Nos hemos centrado en código portable Py2/Py3.

¿Qué podremos hacer cuando dejemos caer Py2?

Bye portabilidad

Dejar de usar los prefijos u''

Eliminar todos los futures y six

F-strings

Substituyen a format, similar microlenguage

Prefijo f'', usa las variables locales

Se pueden meter expresiones Python

Mucho más rápido (increiblemente)

Enums

from enums import Enum
class Colors(Enum):
    red = 1
    green = 2

print(Colors.red) # Prints "Colors.red", not 1
Colors.red.value # 1
Colors.red.name # "red"

Colors(1) # returns Colors.red by value
Colors['red'] # returns Colors.red by name

[c.name for c in Colors] # all color names

Enums (tools)

No puede haber claves duplicadas pero si valores
@enum.unique prohibe valores duplicados

EnumInt deriva de int, se puede usar como tal

Enum.auto() asigna los valores automágicamente

Podemos definir métodos en la clase

Flags

Enums combinables con & | ~ ^

class Colors(Flag): # O FlagInt
    BLACK = 0 # Cero explicito, auto empieza en 1
    RED = auto() # Potencias de 2
    GREEN = auto()
    BLUE = auto()
    YELLOW = RED | GREEN # Combinacion nombrada
    CYAN = GREEN | BLUE
    MAGENTA = RED | BLUE
    WHITE = RED | GREEN | BLUE

Colors.RED | Colors.BLUE #  Colors.MAGENTA
Colors.WHITE & ~Colors.BLUE #  Colors.YELLOW
bool(Colors.CYAN & Colors.RED) # False
bool(Colors.CYAN & Colors.BLUE) # True

Pathlib

Bye os.path.join and co

from pathlib import Path

base = Path('/usr/local')
binpath = base / 'bin'  # overloaded operator /
binpath.parts           # ('/', 'usr', 'local', 'bin')

Pathlib: Pureness

PurePath: ops que no acceden al sistema
Path: le añade ops que acceden al sistema

En Linux:

[Pure]Path es un alias de Posix[Pure]Path

Se puede usar WindowsPurePath
pero no WindowsPath

Pathlib: pure ops

workingDir = Path('.') # Also Path.cwd()
home = Path.home() # '/home/vokimon' in my linux
p = Path('/tmp/vokimon/myzip.tar.gz')
p.parent # '/tmp/vokimon'
p.name # 'myzip.tar.gz'
p.stem # 'myzip.tar'
p.suffix # '.gz'
p.suffixes # ['.tar', '.gz']
p.relative_to('/tmp') # 'vokimon/myzip.tar.gz'
p.with_suffix('.bz2') # '/tmp/vokimon/myzip.tar.bz2'
p.with_name('foo.zip') # '/tmp/vokimon/foo.zip'
p.anchor # p.drive + p.root
str(p) # returns the path string
bytes(p) # uses os.fsencode()
p.as_uri() # file://....
p.as_posix() # forwards back slashes
p.is_absolute() # true
p.is_reserved() # pe. true for 'null' in windows
p.match("*.gz") # true

Pathlib: system ops

Path("/home/vokimon/../sammy/").resolve() # /home/sammy
Path('~/.config').expanduser() # /home/vokimon/.config
subdirs = [ x for x in workingDir.iterdir() if x.is_dir() ]
p.exists(),  p.is_file(), p.is_dir(), p.is_link(), p.is_socket()
p.is_fifo(), p.is_block_device(), p.is_char_device()
p.glob("**/*.py") # globbing recursivo
with p.open(encoding='utf8') as f: lines = f.readlines()
content = p.read_text(encoding='utf8') # Or read_bytes
p.write_text(content, encoding='utf8') # Or write_bytes(bcontent)
p.owner
p.group
p.stat().st_size # st_mtime, st_size...
p.mkdir(parents=True, exists_ok=True) # mkdir -p
p.chmod(0o777)
p.rmdir() # must be empty
p.symlink_to(target) # ln -s
p.(target) # mv
p.rename(target) # mv? (behaves different Win/Posix)
p.replace(target) # mv? (portable)
p.touch() # touch
p.unlink() # rm
p.samefile(other) # Points to same inode

Anotaciones

a: int = 3 # hinting variables
# Hinting functions
def myfunction(arg1: ann1, arg2: ann2 = 'default') -> annr :
    ...

Static type check

Python solo lo guarda en myfunction.__annotations__ y prou.

Static type checking ( mypy, pytype )

Editores para dar pistas inteligentes.

Para documentar, implementar sobrecarga

Runtime type check

pytypes
define decorators @typechecked, @override
funciones para activarlos a nivel de modulo o global

enforce.py
el decorator es @runtime_validation

pydantic
Named tuples by class (ya lo hace Python 3.8)

Typing

Anotaciones con tipos genéricos

from typing import TypeVar, Sequence
X = TypeVar('X')
def choose(elements: Sequence[X], n: int) -> X: 
    ...

DuckTypes (como los Concepts de C++)

Any, Union, List, Tuple, Callable, Type, TypeVar, Optional

Star unpacking

Facilita el uso de desempaquetado con tamaños variables.

>>> a, *b, c = [1,2,3,4]`
>>> b
[2,3]

Control parámetros

def f(a, /, b, *, c, **kwd):
    ...
  • a solo posicional
  • / indica anteriores solo posicionales
  • b posicional o clave
  • * indica siguientes solo clave
  • c solo clave

Nuevos operadores

  • Walrus operator := (asignación/expresión) (2.8)
  • Starship operator <=> (comparacion 1,0,-1) (2.9)
  • Matrix Mult operator: @, __matmult__

Añadidos a la stdlib

  • functools.lru_cache
    • decorator cachea resultados
  • ipaddress
    • interpreta ip str, operaciones…
  • faulthandler:
    • stack trace en petes duros (segfault)

…y más

  • asyncio and @coroutine
  • Excepciones encadenadas
    • raise excep from oldexcept
  • yield from generator()
    • shortcut for x in generator(): yield x
  • nonlocal en las closures para modificar
    variables de la funcion contenedora
  • acentos en las variables (don’t!)