Preservando a ordem de sequências ao remover duplicatas

Imagine que você tenha uma lista com as URLs extraídas de uma página web e queira eliminar as duplicatas da mesma.

Transformar a lista em um conjunto (set) talvez seja a forma mais comum de se fazer isso. Tipo assim:

>>> urls = [
    'http://api.example.com/b',
    'http://api.example.com/a',
    'http://api.example.com/c',
    'http://api.example.com/b'
]
>>> set(urls)
{'http://api.example.com/a',
 'http://api.example.com/b',
 'http://api.example.com/c'}

Porém, observe que perdemos a ordem original dos elementos. Esse é um efeito colateral indesejado da eliminação de duplicatas através da transformação em um conjunto.

Um jeito de preservar a ordem dos elementos após a remoção das duplicatas é utilizar este macete com collections.OrderedDict:

>>> from colllections import OrderedDict
>>> OrderedDict.fromkeys(urls).keys()
['http://api.example.com/b',
 'http://api.example.com/a',
 'http://api.example.com/c']

Legal né? Agora vamos entender o que o código acima fez.

Antes de mais nada, é preciso saber que OrderedDict é uma estrutura de dados muito similar a um dicionário. A grande diferença é que o OrderedDict guarda internamente a ordem de inserção dos elementos. Assim, quando iteramos sobre um objeto desse tipo, ele irá retornar seus elementos na ordem em que foram inseridos.

Agora vamos quebrar as operações em partes para entender melhor o que aconteceu:

>>> odict = OrderedDict.fromkeys(urls)

O método fromkeys() cria um dicionário usando como chaves os valores passados como primeiro parâmetro e como valor o que for passado como segundo parâmetro (ou None, caso não passemos nada).

Como resultado, temos:

>>> odict
OrderedDict([('http://api.example.com/b', None),
('http://api.example.com/a', None),
('http://api.example.com/c', None)])

Agora que temos um dicionário com as chaves mantidas em ordem de inserção, podemos chamar o método keys() para obter somente as chaves que, neste caso, são as nossas URLs:

>>> odict.keys()
['http://api.example.com/b',
'http://api.example.com/a',
'http://api.example.com/c']

Obs.: em Python 3, o método keys() retorna uma view ao invés de uma lista. Esse tipo de objeto suporta iteração e teste de pertinência, assim como a lista. Caso você realmente precise de uma lista, basta construir uma usando o resultado de keys():

>>> list(odict.keys())

A eliminação de duplicatas é só uma consequência do design de dicionários, já que a unicidade das chaves é uma das propriedades fundamentais dessa estrutura de dados.

Caso queira entender melhor os princípios por trás de um dicionário, leia sobre tabelas hash: https://www.ime.usp.br/~pf/estruturas-de-dados/aulas/st-hash.html

Esquisitices (ou não) no arredondamento em Python 3

O arredondamento de números em Python 3 pode ser feito usando uma função builtin chamada round(). Em sua forma mais simples, ela recebe um número qualquer e o arredonda para um número inteiro. Veja ela em ação:

>>> round(1.2)
1
>>> round(1.8)
2

Ela ainda aceita um segundo parâmetro, que indica quantos dígitos de precisão queremos no resultado. O valor padrão desse parâmetro é 0, mas podemos passar qualquer valor:

>>> round(1.847, ndigits=2)
1.85
>>> round(1.847, ndigits=1)
1.8

E o que será que acontece quando queremos arredondar um número como 1.5? A round() arredonda pra cima ou pra baixo? Vamos ver:

>>> round(1.5)
2

Arredonda pra cima? Vamos ver mais um número só pra ter certeza:

>>> round(2.5)
2

Ops, agora foi pra baixo! Vamos ver mais alguns:

>>> round(3.5)
4
>>> round(4.5)
4
>>> round(5.5)
6

Calma aí que tudo tem uma explicação. Em Python 3, a round() define o arredondamento assim:

Arredonda pro mais próximo.
Se empatar, arredonda pro número PAR mais próximo.

Agora faz sentido né? Revendo os exemplos dali de cima, vemos que o arredondamento sempre foi feito pro par mais próximo:

>>> round(3.5)
4
>>> round(4.5)
4
>>> round(5.5)
6

No caso do 3.5, tanto o 3 quanto o 4 estão à mesma distância dele. Ou seja, deu empate. Como a regra determina, round() desempata pro par mais próximo, que é 4. 🙂

E em Python 2?

Em Python 2 é diferente. O empate em arredondamentos é sempre resolvido pra cima, em caso de números positivos:

>>> round(1.5)
2.0
>>> round(2.5)
3.0

E pra baixo, em caso de números negativos:

>>> round(-1.5)
-2.0
>>> round(-2.5)
-3.0

Mas por que Python 3 faz diferente?

O objetivo é deixar o arredondamento menos tendencioso.

Imagine um banco onde todos os arredondamentos são feitos para cima. Ao final do dia, o relatório de receitas do banco vai mostrar um valor mais alto do que o banco realmente recebeu. É exatamente o que acontece em Python 2:

>>> valores = [1.5, 2.5, 3.5, 4.5]
>>> sum(valores)
12.0
>>> sum(round(v) for v in valores)
14.0

Usando a regra de arredondamento de Python 3, os valores arredondados nas operações tendem a ser amortizados, porque metade deles vai ser para cima e a outra metade para baixo, visto que metade dos números existente são pares e a outra metade são ímpares. Veja o mesmo código, agora rodando em Python 3:

>>> valores = [1.5, 2.5, 3.5, 4.5]
>>> sum(valores)
12.0
>>> sum(round(v) for v in valores)
12

Na realidade, isso não é uma novidade de Python 3. Esse tipo de arredondamento é antigo e tem até nome: Bankers Rounding (Arredondamento de Banqueiros).

Se ficou interessado no assunto, dê uma olhada num outro post daqui do blog, que mostra como funciona a divisão inteira em Python: https://pythonhelp.wordpress.com/2013/06/30/comportamento-inesperado-na-divisao-inteira/

Gerando amostras aleatórias com Reservoir Sampling em Python

Alô pessoal!

Hoje começamos um experimento com material para o Python Help no formato screencast!

Nesse primeiro vídeo a gente mostra a implementação de um algoritmo bem legal chamado Reservoir Sampling, que serve para obter amostras aleatórias de uma sequência de elementos de tamanho desconhecido.

Assista e conte-nos o que achou! 🙂

UPDATE: Fábio Utzig apontou uma otimização na nossa implementação para evitar alocações de memória desnecessárias no CPython, ficaria melhor evitar chamar append() e inicializar a lista sample com:

sample = [None] * sample_size

Valeu, Fábio, boa!

Web scraping em páginas baseadas em JavaScript com Scrapy

O Scrapy é um framework todo prontinho pra lidar com a maioria dos problemas que enfrentamos ao fazer web scraping. Porém, é um tanto comum termos que extrair dados de páginas cuja parte do conteúdo seja gerada por código JavaScript, que é tipicamente executado no nosso navegador. Aí é que tá problema, pois o Scrapy não executa os scripts presentes no HTML. Tudo o que ele faz é baixar o HTML exatamente da forma que o servidor entrega.

Este post vai fazer um apanhado geral sobre algumas formas de lidar com páginas baseadas em JavaScript. Vamos começar vendo como saber se determinada informação que queremos extrair é gerada por código JavaScript ou não.

Como identificar uma página baseada em JavaScript

Um jeito simples de descobrir se as informações que queremos extrair são gerada por código JS é usando o nosso navegador. Carregue a página e então utilize a opção “Visualizar código-fonte”. Se o conteúdo que você quer extrair estiver ali representado em HTML, você pode ficar tranquilo, pois você poderá extraí-lo tranquilamente usando somente o Scrapy.

Nota: não utilize a opção “Inspecionar Elemento” (ferramentas do desenvolvedor) neste caso. Embora ela seja uma mão na roda pra descobrirmos como os dados estão estruturados, o problema é que ela já mostra o fonte da página após ter sido renderizada pelo browser (incluindo conteúdo gerado dinamicamente por código JS).

Por exemplo, abra http://quotes.toscrape.com. Esta página mostra frases de autores famosos e, se você abrir o código-fonte da página, verá que cada frase está representada por um bloco div.quote.

Entretanto, se você carregar a variação gerada por JavaScript → http://quotes.toscrape.com/js, você irá perceber que as frases não estão lá bonitinhas dentro do HTML. Na realidade, elas estão entranhadas em um trecho de código JavaScript presente na página, que é executado pelo motor JavaScript do navegador.

Outra opção é usar uma extensão pro navegador que permita desabilitar o JavaScript em uma aba, como o Quick JavaScript Switcher, e então verificar se os dados que queremos extrair estão na página mesmo após desabilitamos o JavaScript.

Bom, uma vez que identificamos que o conteúdo da página é gerado por código JavaScript, o próximo passo é usar alguma das soluções a seguir para que possamos lidar com páginas baseadas em JS usando o Scrapy.

1. Usando um navegador headless

Usando o Scrapy, podemos terceirizar a tarefa de renderizar a página completa para um navegador web. Assim, ao invés de utilizarmos o HTML baixado pelo Scrapy, vamos fazer com que um navegador baixe a página e execute o código JS pra gente e entregue como resposta o HTML prontinho. Uma opção legal para isso é o PhantomJS, que é um navegador headless e que pode ser facilmente integrado com Python via Selenium.

import scrapy
from selenium import webdriver

class QuotesSeleniumSpider(scrapy.Spider):
    name = 'quotes-selenium'
    start_urls = ['http://quotes.toscrape.com/js']

    def __init__(self, *args, **kwargs):
        self.driver = webdriver.PhantomJS()
        super(QuotesSeleniumSpider, self).__init__(*args, **kwargs)

    def parse(self, response):
        self.driver.get(response.url)
        sel = scrapy.Selector(text=self.driver.page_source)
        for quote in sel.css('div.quote'):
            yield {
                'text': quote.css('span.text::text').extract_first(),
                'author': quote.css('small.author::text').extract_first(),
                'tags': quote.css('a.tag::text').extract()
            }

        next_page_url = response.css("li.next > a::attr(href)").extract_first()
        if next_page_url is not None:
            yield scrapy.Request(response.urljoin(next_page_url))

Nota: O spider acima requer o módulo Python para o Selenium, que pode ser instalado via pip install selenium. Também é necessária a instalação do binário do PhantomJS em algum lugar do seu PATH.

O spider acima instancia o driver selenium pro PhantomJS e, no método parse(), faz com que o PhantomJS baixe e renderize a página http://quotes.toscrape.com/js. O HTML renderizado (self.driver.page_source) é então usado para construir um objeto Selector que nos permite extrair dados usando os tradicionais seletores do Scrapy.

Contudo, quando executarmos o código acima, cada página será baixada duas vezes: uma pelo downloader do Scrapy e outra pelo método parse(), na chamada à self.driver.get(). Para evitar esse comportamento, podemos criar um downloader middleware que utilize o selenium para baixar a página ao invés do downloader do Scrapy. Você pode ver o código do middleware clicando aqui e um spider que utiliza tal middleware aqui.

2. Extraindo dados de dentro do código JavaScript

O código fonte da página http://quotes.toscrape.com/js nos mostra que as frases que são renderizadas pelo navegador estão dentro de um arrayzão JavaScript chamado data:

var data = [
{
  "author": {
    "goodreads_link": "/author/show/9810.Albert_Einstein",
    "name": "Albert Einstein",
    "slug": "Albert-Einstein"
  },
  "tags": ["Change", "deep-thoughts", "thinking", "world"],
  "text": "\u201cThe world as we have created it is a process of our thinking. It cannot be changed without changing our thinking.\u201d"
},
{
  "author": {
    "goodreads_link": "/author/show/1077326.J_K_Rowling",
    "name": "J.K. Rowling",
    "slug": "J-K-Rowling"
  },
  "tags": ["abilities","choices"],
  "text": "\u201cIt is our choices, Harry, that show what we truly are, far more than our abilities.\u201d"
},
...

Os dados que queremos extrair já estão prontinhos dentro da página, então podemos extraí-los sem usar um navegador headless. Como os seletores do Scrapy apenas lidam com HTML/XML, vamos usar a lib js2xml para converter o array JavaScript acima para XML e então extrair os dados usando seletores Scrapy.

import scrapy
import js2xml

class QuotesJs2XmlSpider(scrapy.Spider):
    name = 'quotes-js2xml'
    start_urls = ['http://quotes.toscrape.com/js/']

    def parse(self, response):
        script = response.xpath('//script[contains(., "var data =")]/text()').extract_first()
        script_as_xml = js2xml.parse(script)
        sel = scrapy.Selector(_root=script_as_xml)
        for quote in sel.xpath('//var[@name="data"]/array/object'):
            yield {
                'text': quote.xpath('string(./property[@name="text"])').extract_first(),
                'author': quote.xpath(
                    'string(./property[@name="author"]//property[@name="name"])'
                ).extract_first(),
                'tags': quote.xpath('./property[@name="tags"]//string/text()').extract(),
            }

        next_page_url = response.css("li.next > a::attr(href)").extract_first()
        if next_page_url is not None:
            yield scrapy.Request(response.urljoin(next_page_url))

No método parse() do spider acima primeiramente obtemos o código JS contido no elemento (linha 10) e então utilizamos o js2xml para convertê-lo para JavaScript. Depois disso, bastou construir um seletor Scrapy sobre tal valor e extrair os dados usando XPath (ou CSS).

Além de ter menos dependências, esta solução tem um desempenho melhor pois não depende do carregamento de um navegador (mesmo que seja headless como o PhantomJS) durante a execução.

3. Imitando o comportamento do navegador

A web está cheia de sites com conteúdo dinâmico carregado de acordo com ações do usuário. Por exemplo, o usuário de um e-commerce rola a página até o final e novos produtos são carregados automaticamente (a.k.a. rolagem infinita – infinite scrolling). Ou então o usuário clica em um botão que faz com que mais informações sejam mostradas sem recarregar a página.

Nesses casos, o que tipicamente acontece é que o navegador faz requisições AJAX que retornam mais informações quando determinado evento do usuário é disparado (scroll, click, etc).

Extrair dados de páginas desse tipo pode ser bem mais fácil do que a gente imagina. Ao invés de usar Selenium + PhantomJS, basta inspecionar as requisições AJAX que o navegador faz e imitá-las no nosso spider.

Considere a seguinte página: http://quotes.toscrape.com/scroll. A cada vez que rolamos ela até o fim, uma nova requisição é feita para a obtenção de novas frases que são então renderizadas pelo navegador. Para verificar quais requisições são feitas e para quais recursos, podemos usar o painel “Rede” das “Ferramentas do desenvolvedor” do nosso navegador favorito, como mostro abaixo no Chrome:

image00

No nosso exemplo, os novos resultados são obtidos por requisições para http://quotes.toscrape.com/api/quotes?page= e a resposta para as requisições vem prontinha em formato JSON. Ou seja, nem precisaremos usar XPath ou CSS para extrair os dados:

import json
import scrapy


class QuotesAjaxSpider(scrapy.Spider):
    name = 'quotes-ajax'
    base_url = 'http://quotes.toscrape.com/api/quotes?page=%d'
    start_urls = [base_url % 1]

    def parse(self, response):
        json_data = json.loads(response.text)
        for quote in json_data['quotes']:
            yield quote
        if json_data['has_next']:
            next_page = self.base_url % (int(json_data['page']) + 1)
            yield scrapy.Request(url=next_page, callback=self.parse)

O spider acima imita as requisições AJAX feitas pelo navegador e obtém os dados de todas as frases do site.

Em geral, você pode fazer o mesmo para a maioria das páginas que usam AJAX. Algumas podem ser mais complicadinhas, requerendo que você envie alguns dados pré-computados (como hashes) como parâmetro, mas nada que uma boa investigada na página não resolva.

Por fim

Espero que este post tenha ajudado a desmistificar um pouco o scraping de páginas baseadas em JavaScript. Como você pôde ver, nem sempre precisamos recorrer a uma ferramenta externa para renderizar o JS para a gente. Basta entender melhor como o protocolo HTTP e o navegador funcionam para que possamos lidar com esse tipo de problema.

Em alguns casos mais complexos (e também mais raros), você precisará simular a interação do usuário com a página. Para isso, você pode usar os métodos do selenium webdriver ou então simular tal comportamento usando um script Lua com o Splash.

O código apresentado neste post está disponível em: https://github.com/stummjr/pythonhelp-scrapy-javascript/

Usando comando with para evitar acoplamento temporal

Hoje vamos falar sobre como melhorar um padrão que você provavelmente já viu em muito código:

    f = open(...)
    ....
    f.close()

 

    a = A(...)
    a.start(...)
    ...
    a.end()

 

    c = Coisa(...)
    c.cria(...)
    ....
    c.destroi()

 

    test = Test()
    test.setUp()
    test.run()
    test.tearDown()

Os trechos de código acima possuem em comum o que chamamos acoplamento temporal (do inglês, temporal coupling).

Acoplamento mede o quanto uma coisa depende de outra em software, e é comumente medida entre módulos ou componentes – tipo, o quanto certas classes ou funções dependem uma da outra. Em geral, é desejável que haja poucas interdependências, para evitar que a complexidade se espalhe em um projeto.

O acoplamento temporal acontece quando uma coisa precisa ser feita depois de outra, mesmo que seja dentro do mesmo módulo ou função. A relação de interdependência é com o tempo, isto é, o momento em que as coisas precisam acontecer. Alguns exemplos seriam: fechar um arquivo depois de terminar de carregar o conteúdo, liberar memória quando acabar de usar, etc.

Reduzindo o acoplamento temporal

Desde a versão 2.5, Python possui o comando with para lidar exatamente com este tipo de situação. Com ele, podemos fazer:

    with open(...) as f:
        dados = f.read()
    # processa dados aqui

Ao usar o bloco with para abrir um arquivo, o método close() é chamado por trás dos panos pelo gerenciador de contexto incondicionalmente (isto é, mesmo que ocorra alguma exceção no código de dentro do bloco).

O código equivalente seria:

    f = open(...)
    try:
        dados = f.read()
    finally;
        f.close()

Repare como o primeiro código é mais curto e mais simples que o segundo, pois há menos interdependências entre as coisas que estão acontecendo.

A ideia é não precisar lembrar de ter que escrever try/finally e chamar o close(), utilizando por trás dos panos um código que se certifique de que as coisas aconteçam na ordem esperada. A este código, chamamos de gerenciadores de contexto, ou context managers.

Implementando um gerenciador de contexto

A maneira mais simples de implementar um gerenciador de contexto Python é utilizar o decorator contextlib.contextmanager. Vejamos um exemplo:

import os
import contextlib

@contextlib.contextmanager
def roda_em_dir(dir):
    orig_dir = os.getcwd()
    os.chdir(dir)
    try:
        yield
    finally:
        os.chdir(orig_dir)

Esse gerenciador de contexto nos permite rodar um código em um diretório qualquer, e ao fim dele voltar para o diretório que estávamos antes.

A função os.getcwd() devolve o diretório atual e a função os.chdir() entra no diretório passado como argumento, e depois os.chdir() é usada novamente para voltar para o diretório original.

Veja como usá-lo:

print('Comecei no diretorio: %s' % os.getcwd())

with roda_em_dir('/etc'):
    print('Agora estou no diretorio: %s' % os.getcwd())

print('E agora, de volta no diretorio: %s' % os.getcwd())

Rodando esse código na minha máquina, a saída é:

Comecei no diretorio: /home/elias
Agora estou no diretorio: /etc
E agora, de volta no diretorio: /home/elias

A função roda_em_dir() é o que chamamos de uma corotina, pois utiliza o comando yield para “pausar” sua execução, entregando-a para o código que está dirigindo-a. Neste caso, isso é o trabalho do decorator contextlib.contextmanager, que entrega a execução para o código que está dentro do bloco with até que este termine, o que devolverá a execução para a corotina roda_em_dir(), que irá executar o código dentro do finally.

Não se preocupe se for um pouco difícil de entender como a coisa toda funciona, estamos passando por cima de alguns tópicos avançados aqui (decorators, corotinas, etc). O importante é que você se dê conta de que pode implementar um gerenciador de contexto rapidamente usando o contextlib.contextmanager com uma função que faça yield dentro de um bloco try/finally.

Vejamos um outro exemplo, desta vez vamos fazer um gerenciador de contexto que cria um arquivo temporário para utilizarmos em um código de teste, e deleta o arquivo automaticamente ao fim do bloco with:

import os
import contextlib
import tempfile

@contextlib.contextmanager
def arquivo_temp():
    _, arq = tempfile.mkstemp()
    try:
        yield arq
    finally:
        os.remove(arq)

Repare como desta vez, o comando yield não está mais sozinho, desta vez ele está enviando a variável arq que contém o nome do arquivo temporário para ser usado no with, como segue:

with arquivo_temp() as arq:
    print('Usando arquivo temporario %s' % arq)
    print('Arquivo existe? %s' % os.path.exists(arq))

print('E agora, arquivo existe? %s' % os.path.exists(arq))

Rodando na minha máquina, a saída ficou:

Usando arquivo temporario /tmp/tmp2IUF7H
Arquivo existe? True
E agora, arquivo existe? False

Note como a variável arq pode ser usada depois do with também: isto mostra que o contexto está sendo gerenciado de maneira especial, mas o espaço de nomes de variáveis ainda é o mesmo (ou seja, o comando with é mais parecido com if e for, do que com o comando def, por exemplo).

Para concluir

Bem, apesar do mecanismo por trás ser um pouquinho complicado de entender inicialmente, você pode perceber que implementar um gerenciador de contexto não é muito difícil. Você precisa usar o decorator contextlib.contextmanager em uma função geradora fazendo yield dentro de um bloco try/finally – moleza!

Você também pode implementar um gerenciador de contexto escrevendo uma classe que implemente o protocolo do comando with, que envolve basicamente implementar dois métodos especiais chamados __enter__ e __exit__ que sinalizam respectivamente entrar e sair do bloco with.

Em geral é mais conveniente utilizar o @contextlib.contextmanager, mas em alguns casos é melhor implementar os métodos. Por exemplo, caso queira compartilhar o próprio objeto gerenciador do contexto dentro do with, você pode usar return self no método __enter__.

Agora, vá refatorar aqueles códigos com acoplamento temporal e se divirta!

Customizando o prompt do IPython 5+

Há poucos meses atrás foi lançada a versão 5 do IPython. O shell que antes já era super legal, agora ficou mais interessante ainda, com syntax highlighting no código digitado e um esquema de completação inline usando umas listinhas bem convenientes:

screen-shot-2016-09-18-at-8-41-50-am

Porém, a forma de configuração do prompt mudou. Algumas das dicas apresentadas no post Sintonia Fina no IPython não se aplicam mais. Assim, vou mostrar aqui como fazer para que o seu IPython 5+ fique com aparência semelhante à abaixo:

screen-shot-2016-09-18-at-8-40-12-am

O que mudou basicamente é que ao invés de simplesmente definir a string que será usada como prompt em um atributo, agora é preciso implementar uma subclasse de Prompts, com alguns métodos para alterar o comportamento do seu prompt:

from IPython.terminal.prompts import Prompts, Token


class MyCustomPrompt(Prompts):

    def in_prompt_tokens(self, cli=None):
        return [(Token.Prompt, '>>> '), ]

    def out_prompt_tokens(self, cli=None):
        return [(Token.Prompt, ''), ]

    def continuation_prompt_tokens(self, cli=None, width=None):
        return [(Token.Prompt, ''), ]

No exemplo acima, customizei os prompts de entrada (de In [1]: para o tradicional >>>), de saída ( de Out[1]: para nada) e de continuação (de ...: para nada).

O legal dessa nova abordagem é que agora temos mais flexibilidade pra customizar os prompts, colocando valores dinâmicos dentro deles se quisermos.

Como configurar seu prompt

A configuração do IPython se dá através de perfis, então vamos começar criando o perfil default:

$ ipython profile create

Isso irá criar um diretório ~/.ipython com a seguinte estrutura:

.ipython
├── extensions
├── nbextensions
└── profile_default
    ├── ipython_config.py
    ├── ipython_kernel_config.py
    ├── log
    ├── pid
    ├── security
    └── startup
        └── README

Agora salve a classe MyCustomPrompt em um arquivo ~/.ipython/my_custom_prompt.py e depois disso edite o arquivo ~/.ipython/profile_default/ipython_config.py para que tenha o seguinte conteúdo:

from my_custom_prompt import MyCustomPrompt


c = get_config()

c.TerminalInteractiveShell.prompts_class = MyCustomPrompt
c.TerminalIPythonApp.display_banner = False
c.TerminalInteractiveShell.separate_in = ''

Pronto, agora o seu IPython 5+ deverá se parecer com aquele que mostrei no screenshot lá no começo do post.

Se você estiver usando uma versão anterior do IPython, verifique meu post anterior sobre o mesmo assunto: https://pythonhelp.wordpress.com/2013/12/29/sintonia-fina-do-ipython/

Confira as minhas configurações do IPython em: https://github.com/stummjr/dotfiles/tree/master/ipython

O estranho caso do else em loops

Uma das coisas que me chamou a atenção quando comecei a usar Python é o else. O seu uso “natural”, para definir um caminho alternativo para um if, não tem nada demais. O que é um pouco estranho é o fato de Python aceitar else em expressões de loop como for e while. Por exemplo, o código abaixo é perfeitamente válido:

# -*- coding:utf-8 -*-
import random
segredo = random.randint(0, 10)
for tentativa in range(1, 4):
    numero = input('Digite um número entre 0 e 9 (tentativa %d de 3):' % tentativa)
    if numero == segredo:
        print('você acertou!')
        break
else:
    print('você esgotou as suas tentativas')

Perceba que o else está alinhado com o for e não com o if. Neste caso, os comandos contidos nele somente serão executados se o loop não tiver sido encerrado por um break. O mesmo vale para o else em loops while. No exemplo abaixo, o else nunca será executado pois o loop é sempre encerrado por um break:

while True:
    break
else:
    print('nunca serei!')

Confesso que sempre tive uma certa dificuldade para lembrar o significado do else nesses casos, até porque raramente me deparo com eles. Então, certo dia assisti a palestra Transforming Code into Beautiful, Idiomatic Python do Raymond Hettinger, em que ele diz em um certo momento:

O else de loops em Python deveria ser chamado ‘nobreak’.

Pronto, nunca mais esqueci o significado do else em loops! 🙂

Tente outra vez

Imagine que você está escrevendo um código que dependa de recursos externos e que podem falhar caso um servidor não esteja disponível (tipo, baixar uma página ou acionar uma API) ou caso algum recurso (pendrive, drive de DVD, etc) ainda não esteja pronto para operar. Digamos que você queira fazer isso funcionar de maneira robusta, tentando de novo quando uma operação falhar.

Há um tempo atrás mostramos aqui no blog um pequeno exemplo disso, explicando como baixar uma URL com tolerância a falhas simples em Python só com a biblioteca padrão. Acontece que tem um jeito melhor de fazer esse tipo de coisa, que resulta em código limpo e fácil de mudar: usar a biblioteca retrying.

Para começar, instale o pacote retrying com:

pip install retrying

Agora, para usar no seu código, importe o decorator retry (aprenda aqui sobre decorators) e use-o para alguma função que você deseja tentar de novo quando falhar (isto é, quando levantar alguma exceção):

from retrying import retry

@retry
def faz_algo_nao_confiavel():
    print("Tentando...")
    import random
    if random.randint(0, 10) > 1:
        raise RuntimeError("Putz, deu zica!")
    else:
        return "Funcionou!"

A função acima só funciona sem levantar exceção 20% das vezes que é chamada. Colocando o decorator, ela fica envolta em uma lógica que tenta de novo até que funcione sem a exceção. Rodando algumas vezes no meu laptop, a saída fica:

>>> faz_algo_nao_confiavel()
Tentando...
'Funcionou!'
>>> faz_algo_nao_confiavel()
Tentando...
Tentando...
Tentando...
Tentando...
Tentando...
Tentando...
Tentando...
'Funcionou!'
>>> faz_algo_nao_confiavel()
Tentando...
Tentando...
'Funcionou!'

Esse é apenas o jeito mais simples de usar. Em problemas do mundo real, você provavelmente vai querer configurar um intervalo de espera entre cada tentativa e também um limite máximo:

@retry(stop_max_attempt_number=7, wait_fixed=2000)
def busca_algo_na_rede():
    ....

A função acima será tentada no máximo 7 vezes, esperando 2 segundos (2000 ms) entre cada tentativa.

Outra opção interessante é o backoff exponencial, útil quando você quer ajustar a taxa de tentativas para não exigir demais de um sistema remoto que pode estar tendo dificuldades de sobrecarga. Veja um exemplo:

@retry(wait_exponential_multiplier=500, wait_exponential_max=30000)
def aciona_outro_sistema():
    ...

Nesse exemplo, caso a função falhar, será tentada novamente com um intervalo de espera calculado usando uma fórmula exponencial, tentando a primeira vez após 1s, na segunda 2s, na próxima 4s, 8s, 16s até o limite de 30s e então seguirá tentando com esse limite.

Quando não é adequado usar @retry?

Essa ideia de tentar de novo só funciona bem para operações que sejam idempotentes, isto é, que você pode acionar várias vezes sem alterar o resultado.

Por isso, quando quiser adicionar @retry em uma função existente, tenha cuidado para que seja uma função segura de ser chamada mais de uma vez. Em alguns casos, você terá que criar uma nova função com a parte que pode falhar e que funcione dependendo apenas dos próprios parâmetros (tornando-a assim idempotente) e colocar o @retry nessa função.

Funções anônimas em Python

As funções anônimas — em Python também chamadas de expressões lambda — representam um recurso bem interessante da linguagem Python, mas cuja utilidade pode não ser muito óbvia à primeira vista.

Uma função anônima é útil principalmente nos casos em que precisamos de uma função para ser passada como parâmetro para outra função, e que não será mais necessária após isso, como se fosse “descartável”.

Um exemplo

A builtin map recebe como parâmetros: uma função e uma sequência de dados nos quais a função será aplicada. Agora, imagine que temos uma lista com os números entre 1 e 100 e precisamos de outra lista que contenha os dobros dos números de 1 a 100.

numeros = list(range(1, 101))  # compatível com python 3

Para obter a lista de dobros, poderíamos chamar a função map, passando a ela uma função que retorna o dobro do elemento recebido como parâmetro. Sem funções anônimas, faríamos assim:

def dobro(x):
    return x*2

dobrados = map(dobro, numeros)

Assim, a função dobro será aplicada para cada elemento de numeros e o resultado de cada chamada será adicionado à lista dobrados. Porém, a função dobro será usada somente aqui nessa parte do programa, e o desenvolvedor pode achar que sua presença ali polui o código de forma desnecessária. Como não irá utilizá-la mais em lugar algum, essa função pode ser transformada em uma função anônima, usando a expressão lambda:

dobrados = map(lambda x: x * 2, numeros)

A expressão lambda x: x * 2 cria uma função anônima que recebe um valor como entrada e retorna como resultado tal valor multiplicado por 2. Esse tipo de função é assim chamada porque não podemos nos referir a ela através de um nome, diferentemente da função dobro, por exemplo. As funções anônimas que, em Python, são obtidas através das expressões lambda, são bastante limitadas e devem ser utilizadas com cautela, pois o seu abuso pode comprometer a legibilidade do código. Veja alguns exemplos de abuso das expressões lambda: http://wiki.python.org/moin/DubiousPython#Overuse_of_lambda.

Caso você ainda não tenha compreendido o tal do anonimato da função, costumo pensar em um exemplo que usamos com frequência onde também existe anonimato. Você já passou uma lista literal para uma função, como faço no exemplo a seguir?

soma = sum( [1, 1, 2, 3, 5, 8, 13, 21] )

Você concorda que a lista passada como argumento é também um objeto anônimo? A ideia é semelhante a da função anônima, pois passamos objetos “descartáveis”, como no trecho acima, quando sabemos que não vamos precisar daquele objeto em outros trechos do código.

Para aprender mais sobre as expressões Lambda de Python, leia: