Testes Unitários


Testando o código

Vimos no artigo Testando o Código algumas abordagens iniciais para realizar testes no código, tais como usar a declaração assert e as docstrings. Recursos mais avançados epoderosos estão disponíveis, como é o caso do módulo unittest, que veremos agora.

Testes unitários com unittest

Testes unitários permitem que unidades de código possam ser testadas em diversas de suas características. Uma unidade pode ser uma função individual, um método ou procedimento de uma classe ou objeto. Ele é feito durante o desenvolvimento pelo programador.

O módulo unittest, incluído na biblioteca padrão, fornece ferramentas para testes unitários. Com ele podemos projetar um conjunto de testes que verificam se uma função (por exemplo) se comporta como o esperado sob situações variadas. Um bom conjunto de testes considera os possíveis tipos de entrada que uma função pode receber, incluindo testes de cada dessas situações. Uma cobertura completa de testes, em um projeto grande, pode ser muito difícil e, nesses casos pode ser considerado suficiente cobrir os casos críticos de uso do bloco testado. Diversos editores e IDEs, incluindo Jupyter Notebook, PyCharm e VSCode, podem usar unittest integrado.

Para usar unittest vamos escrever uma função a ser testada. Em seguida importamos o módulo unittest e criamos uma classe que herda de unittest.TestCase. Objetos dessa classe chamam e verificam o comportamento dessa função testada ao serem inicializados. Métodos diversos podem ser inseridos para verificar o funcionamento da função sob a inserção de parâmetros diferentes.

Para observar o funcionamento dos testes unitários vamos gravar dois arquivos do python, formata_nomes.py e nomes.py. O primeiro contém a função que queremos testar, o segundo chama essa função.

#formata_nomes.py

def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    partes = nomes.split()
    nome = partes[0].title()
    msg = f"Primeiro nome: {nome}"
    if len(partes) > 1:
        sobre = " ".join(partes[1:]).title()
        msg = f"{msg}, Sobrenome: {sobre}"
    return msg


Essa função recebe nomes e sobrenomes separados por espaços e retorna esse nome formatado como Primeiro nome: nome, Sobrenome: sobrenomes . Ela considera todas as palavras após a primeira como sobrenome. Para usar essa função gravamos e executamos o arquivo nomes.py.

from formata_nome import ler_nome_formatado as nf
print("Digite nome e sobrenomes.")
print("Deixe em branco para terminar.")
while True:
    nomes = input("\nDigite o nome completo: ")
    formatado = nf(nomes)
    if formatado=="": break
    print(f"\tNome formatado: {formatado}.")

Podemos iniciar uma sessão no console (terminal) e executar python nomes.py. O output aparece no código abaixo.

$ python nomes.py
  Digite nome e sobrenomes.
  Deixe em branco para terminar.

  Digite o nome completo: PEDRO
	  Nome formatado: Primeiro nome: Pedro.

  Digite o nome completo: pedro de alcantara
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: De Alcantara.

  Digite o nome completo: pedro II
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: Ii.

  Digite o nome completo:

Na última linha foi inserida uma string vazia, o que termina o loop. Aparentemente a função retorna o que se espera. Mesmo assim vamos testar nossa função: em um novo módulo importamos unittest e a função que pretendemos testar. Depois criamos uma classe que herda de unittest.TestCase e acrescentamos diversos métodos para verificar aspectos diferentes da função. Cada um dos métodos test_1, test_2, test_3 verifica um comportamento da função para diferentes tipos de inputs.

# teste_formata_nomes.py
import unittest
from formata_nome import ler_nome_formatado as nf

class TestaFormataNomes(unittest.TestCase):
    """Testes para 'formata_nome.py'."""

    def test_1(self):
        """testando o nome 'palito'."""

        formatado = nf('palito')
        self.assertEqual(formatado, 'Primeiro nome: Palito')

    def test_2(self):
        """testando nomes com maísculas."""

        formatado = nf('MARCO POLO')
        self.assertEqual(formatado, 'Primeiro nome: Marco, Sobrenome: Polo')

    def test_3(self):
        """testando strings vazias."""

        formatado = nf('')
        self.assertEqual(formatado, '')

if __name__ == '__main__':
    unittest.main()

Ao rodar esse teste observamos o output:

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Nenhum erro foi encontrado em nenhum dos três testes, como mostrado no console. Essa classe pode ter qualquer nome embora seja boa ideia dar um nome representativo de seu objetivo. Ela contém três métodos para testar a função formata_nome.ler_nome_formatado. Qualquer classe que herda de unittest.TestCase executa automaticamente todos os seus métodos que começam com test_ quando é invocada. O retorno da função testada é comparado com o resultado na linha self.assertEqual(formatado, 'string esperada') (um dos método de unittest.TestCase) e gera a informação sobre se o teste foi bem sucedido ou não, com as devidas mensagens.

O bloco if no final, como já vimos, verifica o valor da variável especial __name__. Se o arquivo estiver sendo rodado como programa principal, como ocorreu no nosso caso, ela assume o valor __name__ = "__main__". Nesse caso unittest.main() é chamado e os testes executados.

Suponha que queremos expandir nossa função ler_nome_formatado para que ela retorne uma mensagem de erro caso algum dígito esteja entre os caracteres dos nomes. Se um nome for digitado como “lu1s quinze” a função deve retornar: “Erro: dígito encontrado!”

Vamos então acrescentar um teste em teste_formata_nomes.py. O código abaixo mostra só o acréscimo ao arquivo.

# teste_formata_nomes.py
...
    def test_4(self):
        """testando dígitos no nome."""

        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Rodamos o teste novamente: desta vez um nome inserido com um dígito não retorna o resultado correto e uma mensagem de erro informa qual o teste falhou, onde e porque.

$ python teste_formata_nome.py
...F
======================================================================
FAIL: test_4 (__main__.TestaFormataNomes)
testando dígitos no nome.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "teste_formata_nome.py", line 29, in test_4
    self.assertEqual(formatado, 'Erro: dígito encontrado!')
AssertionError: 'Lu1Z Paulo' != 'Erro: dígito encontrado!'
- Lu1Z Paulo
+ Erro: dígito encontrado!
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1)

Claro que esse defeito deve ser corrigido em formata_nomes.py. Alteramos o código da seguinte forma:

# formata_nomes.py
def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    elif True in [i.isdigit() for i in nomes]:
        msg = "Erro: dígito encontrado!"
    else:
        partes = nomes.split()
        nome = partes[0].title()
        msg = f"Primeiro nome: {nome}"
        if len(partes) > 1:
            sobre = " ".join(partes[1:]).title()
            msg = f"{msg}, Sobrenome: {sobre}"
    return msg

No código temos a condição True in [i.isdigit() for i in nomes] que testa cada caracter da variável de string nomes, retornando uma lista de valores booleanos. O teste resulta verdadeiro se uma ou mais das entradas dessa lista for True, ou seja, se existirem dígitos no nome. Com essa alteração rodarmos o teste mais uma vez e veremos que todas as condições testadas foram satisfeitas.

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK

Para ignorar um dos testes podemos decorar a função com @unittest.skip.

...
    @unittest.skip('Esse teste já foi executado!')
    def test_4(self):
        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Uma classe inteira pode ser ignorada.

@unittest.skip("Uma classe a ser ignorada")
class Classe_de_Teste(unittest.TestCase):
    def um_metodo_qualquer(self):
        pass

Os seguintes decoradores estão disponíveis na classe:

@unittest.skip(msg) ignore o teste em qualquer caso,
@unittest.skipIf(bool, msg) ignore o teste se bool==True,
@unittest.skipUnless(bool, msg) ignore o teste, exceto se bool==True,
@unittest.expectedFailure marca o teste como falha esperada. Se o teste falhar mensagem de sucesso é emitida e, se passar um erro é lançado,
exception unittest.SkipTest(msg) Uma exceção é levantada ao ignorar em teste,

msg é a mensagem retornada com a exceção, que deve ser descritiva do problema ocorrido. bool é qualquer expressão que retorne um booleano.

Métodos setUp() e tearDown()


Os métodos setUp() e tearDown() são usados para definir instruções executadas antes e depois dos testes definidos. setUp() é executado antes de cada teste no módulo e tearDown() depois de cada um deles. Eles podem ser usados, por exemplo, definir variáveis, abrir e fechar uma conexão com banco de dados ou ler dados em um arquivo.

Erros levantados dentro setUp() ou tearDown() serão considerados erros comuns e não uma falha do teste. A implementação default não realiza nenhuma ação (como em pass). Por exemplo, suponha que pretendemos testar nossa classe Calculadora onde

# classe Calculadora
class Calculadora:
    def __init__(self):
        pass

    def soma(self, a, b):
        return a + b

    def subtrai(self, a, b):
        return a - b

    def muliplica(self, a, b):
        return a * b

    def divide(self, a, b):
        if b != 0:
            return a / b

Na classe de teste teríamos que inicializar uma calculadora para cada teste. Alternativamente podemos inicializar uma calculadora no método setUp().

class TestCalculadora(unittest.TestCase):

    def setUp(self):
        self.calc = Calculadora()

    def tearDown(self):
        self.calc = None

    def test_soma(self):
        self.assertEqual(self.calc.add(4, 7), 11)

    def test_subtrai(self):
        self.assertEqual(self.calc.sub(10, 5), 5)

    def test_multiplica(self):
        self.assertEqual(self.calc.mul(3, 7), 21)

    def test_divide(self):
        self.assertEqual(self.calc.div(10, 2), 5)

Também podemos usar métodos semelhantes para classes e módulos: isso é feito com setUpClass() e tearDownClass() em classes, e setUpModule() e tearDownModule() em módulos.

Testes ignorados não acionam setUp() nem tearDown(), caso estejam definidos. Da mesma forma classes ignoradas não acionam setUpClass() nem tearDownClass(). Módulos ignorados não acionam setUpModule() nem tearDownModule().

Para ver uma lista de opções de uso do unittest podemos digitar:

python -m unittest -h

Métodos assert em unittest.TestCase

Nos testes usando unittest.TestCase podemos usar um assert puro ou um dos seguintes métodos definidos no módulo unittest.TestCase:

Método levanta erro se a condição não se verifica
assertEqual(m, n) m == n
assertNotEqual(m, n) m != n
assertTrue(a) a é True
assertFalse(a) a é False
assertIn(item, lista) item está na lista
assertNotIn(item, lista) item não está na lista
assertIs(a, b) a is b
assertIsNot(a, b) a is not b
assertIsNone(x) x == None
assertIsNotNone(x) x != None
assertIsInstance(a, b) a é uma instância de b
assertNotIsInstance(a, b) a não é uma instância de b
assertAlmostEqual(a, b[, n]) se a == b, precisão de n decimais (default: n = 7)
assertNotAlmostEqual(a, b[, n]) negação de assertAlmostEqual(a, b[, n])
assertGreater(a, b) a > b
assertGreaterEqual(a, b) a >= b
assertLess(a, b) a > b
assertLessEqual(a, b) a <= b
assertRegex(s, r) regex r.search(s)
assertNotRegex(s, r) regex not r.search(s)
assertCountEqual(a, b) a e b tem os mesmos elementos e em igual número, independente da ordem.
Método
fail() sempre gera erro

Os erros são levantados quando o teste for falso. Em todos os casos um parâmetro opcional pode ser usado para determinar a mensagem de erro mostrado, como em TestCase.assertEqual(m, n [, mensagem]). Devemos nos lembrar, como dito acima, que um teste com assert pode ser desligado com o ajuste da variável __debug__ = False.

Simulações (Mocks)

Geralmente o estado de uma função, classe ou um de seus métodos depende de objetos externos para a coleta de dados ou outra interação qualquer, tais como arquivos em disco a serem lidos ou acesso a bancos de dados, ou uma peça de hardware a ser acionada. Como não é boa prática acessar em fase de desenvolvimento os objetos na produção desenvolveu-se a abordagem de criar “objetos simulados” ou mocks. Um objeto mock substitui e imita o comportamento de um objeto real, no ambiente de teste. Usando mocks fica mais fácil gerar situações que podem ser raras no ambiente real, por exemplo para o teste de blocos except ou testes condicionais if. Ainda ocorrem casos em que os objetos (que podem ser blocos de código) ainda não foram desenvolvidos ou oferecem respostas muito lentas para efeito de teste. Com esses objetos é possível verificar se e como um método foi chamado, e com qual frequência.

O módulo unittest inclui um subpacote chamado unittest.mock com ferramentas úteis para essa simulação. Ele também oferece uma função patch() que substitui os objetos reais no código por instâncias mocks. patch() pode ser usado como um decorador ou gerenciador de contexto, facilitando a escolha de qual escopo será simulado. Ao final do teste patch() retornará no código as referências aos objetos originais.

O objeto mock

Um objeto mock pode ser instanciado e a ele podemos atribuir métodos e propriedades.

from unittest.mock import Mock
mock = Mock()
print(mock)
<Mock id='140292494179968'>

# ao objeto podemos atribuir métodos e propriedades
mock.propriedade
mock.metodo()

Além da classe unittest.mock (que é a base das classes simuladas) o módulo também contém uma subclasse unittest.mock.MagicMock que fornece implementações de vários métodos mágicos como .__len__(), __str__() e .__iter__().
Por exemplo, gravamos o arquivo dia_semana.py, que imprime fim de semana se o dia for sábado ou domingo, e dia da semana para os demais dias.

from datetime import datetime
def is_fds():
    dia_semana = datetime.today().weekday()
    return dia_semana > 4

print('fim de semana' if is_fds() else 'dia da semana')

O módulo datatime retorna weekday() = 0 para segunda feira, weekday() = 5, 6 para sábado e domingo. O resultado desse código depende do dia em que está sendo executado. Para uma execução feita na terça feira temos:

$ python dia_semana.py
# é impresso no console
dia da semana

É claro que seria interessante testar o código para outros dias, sem ter que esperar a data correta, nem alterar o relógio do computador. Para fazer isso fazemos um mock de datetime.

import datetime
from unittest.mock import Mock

# fixamos 2 dias para serem usados no teste
ter = datetime.datetime(year=2022, month=3, day=1)  # terça feira (1)
sab = datetime.datetime(year=2022, month=3, day=5)  # sábado (5)

# Mock datetime para controlar a data
datetime = Mock()

def is_fds():
    dia_semana = datetime.datetime.today().weekday()
    return dia_semana > 4

# força datetime para retornar a data em ter (terça feira)
datetime.datetime.today.return_value = ter
# teste para dia = terça
print('fim de semana' if is_fds() else 'dia da semana')

# força datetime para retornar a data em sab (sábado)
datetime.datetime.today.return_value = sab
# teste para dia = sábado
print('fim de semana' if is_fds() else 'dia da semana')

Agora, ao executar o script temos duas respostas:

$ python dia_semana.py
dia da semana
fim de semana

Nesse exemplo, quando fazemos datetime = Mock() tornamos datetime.datetime.today um método simulado, que pode receber a propriedade datetime.datetime.today.return_value a critério do programador. Com isso o método interno .today() retorna a data especificada.

Bibliografia

Livros:

  • Ceder, Vernon; Mcdonald, Kenneth: The Quick Python Book, 2nd. Ed., Manning, Greenwich, 2010.
  • Hunt, John: Advanced Guide to Python 3 Programming, Springer, Suíça, 2019. Disponível em Academia.edu.

Sites:

todos eles visitados em março de 2020.

Testando o Código


Testando o código

É natural que ao escrever código, principalmente em projetos grandes, sempre sejam introduzidos erros. Existem três tipos básicos de erros:

  1. erros de sintaxe são o tipo mais básico e os mais fáceis de serem encontrados. Eles ocorrem quando uma ou mais linhas de código estão escritas incorretamente de forma que o interpretador do Python não consegue processá-las. Eles são quase sempre fatais, impedindo que o código seja executado. Eles são, em geral, erros de digitação, indentação incorreta ou argumentos incorretos passados para funções e classes. Por ex.: print "esqueci o parênteses" não é uma linha válida no python 3.x (embora esteja correta no python 2.x).
  2. erros de tempo de execução ocorrem quando a sintaxe está correta mas o interpretador não pode executar a ação. Isso pode ser causado pelo uso de uma variável não definida, um loop infinito, uma divisão por zero, etc.
  3. erros lógicos são os mais difíceis de serem detectados. O código roda sem interrupções mas não executa a tarefa proposta. Ele pode envolver uma operação matemática incorreta, uso incorreto de índices (como começar no indíce 1, e não 0), um loop interrompido prematuramente, etc.


IDES com preenchimento de código e realce de sintaxe ajudam bastante a evitar os dois primeiros tipos de erros. Frameworks de testes podem ser úteis na depuração de erros lógicos.

O processo de se buscar erros (bugs) no código pode (e deve) ser usado várias vezes durante sua construção. Mas, uma vez finalizado, pelo menos em etapa, o código deve ser testado para verificação de que está realizando corretamente a tarefa proposta. Esses últimos testes devem ser os mais amplos possíveis pois sempre podem aparecer erros não cobertos pelas tentativas prévias de checagem. Mesmo projetos completos e bem testados podem necessitar de novas versões com a inserção de novas funcionalidades ou aprimoramento das que existem. Inserir código em um projeto que já existe é uma prática sensível à introdução de erros.

Embora possam excluir muitos possíveis erros, os teste não são capazes de detectar todos os erros de um código porque é praticamente impossível avaliar seu comportamento sob todos os caminhos de execução, em projetos não triviais. Por isso é importante projetar testes que verificam aspectos do código tão amplos quanto possível.

Além dos testes de correção lógica do código, diversos outros aspectos devem ser testados:

  • performance de execução,
  • robustez do aplicativo sob alta demanda ou uso prolongado, principalmente em aplicativos web,
  • capacidade de implantação e instalação nas plataformas alvo,
  • adaptabilidade às atualizações de versões (quando existirem),
  • habilidade realização de backups de dados e do próprio aplicativo e seu estado,
  • segurança do sistema sob ação do aplicativo e capacidade de recuperação em caso de falha.

Existem muitos recursos disponíveis aos desenvolvedores para testes de código em python. A mais simples delas consiste em inserir verificações assert em pontos críticos do código.

Declaração ASSERT


Vimos na seção sobre tratamentos de erros como levantar uma exceção com assert. Podemos usar assert para inserir no código um teste que gera uma exceção caso uma expressão não seja verdadeira.

A sintaxe é: assert teste_booleano [, mensagem], que lança um AssertionError com uma mensagem opcional.

Para recordar o comportamento de assert definimos uma função que lança um erro quando seu argumento é maior que 3, emitindo uma mensagem de erro.

def menor_que_4(i):
    assert i < 4, 'O número deve ser menor que 4'
    dic={1:'um', 2:'dois', 3:'três'}
    return dic[i]

for i in range(1,5):
    try:
        print (menor_que_4(i))
    except AssertionError as msg:
        print(msg)
        
# esse código gera o output
  um
  dois
  três
  O número deve ser menor que 4

Essa é uma forma de se assegurar que um valor está em conformidade com o esperado e, caso contrário, descobrir que valor ofendeu a condição imposta. Se temos vários asserts no código saberemos também em que módulo e linha o erro ocorreu.

Variável __debug__: Caso existam muitas declarações assert no código, além de torná-lo mais extenso e menos legível, pode haver impacto no desempenho do aplicativo. Você pode remover ou comentar todas as declarações (o que não é difícil com um bom editor ou IDE) mas, nesse caso, terá que retornar com todas elas se precisar modificar o projeto.

Declarações assert funcionam junto com a variável interna (built-in) do Python __debug__, que é True por default. Internamente a declaração assert i < 4 é equivalente a:

if __debug__:
    if not i < 4:
        raise AssertionError

Se marcarmos __debug__ == False o teste de assert não será executado. Ocorre que não podemos atribuir valores a essa variável no código, como __debug__ == False. Para isso é necessário definir a variável de ambiente PYTHONOPTIMIZE ou executar o Python com a opção – O. Assim podemos ter todos os testes de assert ativos na fase de desenvolvimento e desligados na produção. Também é possível remover as instruções assert e as docstrings ao compilar o código com compileall. (Leia sobre compilação em Módulos e Pacotes.)

Docstrings

Um mecanismo mais poderoso que lançar erros em pontos específicos consiste em usar docstrings contendo testes e seus resultados, e usar o módulo doctest. (Leia mais sobre doscstrings.)

O módulo doctest procura por trechos na docstring com o formato de sessões interativas do Python e executa essas linhas de comando para conferir o output proposto. O doctest pode:

  • verificar se as docstrings estão atualizadas, conferindo se os exemplos interativos funcionam como documentado.
  • realizar testes de regressão para verificar se os exemplos interativos de um arquivo (ou objeto) em teste funcionam conforme o esperado.
  • facilitar a composição de documentos tutoriais sobre um pacote com com exemplos de entrada-saída. Se corretamente estruturados esses documentos podem ser considerados uma “documentação executável”.

Por exemplo, gravamos o arquivo testando.py, incluindo as linhas import doctest e doctest.testmod() no bloco de inicialização.

# testando.py
def fatorial(n):
    """Retorna fatorial de n inteiro, onde n >= 0.

    >>> [fatorial(n) for n in range(6)]
    [1, 1, 2, 6, 24, 120]
    >>> fatorial(30)
    265252859812191058636308480000000
    """

    if n ≤ 1:
        return 1
    else:
        return n * fatorial(n-1)

if __name__ == "__main__":
    import doctest
    doctest.testmod()

Quando esse script é executado diretamente o módulo doctest é importado e a documentação é “executada” em suas linhas de código e conferida com os outputs fornecidos. Se todos os testes forem bem sucedidos nenhuma mensagem será exibida.

# 1º teste
$ python testando.py

Introduzindo erros: Para efeito de teste vamos introduzir um erro proposital, alterando a linha >>> fatorial(30) para >>> fatorial(20) nas linhas do docstring. O output proposto agora está incorreto, e isso será mostrado no output de doctest.

# 2º teste
$ python testando.py
  **********************************************************************
  File "testando.py", line 6, in __main__.fatorial
  Failed example:
      fatorial(20)
  Expected:
      265252859812191058636308480000000
  Got:
      2432902008176640000
  **********************************************************************
  1 items had failures:
     1 of   2 in __main__.fatorial
  ***Test Failed*** 1 failures.

O resultado aponta como errônea a linha 6 do “testando.py”, como seria esperado.
Módulos executados com

if __name__ == "__main__":
    import doctest
    doctest.testmod()

executarão todas as suas docstrings. Alternativamente, podemos executar o arquivo com o sinalizador python testando.py -v, o que resultará em um output mais extenso e pormenorizado.

Outra forma interessante de se usar o doctest é passando um arquivo de texto como parâmetro para análise de suas linhas de código e output. Um arquivo de texto, digamos que sobre_python.txt (que pode ser parte de um livro, digamos) é gravado com testes idênticos aos de um docstring. Em seguida fazemos:

import doctest
doctest.testfile("sobre_python.txt")

O arquivo sobre_python.txt não precisa ser completo nem conter todas as definições de funções ou módulos usados. Nesse caso as funções e módulos testadas (ou necessárias para o teste) devem ser importadas.

    Sobre o módulo "testando"
    =========================
    Uso da função ``fatorial``
    -------------------
    Importe o módulo e função
    >>> from testando import fatorial
    Agora você pode usar:
    >>> fatorial(6)
    120

Existem algumas formas de alterar a forma como doctest lê as docstrings. As mais comuns são +ELLIPSIS (significando que um sinal de reticência representa qualquer substring) e +NORMALIZE_WHITESPACE (que força o tratamento de qualquer sequência de espaços em branco da mesma forma). Isso fazer isso basta inserir um comentário com a forma de # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE, onde o sinal + ativa a instrução. Os testes abaixos são bem sucedidos:

>>> lista = [2, 4, 6, 8, 10]
>>> lista # doctest: +ELLIPSIS
    [2, ..., 10]
>>> lista # doctest: +NORMALIZE_WHITESPACE
    [2,4,   6,8,     10]

Para desativar as instruções usamos , como em # doctest: -ELLIPSIS, -NORMALIZE_WHITESPACE. As instruções podem ser usadas em conjunto.

Ao realizar testes com doctest alguns cuidados devem ser tomados quando se compara objetos que podem ser retornados em diferentes ordens. Por exemplo, dicionários não são objetos ordenados, e um teste supondo uma ordem específica pode falhar.

# Testando valores em um dicionário
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}
>>> capitais
{"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

O teste acima falhará se o dicionário for retornado em ordem diferente. Uma solução consiste em testar por cada chave ou ordenar o dicionário a ser
testado. Como dicionários não possuem elementos com chaves repetidas, o mais apropriado é ordenar por chaves (key).

# Testando valores em um dicionário com ordenação
>>> capitais = {"Belo Horizonte":"MG", "São Paulo": "SP", "Rio de Janeiro":"RJ"}

>>> ordenado = dict(sorted(capitais.items(), key=lambda i: i[0]))
>>> ordenado
{"Belo Horizonte":"MG", "Rio de Janeiro":"RJ", "São Paulo": "SP"}

Além disso, como linhas vazias são consideradas marcas para terminar o processamento doctest, se linhas em branco fazem parte do output esperado é necessário inserir uma linha com <BLANKLINE>. Para inserir caracteres \ como escape de outro caracter ou para marcar continuação de linha, o string do docteste deve ser raw, ou seja, precedido por r.

Doctestes são úteis e devem ser usados, mas podem ficar grandes e pesados para projetos maiores. Outras formas de testagem estão disponíveis no python.

Testes unitários com unittest

Testes unitários permitem que unidades de código possam ser testadas em diversas de suas características. Uma unidade pode ser uma função individual, um método ou procedimento de uma classe ou objeto. Ele é feito durante o desenvolvimento pelo programador.

O módulo unittest, incluído na biblioteca padrão, fornece ferramentas para testes unitários. Com ele podemos projetar um conjunto de testes que verificam se uma função (por exemplo) se comporta como o esperado sob situações variadas. Um bom conjunto de testes considera os possíveis tipos de entrada que uma função pode receber, incluindo testes de cada dessas situações. Uma cobertura completa de testes, em um projeto grande, pode ser muito difícil e, nesses casos pode ser considerado suficiente cobrir os casos críticos de uso do bloco testado. Diversos editores e IDEs, incluindo Jupyter Notebook, PyCharm e VSCode, podem usar unittest integrado.

Para usar unittest vamos escrever uma função a ser testada. Em seguida importamos o módulo unittest e criamos uma classe que herda de unittest.TestCase. Objetos dessa classe chamam e verificam o comportamento dessa função testada ao serem inicializados. Métodos diversos podem ser inseridos para verificar o funcionamento da função sob a inserção de parâmetros diferentes.

Para observar o funcionamento dos testes unitários vamos gravar dois arquivos do python, formata_nomes.py e nomes.py. O primeiro contém a função que queremos testar, o segundo chama essa função.

#formata_nomes.py

def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    partes = nomes.split()
    nome = partes[0].title()
    msg = f"Primeiro nome: {nome}"
    if len(partes) > 1:
        sobre = " ".join(partes[1:]).title()
        msg = f"{msg}, Sobrenome: {sobre}"
    return msg

Essa função recebe nomes e sobrenomes separados por espaços e retorna esse nome formatado como Primeiro nome: nome, Sobrenome: sobrenomes . Ela considera todas as palavras após a primeira como sobrenome. Para usar essa função gravamos e executamos o arquivo nomes.py.

from formata_nome import ler_nome_formatado as nf
print("Digite nome e sobrenomes.")
print("Deixe em branco para terminar.")
while True:
    nomes = input("\nDigite o nome completo: ")
    formatado = nf(nomes)
    if formatado=="": break
    print(f"\tNome formatado: {formatado}.")

Podemos iniciar uma sessão no console (terminal) e executar python nomes.py. O output aparece no código abaixo.

$ python nomes.py
  Digite nome e sobrenomes.
  Deixe em branco para terminar.

  Digite o nome completo: PEDRO
	  Nome formatado: Primeiro nome: Pedro.

  Digite o nome completo: pedro de alcantara
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: De Alcantara.

  Digite o nome completo: pedro II
	  Nome formatado: Primeiro nome: Pedro, Sobrenome: Ii.

  Digite o nome completo:

Na última linha foi inserida uma string vazia, o que termina o loop. Aparentemente a função retorna o que se espera. Mesmo assim vamos testar nossa função: em um novo módulo importamos unittest e a função que pretendemos testar. Depois criamos uma classe que herda de unittest.TestCase e acrescentamos diversos métodos para verificar aspectos diferentes da função. Cada um dos métodos test_1, test_2, test_3 verifica um comportamento da função para diferentes tipos de inputs.

# teste_formata_nomes.py
import unittest
from formata_nome import ler_nome_formatado as nf

class TestaFormataNomes(unittest.TestCase):
    """Testes para 'formata_nome.py'."""

    def test_1(self):
        """testando o nome 'palito'."""

        formatado = nf('palito')
        self.assertEqual(formatado, 'Primeiro nome: Palito')

    def test_2(self):
        """testando nomes com maísculas."""

        formatado = nf('MARCO POLO')
        self.assertEqual(formatado, 'Primeiro nome: Marco, Sobrenome: Polo')

    def test_3(self):
        """testando strings vazias."""

        formatado = nf('')
        self.assertEqual(formatado, '')

if __name__ == '__main__':
    unittest.main()

Podemos rodar esse teste e observar seu output:

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK

Nenhum erro foi encontrado em nenhum dos três testes, como mostrado no console. Essa classe pode ter qualquer nome embora seja boa ideia dar um nome representativo de seu objetivo. Ela contém três métodos para testar a função formata_nome.ler_nome_formatado. Qualquer classe que herda de unittest.TestCase executa automaticamente todos os seus métodos que começam com test_ quando é invocada. O retorno da função testada é comparado com o resultado na linha self.assertEqual(formatado, 'string esperada') (um dos método de unittest.TestCase) e gera a informação sobre se o teste foi bem sucedido ou não, com as devidas mensagens.

O bloco if no final, como já vimos, verifica o valor da variável especial __name__. Se o arquivo estiver sendo rodado como programa principal, como ocorreu no nosso caso, ela assume o valor __name__ = "__main__". Nesse caso unittest.main() é chamado e os testes executados.

Suponha que queremos expandir nossa função ler_nome_formatado para que ela retorne uma mensagem de erro caso algum dígito esteja entre os caracteres dos nomes. Se um nome for digitado como “lu1s quinze” a função deve retornar: “Erro: dígito encontrado!”

Vamos então acrescentar um teste em teste_formata_nomes.py. O código abaixo mostra só o acréscimo ao arquivo.

# teste_formata_nomes.py
...
    def test_4(self):
        """testando dígitos no nome."""

        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Rodamos o teste novamente: desta vez um nome inserido com um dígito não retorna o resultado correto e uma mensagem de erro informa qual o teste falhou, onde e porque.

$ python teste_formata_nome.py
...F
======================================================================
FAIL: test_4 (__main__.TestaFormataNomes)
testando dígitos no nome.
----------------------------------------------------------------------
Traceback (most recent call last):
  File "teste_formata_nome.py", line 29, in test_4
    self.assertEqual(formatado, 'Erro: dígito encontrado!')
AssertionError: 'Lu1Z Paulo' != 'Erro: dígito encontrado!'
- Lu1Z Paulo
+ Erro: dígito encontrado!
----------------------------------------------------------------------
Ran 4 tests in 0.001s
FAILED (failures=1)

Claro que esse defeito deve ser corrigido em formata_nomes.py. Alteramos o código da seguinte forma:

# formata_nomes.py
def ler_nome_formatado(nomes):
    msg = ""
    if nomes.strip() == "":
        msg = ""
    elif True in [i.isdigit() for i in nomes]:
        msg = "Erro: dígito encontrado!"
    else:
        partes = nomes.split()
        nome = partes[0].title()
        msg = f"Primeiro nome: {nome}"
        if len(partes) > 1:
            sobre = " ".join(partes[1:]).title()
            msg = f"{msg}, Sobrenome: {sobre}"
    return msg

No código temos a condição True in [i.isdigit() for i in nomes] que testa cada caracter da variável de string nomes, retornando uma lista de valores booleanos. O teste resulta verdadeiro se uma ou mais das entradas dessa lista for True, ou seja, se existirem dígitos no nome. Com essa alteração rodarmos o teste mais uma vez e veremos que todas as condições testadas foram satisfeitas.

$ python teste_formata_nome.py
...
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OK

Para ignorar um dos testes podemos decorar a função com @unittest.skip.

...
    @unittest.skip('Esse teste já foi executado!')
    def test_4(self):
        formatado = nf('lu1z paulo')
        self.assertEqual(formatado, 'Erro: dígito encontrado!')
...

Uma classe inteira pode ser ignorada.

@unittest.skip("Uma classe a ser ignorada")
class Classe_de_Teste(unittest.TestCase):
    def um_metodo_qualquer(self):
        pass

Os seguintes decoradores estão disponíveis na classe:

@unittest.skip(msg) ignore o teste em qualquer caso,
@unittest.skipIf(bool, msg) ignore o teste se bool==True,
@unittest.skipUnless(bool, msg) ignore o teste, exceto se bool==True,
@unittest.expectedFailure marca o teste como falha esperada. Se o teste falhar mensagem de sucesso é emitida e, se passar um erro é lançado,
exception unittest.SkipTest(msg) Uma exceção é levantada ao ignorar em teste,

msg é a mensagem retornada com a exceção, que deve ser descritiva do problema ocorrido. bool é qualquer expressão que retorne um booleano.

Bibliografia

Livros:

  • Ceder, Vernon; Mcdonald, Kenneth: The Quick Python Book, 2nd. Ed., Manning, Greenwich, 2010.
  • Hunt, John: Advanced Guide to Python 3 Programming, Springer, Suíça, 2019. Disponível em Academia.edu.
  • Romano, F., Phillips, D., Hattem, R.: Python: Journey from Novice to Expert, Packt Publishing, 2016.

Sites:

todos eles visitados em março de 2020.




Testes: unittest

Módulos e Pacotes


Executando o código

Podemos digitar comandos em sessões interativas do python diretamente em uma sessão do terminal, e ler nele os outputs das linhas executadas. Vários editores e IDEs, como Pycharm e VSCode, oferecem acesso a um terminal onde o código pode ser executado diretamente sem abandonar o IDE. O Jupyter Notebook também roda de modo interativo, exibindo em suas células o resultado de blocos de código.

# sessão interativa do python (no terminal)
$ python
  Python 3.8.8 (default, Apr 13 2021, 19:58:26) 
  [GCC 7.3.0] :: Anaconda, Inc. on linux
  Type "help", "copyright", "credits" or "license" for more information.
  >>> lista = ["sogra","da","Casa"]
  >>> for i in lista[::-1]:
  ...     print(i, end = " ")
  Casa da sogra

Para programas mais extensos, e que devem ser executados várias vezes, torna-se mais prático gravar em um arquivo as linhas de código e executá-lo depois, em bloco. Esses arquivos são chamados de scripts e podem ser divididos em múltiplos arquivos, se isso for necessário para facilitar seu desenvolvimento e manutenção.

Módulos

Uma prática comum consiste em gravar arquivos separados com um conjunto de funções que podem ser usadas em outras partes do mesmo projeto ou em projetos diferentes, o que facilita a reutilização do código. Esses arquivos são os módulos do Python. Ele devem ser gravados com extensão .py e seu nome (sem a extensão) é o nome do módulo, usado na sua importação. Dentro de cada módulo seu nome está disponível na variável global __name__.

Por exemplo, gravamos um arquivo com o nome texto.py com duas funções. Uma delas remove as letras duplicadas de uma string (banana ↦ ban), e outra retorna strings com suas letras invertidas entre maiúscula e minúsculas (AzUlEjO ↦ aZuLeJo).

# arquivo texto.py
def remove_duplicadas(palavra):
    nova, foi = "", ""
    for i in palavra:
        if not i in foi:
            nova += i
        foi += i    
    return nova

def inverte_caso(palavra):
    t = [x.upper() if x.islower() else x.lower() for x in palavra]
    return "".join(t)

Para usar essas funções criamos agora um arquivo que importa texto.py, dando a ele o nome de usa_texto.py.

# usa_texto.py
# (1º bloco)    
import texto as tx
print(tx.remove_duplicadas("inconstitucionalissimamente"))
print(tx.inverte_caso("rUA DA bAHIA"))

Em seguida o executamos no prompt do terminal, estando todos na mesma pasta:

$ python usa_texto.py
  incostualme
  Rua da Bahia

Outras formas de importação para usa_texto.py estão listadas abaixo, todas com o mesmo resultado no output:

# (2º bloco)
  from texto import *
  print(remove_duplicadas("inconstitucionalissimamente"))
  print(inverte_caso("rUA DA bAHIA"))

# (3º bloco)
  from texto import remove_duplicadas, inverte_caso
  print(remove_duplicadas("inconstitucionalissimamente"))
  print(inverte_caso("rUA DA bAHIA"))

# (4º bloco)
  import texto
  remove = texto.remove_duplicadas
  inverte = texto.inverte_caso
  print(remove("inconstitucionalissimamente"))
  print(inverte("rUA DA bAHIA"))

No 2º bloco todos os objetos são importados. Essa prática é desaconselhada porque, em módulos grandes, pode aumentar a chance de conflitos de nomes (de existirem objetos com nomes iguais em mais de um módulo). A instrução de import apenas indica o caminho onde estão os objetos que serão usados. Ela não implica carregamento de objetos não usados para a memória. import * promove a importação de todos os nomes de objetos exceto aqueles que começam com _ (underscore ou sublinhado). import module as m cria um aliás (um apelido) para module e from module import objeto as obj faz o mesmo para um objeto dentro do módulo. No 4º bloco as funções receberam nomes, o mesmo que from texto import remove_duplicadas as remove, etc.

Além de funções, um módulo pode conter outras instruções destinadas a inicializar o módulo. Elas são executados na primeira vez que o nome do módulo é encontrado em uma importação ou quando o arquivo é executado como um script. Cada módulo tem seu próprio namespace (leia sobre o assunto) ou tabela de símbolos, que é usado por todas suas as funções. Essas variáveis ​​globais no módulo não conflituam com outras eventuais que apareçam com o mesmo nome em outras partes do código. Mesmo assim é uma boa prática se referir às variáveis e funções de um módulo com a notação nome_modulo.nome_item, onde item é um objeto qualquer dentro do módulo.

Também podemos importar módulos dentro de outros módulos. Nomes (que são referências aos objetos) dos módulos importados são juntados no mesmo namespace do módulo importador. É uma convenção, mas não obrigatório, listar todas as instruções de importação no início do módulo, o que facilita a leitura do código.

Executando um módulo

Módulos podem ser preparados para executar diretamente no terminal. Para ver isso gravamos o seguinte conteúdo em arquivo com o nome fatorial.py.

# fatorial.py
import sys
def fatorial(n):
    if n <= 1:
        return 1
    else:
        return n * fatorial(n-1)
      
if __name__ == "__main__":
    print(fatorial(int(sys.argv[1])))    

Se esse arquivo for executado diretamente no terminal, a variável __name__ assume o valor “__main__” e a condição final é satisfeita. sys.argv[1] traz para o código um parâmetro digitado no terminal, após o nome do módulo.

$ python fatorial.py 8
40320
$ python fatorial.py 10
3628800

!# shebang


Podemos executar diretamente um arquivo de script se ele contiver uma referência ao interpretador. Para isso acrescentamos a 1ª linha com #!/usr/bin/env python3. O sinal #! é denominado shebang e é destinado a indicar o caminho para o interpretador, no caso python 3 na pasta #!/usr/bin/env. Gravamos o arquivo letras.py, com a função mai_min_uscula() com o efeito de transformar uma string em outra com letras alternadamente maiúsculas e minúsculas (azulejo ↦ aZuLeJo).

#!/usr/bin/env python3

def mai_min_uscula(palavra):
    coleta = ""
    for i in range(len(palavra)):
        j = palavra[i]
        coleta += j.upper() if i%2==0 else j.lower()
    return coleta   

if __name__ == "__main__":
    import sys
    print(mai_min_uscula(sys.argv[1]))

Antes de tentar rodá-lo diretamente no terminal temos que tornar o arquivo executável. No linux:

# para tornar o arquivo executável    
$ chmod +x letras.py

# para executá-lo
$ ./letras.py "mais vale um pássaro na mão"
MaIs vAlE Um pÁsSaRo nA MãO

Além de permitir executar diretamente o arquivo, o shebang também é útil quando queremos que outras versões do python instaladas no computador sejam usadas.

# o comando
$ ./letras.py "texto"
# será executado como
usr/bin/env python3 letras.py "texto"


Naturalmente se o script for executado em outra máquina pode ser necessário ajustar o endereço em #!endereço para corresponder a uma instalação presente do python. Apenas para recordar, se o módulo for importado, e não executado diretamente, a variável __name__ tem outro valor e o bloco final é ignorado.

Localização de módulos

Quando importamos o módulo caminho/modulo_nome.py o interpretador busca por esse nome

  • primeiro no módulo interno, (built-in);
  • se não encontrar ele busca nos diretórios listados na variável sys.path que contém caminho (ou pasta local, ativa no momento, se caminho não for especificado);
  • nos diretórios estabelecidos em PYTHONPATH, uma variável de ambiente;
  • ou na pasta default estabelecida pela instalação de seu python.

É possível aterar dinamicamente, dentro do código, o conteúdo de sys.path de forma a procurar por módulos nos diretórios estabelecidos.

Python Bytecode

Python é uma linguagem interpretada e o interpretador é instalado junto com o próprio python. A sintaxe do código é a mesma para qualquer plataforma mas o interpretador é diferente para cada uma delas. Dessa forma pode-se escrever um único código que roda em qualquer plataforma. Também existem compiladores para transformar o arquivo (ou projeto) em um executável.

Na execução o interpretador gera um arquivo de bytecode (instruções de máquina de baixo nível) que, em seguida pode ser executada pela máquina virtual do python (python virtual machine). Esse bytecode fica em __pycache__, um subdiretório do diretório ativo na compilação. Apenas módulos carregados por imports são armazenados em __pycache__. Para obter todo o código como arquivo.pyc ele deve ser compilado com instruções explícitas para isso (como mostrado abaixo).

Na complição os seguintes arquivos são armazenados, com as extensões:

  • arquivo.py: O código fonte sendo executado.
  • arquivo.pyc: arquivo bytecode compilado. Todos os módulos importados são convertidos em *.pyc pelo interpretador. Esse arquivo pode ser carregado mais rapidamente que o módulo original.
  • arquivo.pyo: Um arquivo *.pyc é criado quando o parâmetro de otimização (-O) é usado.
  • arquivo.pyd: Arquivo dll do Windows.

Quando um arquivo é executado em um terminal as extensões .pyc tem prioridade para serem carregadas, a menos que o código fonte tenha sido alterado mais recentemente. Na compilação as chaves (opções) -O ou -OO podem ser usadas para diminuir o tamanho do módulo complilado:

  • A opção -O remove as instruções assert,
  • a opção -OO remove as instruções assert e as docstrings.

Ao fazer isso deve-se ter certeza de que tais instruções não serão mais necessárias. Módulos compilados são carregados em menor tempo mas não executam mais rápido. O módulo compileall pode ser usado para criar arquivos .pyc para todos os módulos de um diretório.

Módulos na biblioteca padrão e módulos instalados


Na instalação padrão do Python diversos módulos são instalados por default e podem ser importados diretamente em qualquer outro módulo. Alguns deles estão descritos no artigo Biblioteca Padrão, neste site. Alguns módulos são específicos da plataforma, como o winreg para o Windows, que permite diversas operações com o registro desse sistema.

Um exemplo é o módulo sys com diversas propriedades e métodos úteis.

import sys
sys.version
'3.8.8 (default, Apr 13 2021, 19:58:26) \n[GCC 7.3.0]'
sys.path
['/home/guilherme/.anaconda3/lib/python38.zip',
 '/home/guilherme/.anaconda3/lib/python3.8',
 '/home/guilherme/.anaconda3/lib/python3.8/lib-dynload',
 '',
 '/home/guilherme/.anaconda3/lib/python3.8/site-packages']
# acrescenta um diretório no path
sys.path.append('/home/guilherme/Projetos/Python/unittest')

A variável sys.path contém uma lista de strings, inicializada com a variável de ambiente PYTHONPATH. Ela pode ser modificada com
sys.path.append('novo/caminho')

Pacotes (Packages)

Um pacote (ou package) é uma coleção de módulos, dispostos de forma organizada para tornar mais simples a construção do código, sua utilização e reutilização. Essa estrutura facilita a localização e execução cada um de seus módulos. Pacotes são compreendidos como:

  • aqueles instalados de fontes externas usando ferramentas como pip ou pipenv. Frequentemente buscamos pacotes no Python Package Index, (Pypi);
  • aqueles construídos pelo desenvolvedor com seu próprio código. Pacotes são usados como formas de estruturar o projeto.

Os pacotes permitem que os namespaces (as tabelas de referência entre nomes e objetos) de módulos do python sejam estruturados usando a notação de ponto. Por exemplo modulo1.modulo2 é uma referência para o submódulo modulo2 contido em modulo1. Isso ajuda a impedir conflito de nomes em projetos grandes, principalmente quando módulos diferentes foram escritos por desenvolvedores diferentes.

Um diretório com um pacote deve necessariamente conter um arquivo __init__.py. O nome do pacote é mesmo do diretório base. É boa prática dar nomes que nos ajudem a lembrar de sua funcionalidade.

Arquivo __init__.py


O arquivo especial __init__.py é sempre executado quando o pacote é importado. Ele pode ser vazio ou pode conter código de inicialização do aplicativo ou dos módulos em seu diretório. Uma prática comum é a de importar os módulos necessários nessa inicialização. Quando um subpacote é importado, por exemplo com import meu_app.gerar_excel (em referência à figura), os arquivos de inicialização são executados na seguinte ordem:

  1. meu_app.__init__.py
  2. meu_app.gerar_excel.__init__.py

Por exemplo, vamos exibir a estrutura de um pacote de nome meu_app, cuja finalidade é ler e escrever dados numéricos em arquivos .txt ou .csv, e construir planilhas Excel com esses dados. Na figura mostramos a estrutura de diretórios, com nomes após sinais de #. Usuários do pacote podem importar os módulos:

# para ler os arquivos csv    
import meu_app.ler.ler_csv

# para gerar planilhas com dados importados
import meu_app.gerar_excel.gerar_xl
# ou
from  meu_app.gerar_excel import gerar_xl

# para importar todos os módulos de meu_app.escrever from meu_app.escrever import *

Se o objeto buscado não for encontrado um ImportError é lançado. No último exemplo, podemos limitar os módulos importados com * inserindo a variável especial __all__ em __inti__.py. Se meu_app.escrever.__init__.py contiver a linha: __all__=["escrever_csv"] apenas esse módulo será importado. Essa variável recebe uma lista dos módulos a serem importados: __all__=["modulo1", "modulo2", ...].

Quando os pacotes estão estruturados como o meu_app na figura, contendo subpacotes, podemos importar módulos de pacotes diferentes usando caminhos completos. Por exemplo, se para gerar uma planilha precisamos de ler_csv usamos from meu_app.ler import ler_csv dentro de gerar_xl.

Podemos ainda usar um atributo especial, __path__ contendo uma lista com todos os caminhos onde existem pacotes. Desta forma subpacotes que são partes de um pacote central podem estar distribuídos em diretórios quaisquer.

Função dir()

A função built-in dir() retorna uma lista de nomes definidos em um namespace. Sem argumentos ela retorna a lista, em ordem alfabética, de todos os nomes definidos na tabela de símbolos local. Abaixo dir() é usado logo após a inicialização do python, após a importação do módulo math e depois da criação de uma nova classe.

$ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26) 
[GCC 7.3.0] :: Anaconda, Inc. on linux
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__']

# importando math
>>> import math
>>> dir()
['__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'math']

# se definirmos uma classe ou definirmos uma variável
>>> class Nova():
...     pass
>>> x =1010
>>> dir()
['Nova', '__annotations__', '__builtins__', '__doc__', '__loader__', '__name__', '__package__', '__spec__', 'math', 'x']

Dessa forma a função é útil para mostrar o que foi inserido na tabela após uma importação ou qualquer outra ação do usuário. Se um um módulo for passado como argumento dir(modulo) lista os nomes dentro desse módulo.

>>> dir(math)
['__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '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']

Classes, Módulos e Pacotes

No python um módulo pode conter diversas classes (diferente do Java ou C#), funções e definições de constantes. Módulos muito grandes, com muitas classes, podem ser divididos, principalmente se existirem objetivos diferentes em suas classes. Módulos não precisam ser parte de um pacote. Um pacote pode conter diversos módulos, com funcionalidade e objetivo semelhante.

Além de tornar o código mais estruturado e fácil de ser lido (protanto mais fácil de manter) a estrutura de classes, módulos e pacotes favorece a reutilização de código. Essa prática está em acordo com o princípio DRY (“Don’t Repeat Yourself” ou “Não se repita”). A abordagem DRY para programação se refere à agrupar partes repetidas do código em funções, classes, módulos ou pacotes (dependendo da complexidade) que podem ser chamadas em partes diversas do projeto.

Compilando o código

Usamos aqui o termo “compilar” significando “gerar um bytecode”.

Quando executamos um aplicativo ou módulo do python apenas os módulos importados são armazenados como bytecodes. Para forçar a compilação e armazenamento podemos usar py_compile e compileall.

py_compile(arquivo.py) Compila arquivo.py para bytecode
compileall.compile_dir(dir) Compila todos os arquivos no diretório para bytecode
compileall.compile_file(arquivo.py) Compila arquivo.py para bytecode
compileall.compile_path() Compila arquivos em sys.path para bytecode

Esses módulos são rodados no terminal do sistema operacional:

# para compilar um arquivo
$ python -m py_compile arquivo_compilar.py

# para compilar arquivos em um diretório
$ python -m compileall.compile_dir caminho_da_pasta

Com esses processos (ou durante a execução usual do python) os arquivos *.pyc são colocados na pasta “__pycache__” com o nome arquivo_compilar.cpython-39.pyc, onde cpython-xx indica que foram compliados sob cpython-xx, sendo xx a versão do cpython. Esses arquivos podem ser executados com:

$ cd __pycache__    
$ python arquivo_gerado_bytecode.cpyton-39.pyc

Você pode entregar apenas os arquivos *.pyc para um usuário final que deve ter o interpretador do python instalado em seu computador para executá-los.

Embora python seja uma linguagem interpretada existem compiladores desenvolvidos para transformar um aplicativo em um executável. Entre eles:

  • py2exe transforma programas do Python em pacotes que podem rodar no Windows sem a necessidade de ter uma instalação do Python.
  • Nuitka the Python Compiler, um compilador compatível com python2 e python3.
  • Pyinstaller, empacota todo um aplicativo python, com suas dependências, em um pacote único. Funciona com python3.6 ou superior.

PyInstaller é um empacotador que permite ao usuário final executar o aplicativo sem instalar um interpretador python ou qualquer módulo. Ele funciona corretamente com os principais pacotes, como numpy, PyQt, Django e wxPython. Ele deve ser executado na versão específica de plataforma a que é destinado o pacote final. O PyInstaller tem sido usado com sucesso no Windows, Mac OS X, GNU/Linux, Solaris, FreeBSD e OpenBSD.

Python Eggs e Wheels


Python Eggs: Dois formatos de distribuição de aplicativos do python são os arquivos *.egg e *.whl. Eggs são uma tecnologia mais antiga, recentemente substituída pelo formato wheels. Um arquivo egg é basicamente um arquivo zipado com instruções para a instalação de um pacote. Apesar de ter sido substituído ainda existem arquivos eggs para serem baixados e instalados. Ele faz uso do pacote SetupTools, que é a forma padrão original de lidar com pacotes do PyPI (e outras fontes) usando comando de linha. Você pode criar seu próprio egg para distribuí-lo, ou instalar pacotes baixados de terceiros. (Documentação sobre eggs).

Python wheels: O formato Wheel foi criado para substituir os python eggs. Ele trabalha junto com o pip e foi projetado para conter todos os arquivos para uma instalação de maneira próxima do formato em disco após a instalação. Semelhante a um *.egg, arquivo *.whl também é um arquivo zip com extensão renomeada. Se essa extensão for renomeada (*.whl ↦ *.zip), o arquivo pode ser aberto e analisado com qualquer aplicativo zip.

O nome de uma instalação wheel contém informações sobre quais as versões do python e da plataforma são suportadas, o que é usado pelos instaladores para fazer uma escolha correta da versão do pacote a ser instalado. Existe suporte para wheels em pip >= 1.4 e setuptools >= 0.8.

Um único arquivo .whl contém todos os módulos de seu projeto, inclusive aqueles importados por ele. Uma vez criado ele pode ser carregado para um ambiente na nuvem, como o Pypi, e dali instalado por qualquer usuário que queira usá-lo. Se o arquivo se chama arquivo.whl ele pode ser instalado com o comando pip install arquivo.whl.

Para usar wheels precisamos instalá-lo. Certifique-se de que você tem uma versão recente do pip fazendo o upgrade, e instale o wheel e setuptolls, lembrando que o ideal é fazer isso em um ambiente virtual (Leia sobe Ambientes Virtuais):

# upgrade do pip    
$ pip install --upgrade pip
# instalação do wheel e setuptools
$ pip install wheel
$ pip install setuptools

Para a criação de um arquivo .whl devemos proceder da seguinte forma:

  • Copie todos os módulos e pacotes de seu projeto em um diretório raiz. Vamos chamá-lo aqui de raiz. Lembramos, como visto acima, que todos os diretórios e subdiretórios devem possuir um arquivo __init__.py. (Não obrigatório no diretório raiz.)
  • Crie um arquivo com o nome setup.py no diretório raiz. Esse arquivo deve conter um mínimo de informações como: nome da distribuição, número da versão e lista de nomes de pacotes.

Um exemplo mínimo desse arquivo seria o seguinte:

from setuptools import setup, find_packages

setup(
    # nome da distribuição
    name = 'nome_da_distribuição', 
    # versão
    version='1.0', 
  
    # definição de nomes dos pacotes e módulos (uma das 3 abordagens)
 
    # 1. se existem poucos módulos no diretório raiz
    packages = ['']
  
    # 2. liste todos os nomes dos pacotes
    packages = ['pacote1', 'pacote2', ...]
  
    # 3. encontre automaticamente todos os pacotes
    packages = find_packages()
)
  • O nome da distribuição será exibido com python -m pip list ou conda list.
  • A versão pode ser incrementada, conforme o desenvolvedor.
  • A 1ª abordagem pode ser usada quando existem poucos módulos na raiz e nenhum subdiretório com pacotes. Os módulos podem ser importados diretamente sem o uso do nome da distribuição (como import modulo_1 para importar o arquivo import modulo_1.py.
  • Na 2ª abordagem todos os pacotes estão na lista e a importação é feita como from pacote1 import modulo1 ou import pacate1.modulo1 as m1.
  • Na 3ª abordagem a lista packages é preenchida automaticamente pelo método find_packages().

Depois, no terminal, navegue até o diretório raiz e execute setup

$ cd raiz
$ python setup.py bdist_wheel

Esse procedimento cria .whl (o arquivo wheel) em um subdiretório chamado dist de raiz. (Ou seja, cria raiz/dist/nome_completo_do_wheel.wheel). Para testar seu funcionamento você pode instalá-lo com

$ pip install nome_completo_do_wheel.whl

Wheels são uma boa maneira de criar um repositório local, com as dependências para seu projeto, que pode ser instalado rapidamente. A criação de vários repositórios wheel facilitam a alternância entre versões para fins de teste. Quando usado em conjunto com os ambientes virtuais essa tecnologia permite verificar como as versões atualizadas das dependências de seu projeto podem afetar seu desepenho sem precisar baixá-las várias vezes.

Bibliografia

todos acessados em março de 2020.

Flask, parte 2


Templates do Jinja2


Um aplicativo web (ou outro qualquer) deve ser escrito de forma clara, para facilitar sua expansão e manutenção. Uma das formas usadas pelo Flask para implementar esse estratégia é a de colocar código python e html separados. Os templates, como vimos, são modelos ou estruturas básicas que podem ser preenchidas dinamicamente, de acordo com as requisições. Esse é o chamado de modelo de separação entre lógica de negócio e lógica de exibição (business and presentation logic). Templates são tratados por um dos módulos que compõem o Flask: o módulo Jinja2.

Um exemplo básico de template para a exibição de um artigo poderia ser o seguinte:

# template.html
<h1>{{ titulo }} </h1>
<p>{{ autor }}, {{ data }} </p>
<p>{{ texto_do_artigo }} </p>
<p>{{ pe_de_pagina }} </p>

Os campos {{ variavel }} são chamados de localizadores (placeholders) para os valores que serão passados pelas funções view. Em muitos casos as informações usadas para popular essas variáveis são lidas em um banco de dados.

Já vimos o exemplo:

@app.route(“/frutas/<nome_da_fruta>”)
def frutas(nome_da_fruta):
return render_template(“frutas.html”, nome_da_fruta = nome_da_fruta)

onde /frutas/<nome_da_fruta>, fornece o valor da varíavel passada para o parâmetro da função (em vermelho). Dentro do corpo da função a variável de mesmo nome (em verde) recebe esse valor. Esses nomes não precisam ser os mesmo, embora esse seja uma prática comum entre programadores do python.

O método render_template() é parte do Jinja2 para integrar templates e lógica do aplicativo.

Filtros


Variáveis e objetos do python podem ser integrados nos templates de algumas formas. Por meio do módulo Jinja temos diversos filtros para manipular campos em templates. Já vimos como inserir uma variável em um template. Um exemplo de filtro é title(), que torna a string no formado de título, com a primeira letra de cada palavra em maísculo.

# suponha que temos a variável titulo = "a casa da mãe joana"
# essa string pode ser exibida dentro de uma tag <h1>
<h1> {{ titulo }} </h1>
↳ a casa da mãe joana

# para maísculas na primeira letra de cada palavra
<h1> {{ titulo | title() }} </h1>
↳ A Casa Da Mãe Joana

Uma descrição dos filtros para texto está na tabela abaixo.

Filtro Descrição
capitalize Converte 1º caracter em maiúsculo, os demais em minúsculo
lower Converte todos os caracteres minúsculo
upper Converte todos os caracteres maiúsculo
title Converte 1º caracter de cada palavra em maiúsculo
trim Remove espaços em branco no início e no fim
safe Renderiza o valor sem aplicar escape (inclui tags)
striptags Remove todas as tags HTML do valor

safe: O filtro safe informa ao Flask que a tag html pode ser renderizada com segurança. Mais exemplos abaixo.
striptags: remove as tags <tag> e </tag> e retorna o texto puro.

Exemplos de Filtros em Strings

Suponha que temos uma variável de nome titulo. Nos templates ela pode ser exibida diretamente, como uma string, ou passando por algum dos vários filtros. Nos quadros seguintes os comentários são iniciados por # enquanto outputs são identificados pelo sinal .

# suponha que a variável titulo2 não está definida
# default fornece um valor default (se titulo2 não está definido).
<h1> {{titulo2 | default ("Título Não Encontrado")}} </h1>
↳ Título Não Encontrado

# torna maiúscula a primeira letra
<h1> {{"mercado" | capitalize()}} </h1>
↳ Mercado

# em linha anterior ao uso podemos definir um valor
# capitalize torna maiúscula a 1ª letra de cada palavra
{% set titulo2 = "um título para a página" %}
<h1> {{ titulo2 | capitalize()}} </h1>
↳ Um título para a página

# title() torna maiúscula a 1ª letra de cada palavra
<h1> {{titulo2 | title()}} </h1>
↳ Um Título Para A Página

# substituir um trecho em uma string
{{ "Bom dia galera!" | replace("Bom dia", "Boa noite") }}
↳ Boa noite galera!

# inverter a ordem dos elementos
{{ "Olá galera!" | reverse() }}
↳ !arelag álO

Conversores

Por default os valores passados em uma url e capturados como valores do python são strings. Alguns conversores podem transformar essas strings em caminhos (que usam barras / ), inteiros ou decimais.

@app.route("/usuario/<int:id>")
def exibir_id(id):
    # esta função recebe id como um inteiro e o exibe
    return f"O id digitado é {id}"

@app.route("/path/")
def exibir_caminho(caminho):
    # recebe e retorna o caminho passado
    return f"Caminho {caminho}"

Os seguintes conversores estão disponívies:

string (default) qualquer string sem barras / ou \
int converte em inteiros positivos
float converte em números decimais
path strings contendo barras de caminho
uuid strings UUID†

† Uma string UUID (Universally Unique IDentifier), também chamada de GUID (Globally Unique IDentifier) é um número de 128-bits usado na troca de informações em computação.

Valores numéricos podem ser convertidos entre inteiros e decimais, e um valor default ser fornecido.

# números inteiros podem ser convertidos em decimais, ou decimais em inteiros
{{ 10 | float() }}
↳ 10.0 ou 0.0      # 0.0 se a conversão não for possível
{{ 10.0 | int() }}
↳ 10

# um valor default, em caso de erro
{{ "qualquer" | float (default = "Erro: texto não pode ser convertido em decimal") }}
↳ Erro: texto não pode ser convertido em decimal

Manipulação de Listas

Diversas operações são disponíveis em listas.

# join: junta elementos de uma lista
{{ [1, 2, 3] | join() }}
↳ 123
{{ ["Um", "Dois", "Tres"] | join() }}
↳ UmDoisTres

# inserindo um separador
{{ [1, 2, 3] | join ("|") }}
↳ 1|2|3
{{ ["Um", "Dois", "Tres"] | join("-") }}
↳ Um-Dois-Tres

# o filtro list() retorna uma lista
{{ "Guilherme" | list()}}
↳ ["G","u","i","l","h","e","r","m","e"]

# random() seleciona um item aleatorio da lista
{{ ["Mercúrio", "Venus", "Terra"] | random() }}
↳ Venus

{% set pe_pagina = ["citacao 1", "citacao 2", "citacao 3", "citacao 4", "citacao 5"] %}
{{ pe_pagina | random() }}
↳ citacao 4

# replace (visto acima para strings) também pode ser usado em listas
{% set lista = ["Nada", "a", "dizer"] %}
{{ lista | replace ("Nada", "Tudo") }}
↳ ["Tudo", "a", "dizer"]

# o filtro reverse() também pode inverter uma lista
# mas seu resultado é um objeto iterador
{% set lista = ["unidade", "dezena", "centena"] %}
{{ list | reverse() }}
↳ <list_reverseiterator object at 0x7fc0b6262518>

# para usar o objeto lista sem usar iterações temos que usar o método list()
{{ list | reverse() | list() }}
↳ ["centena", "dezena", "unidade"]

O filtro random() pode ser útil para exibir um artigo aleatório do site na homepage, para escolher uma imagem ou um pé de página, etc.

Outros exemplos de manipulação de listas incluem o uso de first(), last(), uso de índices e de laços para percorrer toda a lista.

# first() é 1º elemento da lista, last() é o último elemento
{% set nomes = ["João", "Pedro", "da", "Silva"]  %}
<p> Nome: {{ nomes | first() }} </p>
<p> Segundo Nome: {{ nomes [1] }} </p>
<p> Sobrenome: {{ nomes | last() }} </p>
↳ Nome: João
↳ Segundo Nome: Pedro
↳ Sobrenome: Silva

# o tamanho de uma lista é retornado com {{ lista | length }}
# laços for são usados para percorrer os elementos
{% set comentarios = ["Comenta 1", "Comenta 2", "Comenta 3", "Comenta 4"]%}
<p>Temos ({{comentarios | length}}): comentários</p>
{% for comentario in comentarios %}
    <p> {{ comentario }} </p>
{% endfor%}
# resulta em
↳ Temos 4: comentários
↳ Comenta 1
↳ Comenta 2
↳ Comenta 3
↳ Comenta 4

O filtro safe

O filtro safe serve para passar para o interpretador do Flask a informação de que as tags html devem ser renderizadas. Sem ele a string "<texto>" é exibida literalmente, inclusive com os delimitadores "<>".

Os códigos &lt; (<) e &gt; (>) são entidades html, descritas nesse site.

Por motivo de segurança o Jinja2 remove as tags html. Por exemplo: uma variável com valor "<li> TEXTO </li>" será renderizada como "&lt;li&gt; TEXTO &lt;/li&gt;" por extenso e sem provocar a renderização do navegador. Com o filtro safe o TEXTO é exibido como um ítem de lista.

# exibição literal de uma string
{{ "<b>Texto a exibir!</b>" }}
↳ <b>Texto a exibir!</b>

# para forçar a renderização da tag <b> (negrito)
{{ "<b>Texto a exibir!</b>" | safe }}
↳ Texto a exibir!

# define uma lista
{% set lista = ["<li>Um elefante</li>", "<li>Dois elefantes</li>", "<li>Três elefantes</li>"] %}
<ul>
{% for item in list %}
    {{ item | safe }}
{% endfor %}
</ul>
# será renderizado como
↳
⏺ Um elefante
⏺ Dois elefantes
⏺ Três elefantes

# alternativamente
{% set lista = ["Um elefante", "Dois elefantes", "Três elefantes"] %}
<ul>
{% for item in list %}
    <li> {{ item }} </li>
{% endfor %}
</ul>
# será renderizado da mesma forma.
# Nesse caso não existem tags na lista e safe é desnecessário.

Observação importante: Qualquer input digitado por usuários deve passar pelo filtro safe para evitar que alguma instrução danosa seja processada pelo navegador.

Laços e bifurcações


Vimos que um template recebe variáveis do python e pode processá-las com código. Por exemplo, modificamos o template frutas.html da seguinte forma:

# frutas.html
<body>
    {% if nome_da_fruta == None %}
        <p>Você não escolheu uma fruta!</p>
    {% elif nome_da_fruta == "laranja" %}
        <p>Você escolheu laranja, a melhor fruta!</p>
    {% else %}
        <p>Você escolheu a fruta: {{ nome_da_fruta }}</p>
    {% endif %}
</body>

No código de meu_site.py modificamos a função frutas para que por default ela receba None (caso nada seja escrito após o nome do diretório /frutas/:

# meu_site.py (apenas trecho)
@app.route("/frutas/")
@app.route("/frutas/<nome_da_fruta>")
def frutas(nome_da_fruta=None):
    return render_template("frutas.html", nome_da_fruta=nome_da_fruta)

Agora temos as respostas:

url resposta no navegador
http://127.0.0.1:5000/frutas/ Você não escolheu uma fruta!
http://127.0.0.1:5000/frutas/laranja Você escolheu laranja, a melhor fruta!
http://127.0.0.1:5000/frutas/goiaba Você escolheu a fruta: goiaba

No template, os trechos entre chaves não são parte do html e sim do Python, gerenciado pelo Flask. Dessa forma podemos integrar as páginas da web com as inúmeras bibliotecas do Python.

Quatro tipos de marcações estão disponíveis para para inserção do código nos templates.

sintaxe usadas para
{% ... %} linhas de instruções
{{ ... }} expressões
{# ... #} comentários
# ... ## instruções inline

Variáveis

Variáveis a serem usadas nos templates podem ser de qualquer tipo. Por exemplo:

{% set nomes = ["João", "Pedro", "da", "Silva"]  %}
<p>O segundo nome da lista: {{ nomes[1] }}.</p>
↳ O segundo nome da lista: Pedro.

{% set id = 3 %}
<p>Quem? {{ nomes[id] }}!</p>
↳  Quem: Silva!

# uso de um dicionário
{% set dicionario = {"Nome":"Paul"; "Sobrenome":"Dirac"; "Profissão":"Físico"}  %}
<p>Estes são os dados do: {{ dicionario["Nome"] }}.</p>
{% for chave, valor in dicionario.items() %}
    <p>{{ chave }} : {{ valor }}</p>
{% end for %}
↳ Estes são os dados do: Paul.
↳ Nome: Paul
↳ Sobrenome: Dirac
↳ Profissão :Físico

# no caso geral, se um objeto é passado para o template e tem um método, podemos usar:
<p>Obtendo um valor com um método de objeto disponível: {{ objeto.metodo() }}.</p>

Incluindo trechos com include

Se uma parte do template é repetida várias vezes ela pode ser colocada à parte, em arquivo separado, e incluída na template principal. Por ex., se temos um pé de página que aparece em diversas de nossas páginas ele pode ser gravado à parte.

# arquivo pe_de_pagina.html
<div class="pe_pagina">
<p>Esté é o meu pé de página</p>
</div>

Esse código assume que existe a definição de uma classe css chamada pe_pagina.
Em todos os arquivos que devem exibir o pé de página inserimos:

# todo o texto da página
# pé de página
{% include 'pe_de_pagina.html' %}

Macros

Macros são formas de implementar o princípio DRY (Don’t Repeat Yourself ), uma prática de desenvolvimento de software que visa reduzir a repetição do linhas semelhantes ou com mesma função no código. Ele torna o código mais legível e de fácil manutenção, uma vez que alterações devem ser feitas em um único bloco. Templates, macros, inclusão de conteúdo externo (como em include()) e heranças de modelos são todos instrumentos utilizados para isso.

Outra forma de gerar código reutilizável é através da criação de macros, um recurso similar às funções usuais do Python. Macros podem ser gravadas em arquivos separados e importadas dentro de todos os templates que fazem uso delas, facilitando a modularização do código.

Uma macro pode executar tarefas simples como simplesmente montar as linhas da lista. Suponha que temos uma lista de linhas e queremos montar uma lista não ordenada em html:

 {% macro montar_lista(linha) %}
     <li>{{ linha }}</li>
 {% endmacro %}
 <ul>
 {% for linha in linhas %}
     {{ montar_lista(linha) }}
 {% endfor %}
 </ul>

Outra possibilidade importante para a modularização consiste em gravar um arquivo macros.html que contém a macro vermelho(texto, marcas). Ele retorna linhas de uma lista coloridas de vermelho se texto == marcas, de azul caso contrário.

# arquivo macros.html
{% macro vermelho(texto, marcar) %}
{% if texto == marcar %}
    <li style="color:red;">{{ texto }}</li>
{% else %}
    <li style="color:blue;">{{ texto }}</li>
{% endif %}
{% endmacro %}

O arquivo mostrar_frutas.html importa a arquivo anterior, com a sua macro, e faz uso dela para exibir a lista ordenada.

# arquivo mostrar_frutas.html
{% from "macros.html" import vermelho %}
{% set frutas = ["Abacate", "Abacaxi", "Laranja", "Uva"]  %}
{% set selecionado = "Abacaxi"  %}
<ol>
 {% for fruta in frutas %}
     {{ vermelho(fruta, selecionado) }}
 {% endfor %}
</ol>

O resultado no navegador é o seguinte:

Assim como é válido no Python, podemos fazer a importação de forma alternativa (mostrando só linhas diferentes):

# arquivo mostrar_frutas.html
{% import "macros.html" as macros %}
{% for fruta in frutas %}
    {{ macros.vermelho(fruta, selecionado) }}
{% endfor %}

Herança de Templates

Similar à herança de classes no modelo POO do python, podemos criar um template base e derivar dele outros templates que herdam a sua estrutura. Os templates base definem blocos que podem ser sobrescritos nos templates filhos. Um template base pode ter a seguinte estrutura:

# arquivo base.html    
<html>
<head>
 {% block head %}
 <title> Artigo {% block title %}{% endblock %} </title>
 {% endblock %}
</head>
<body>
 {% block body %}
 {% endblock %}
 {% block final %}
 <p>Site construído com Python-Flask!</p>
 {% endblock %} 
</body>
</html>

A instrução {% block nome_do_bloco %}{% endblock %} pode ser substituída por conteúdo nos templates filhos (ou derivados). Para herdar desse template usamos extends e redefinimos os blocos da base:

# arquivo derivado.html    
{% extends "base.html" %}
{% block title %}Nome do Artigo{% endblock %}
{% block head %}
<style>
# estilos css ficam aqui
</style>
{% endblock %}
{% block body %}
<h1>Nome do Artigo</h1>
<p>Texto do artigo</p>
{% endblock %}
{% block final %}
 {{ super() }}
{% endblock %}

O bloco final, {% block final %}{{ super() }}{% endblock %}, usa super() para simplesmente importar o conteúdo do arquivo base. Se a base e o derivado contém texto o conteúdo da base é sobreposto.

Solicitações e Respostas do Servidor (Request, Response)

† O que é uma thread?

Ao receber uma solicitação de um cliente o Flask responde passando para as funções de visualização (view functions) os objetos que serão usados para a construção da página web. Um exemplo é o objeto request, que contém a solicitação HTTP enviada pelo cliente. Temos que nos lembrar que o aplicativo pode receber um grande volume de solicitações em múltiplas threads. Para evitar que todas as funções de visualização recebam essas informações como parâmetros, o que pode tornar o código complexo e gerar conflitos o Flask usa contextos para que esses objetos fiquem temporariamente acessíveis dentro desses contextos.

Para os exemplos que se seguem vamos trabalhar usando o python no terminal. Para isso ativamos o ambiente virtual e o próprio python:

$ cd ~/caminho_para_o_projeto
$ source ./bin/activate
# o prompt muda para indicar ativação de venv
$ (venv) $ python
Python 3.9.10 (main, Jan 17 2022, 00:00:00)

Graças aos contextos, funções de visualização como a seguinte podem ser escritas:

from flask import request
@app.route('/')
def index():
    navegador = request.headers.get('User-Agent')
    return f'<p>Verificamos que seu navegador é o {navegador} </p>'

Que retorna no navegador (no meu caso):

Contextos: Note que request não foi passado explicitamente para index(), agindo como se fosse uma variável global. Isso é obtido pelo Flask com os contextos ou ambientes reservados. Dois ambientes são usados: o contexto de aplicativo e o contexto de requisição.

As seguintes variáveis existem nesses contextos:

nome da variável Contexto Descrição
current_app Aplicativo A instância do aplicativo ativo.
g Aplicativo Objeto usado pelo aplicativo para armazenamento de dados durante uma requisição. Resetada a cada requisição.
request Requisição Objeto que encapsula o conteúdo da requisição HTTP enviada pelo cliente.
session Requisição Representa a sessão do usuário, um dicionário que o aplicativo usa para armazenar valores entre requisições.

Esses contextos só estão disponíveis quando o aplicativo recebe uma requisição (por meio de uma url digitada no navegador). O Flask ativa o contexto de aplicativo disponibilizando current_app e g, e o contexto de requisição disponibilizando request e session, para a thread, e em seguida os remove. Um exemplo desse comportamento pode ser visto no código, executado dentro do python:

from meu_site import app
from flask import current_app
current_app.name                     # um mensagem de erro é exibida
                                     # pois o contexto não está ativo
RuntimeError: working outside of application context

app_contexto = app.app_context()
app_contexto.push()                 # o flask ativa o contexto
current_app.name                    # o nome do app é impresso
'meu_site'
app_contexto.pop()

Flask usa os métodos objeto.push() e objeto.pop() para iniciar e terminar um contexto. A variável current_app.name só existe enquanto o contexto está ativado.

Preparando uma resposta

Para responder a uma solicitação o Flask armazena um mapa que associa URLs (e suas partes) à função resposta que deve ser executada. Esse mapa está armazendo em app.url_map.

from meu_site import app
app.url_map
Map([<Rule '/contatos/' (HEAD, OPTIONS, GET) -> contatos>,
 <Rule '/frutas/' (HEAD, OPTIONS, GET) -> frutas>,
 <Rule '/' (HEAD, OPTIONS, GET) -> index>,
 <Rule '/frutas/<nome_da_fruta>' (HEAD, OPTIONS, GET) -> frutas>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>])

A lista mostra o mapeamento das funções view que criamos e mais um, denominado /static, acrescentado automaticamente para acessar arquivos estáticos como arquivos de estilo (cascading style sheets, *.css) e imagens. Os elementos (HEAD, OPTIONS, GET) são passados dentro da URL. Os dois primeiros são gerenciados internamento pelo Flask.

Objeto request

O objeto request, armazenado na variável request, contém toda a informação passada na requisição pela URL. Os atributos e métodos mais comuns desse objeto são listados a seguir.

Atributo/Método Descrição
form Dicionário com todos os campos de form submetidos na requisição.
args Dicionário com todos os argumentos passados na string de pesquisa da URL.
values Dicionário com valores combinados de form e args.
cookies Dicionário com todos os cookies incluídos na requisição.
headers Dicionário com todos os cabeçalhos HTTP incluídos na requisição.
files Dicionário com todos os arquivos de upload incluídos na requisição.
get_data() Retorna dados em buffer na requisição.
get_json() Retorna dicionário com dados JSON incluído na requisição.
blueprint Nome do blueprint que está processando a requisição.
endpoint Nome do endpoint processando a requisição. Flask usa a função view como nome do endpoint para um caminho.
method Método da requisição HTTP, (GET ou POST).
scheme Esquema da URL (http or https).
is_secure() Retorna True se a requisição veio de conexão segura (HTTPS).
host O host definido na requisição, incluindo o número da porta se fornecido pelo cliente.
path Parte da URL que define o caminho.
query_string Parte da URL que define a string de pesquisa (query), como um valor binary (raw).
full_path Parte da URL que define caminho e pesquisa (query).
url Requisição completa da URL fornecida pelo cliente.
base_url O mesmo que url, sem a parte de pesquisa (query).
remote_addr Endereço de IP do cliente.
environ Dicionário com o ambiente de WSGI da requisição.
(†) Blueprints serão vistos mais tarde.

Hooks de Solicitação (request hooks)

Com o Flask podemos registrar funções que devem ser chamadas antes ou depois de uma solicitação. Essas funções podem ser usadas para executar tarefas úteis, tais como autenticar um usuário, abrir e fechar a conexão com um banco de dados, etc. Quatro ganchos (hooks) são disponibilizados:

before_first_request Registra função para execução antes da primeira requisição. Útil para tarefas de inicialização do servidor.
before_request Registra função a ser executada antes de cada requisição.
after_request Registra função a ser executada após cada requisição, caso não ocorram exceções não tratadas.
teardown_request Registra função a ser executada após cada requisição, mesmo que ocorram exceções não tratadas.

Um exemplo de uso desses hooks seria o de usar before_request para coletar dados que serão usados ao longo do ciclo de vida do aplicativo para um usuário e os armazenar na variável g para uso posterior.

Como vimos, uma requisição resulta em uma resposta por meio de uma das funções view enviada ao cliente. Ela pode ser uma página simples de html construída com auxílio dos templates ou algo mais complexo. Junto com a resposta, de acordo com o protocolo HTTP, é enviado um código de status (status code) indicando o sucesso da solicitação. Por default o Flask retorna o status_code = 200 para solicitação bem sucedida. Podemos usar uma função view para retornar outro código.

@app.route('/')
def index():
    return 'Ocorreu um erro!', 400
42 é a resposta do Guia do Mochileiro das Galaxias!

A função acima retorna uma tupla com uma string e um inteiro. É possível e útil fazer com que essas funções retornem um objeto response no lugar da tupla.

from flask import make_response
@app.route('/')
def index():
   response = make_response('Resposta Final sobre o Universo!')
   response.set_cookie('resposta', '42') return response

Dessa forma response passa a conter um cookie (que é gerenciado pelo navegador que o recebe). A tabela seguinte mostra métodos e atributos mais usados no objeto response.

Métodos e atributos do objeto response

Atributo/Método Descrição
status_code Código numérico de status do HTTP
headers Objeto tipo dicionário com todos os cabeçalhos a serem eviados em response
set_cookie() Acrescenta um cookie no objeto response
delete_cookie() Remove um cookie
content_length Comprimento do corpo da response
content_type Tipo de midia do corpo da response
set_data() Define o corpo da response como string ou bytes
get_data() Retorna o corpo da response


Dois tipos de resposta especiais para casos que ocorrem com frequência são fornecidos como funções auxiliares. Uma delas é uma forma de lidar com erros, eviando um código por meio do método abort(). No exemplo abaixo usamos essa função para enviar uma mensagem 404 (página não encontrada) caso a id de um usuário não seja encontrada com load_user(id).

from flask import abort
@app.route('/usuario/<id>')
def usuario(id):
    usuario = load_user(id)
    if not usuario:
        abort(404)
    return f'<div class="usuario">Bem vindo {usuario.name}</div>'

Esse exemplo supõe que exista um template para usuario e que ele carrega instruções css para a classe usuario.

Se load_user(id) retornar None é executada abort(404) que retorna a mensagem de erro. Uma exceção é levantada e a função usuario() é abandonada antes de atingir a instrução return.

O outro tipo de resposta é o redirect que não retorna uma página mas sim uma nova URL redirecionando o navegador. redirect retorna status_code = 302 e a nova URL (dada no código).

from flask import redirect
@app.route('/')
def index():
    return redirect('https://phylos.com/programacao')

Bibliografia

Veja a bibliografia na Parte 1.


Flask, Parte 3 está em preparação!

Flask, parte 1


Web Frameworks

Para a construção de páginas e aplicativos web é essencial algum conhecimento de html e css, não cobertos nesse artigo.

Um aplicativo web web application ou simplesmente web app) é um aplicativo executado através de um servidor web, diferente de um aplicativo executado na máquina local, e geralmente rodados e visualizados por meio de um browser ou navegador. Eles podem ser um conjunto de páginas de texto dinâmicas contendo, por exemplo, pesquisas em uma biblioteca, um gerenciador de arquivos na nuvem, um gerenciador de contas bancárias ou emails, um servidor de músicas ou filmes, etc. Com frequência esses aplicativos estão conectados a um banco de dados, podendo fazer neles consultas e modificações.

Um framework para a web é um conjunto de softwares destinados a oferecer suporte ao desenvolvimento de aplicativos na Web. Eles buscam automatizar a construção dos web apps e seu gerenciamento durante o funcionamento, após sua publicação na web. Alguns frameworks web incluem bibliotecas para acesso a banco de dados, templates para a construção dinâmica das páginas e auxílio à reutilização de código. Eles podem facilitar o desenvolvimento de sites dinâmicos ou, em alguns casos, de sites estáticos.

Framework Flask

O Flask é um micro-framework em Python usado para desenvolvimento e gerenciamento de web apps. Ele é considerado micro porque possui poucas dependências para seu funcionamento e pode ser usado com uma estrutura inicial bem básica, voltada para aplicações simples. Apenas duas bibliotecas são instaladas junto com o Flask. Ele não contém, por ex., um gerenciador de banco de dados ou um servidor de email. No entanto esses serviços podem ser acrescentados para ampliar as funcionalidades do aplicativo.

Em particular, o Flask não inclui uma camada de abstração com banco de dados. Em vez disso é possível instalar extensões, escolhendo o banco de dados específico que se quer usar. Essas extensões também podem auxiliar a validação de formulário, manipulação de upload, tecnologias de autenticação aberta, entre outras.

Flask foi desenvolvido por Armin Ronacher e lançado em 2010. Algumas vantagens citadas em seu uso são: (a) projetos escrito com a Flask são simples (comparados àqueles gerados por frameworks maiores, como o Django) e tendem a ser mais rápidos. (b) Ele é ágil e modular: o desenvolvedor se concentra apenas nos aspectos utilizados de seu aplicativo, podendo ampliar sob demanda. (c) Os projetos são pequenos mas robustos. (d) Existe uma vasta comunidade de desenvolvedores contribuindo com seu desenvolvimento, apresentando bibliotecas que ampliam sua funcionalidade.

Criando um aplicativo básico

Nessa primeira parte vamos criar um aplicativo bem básico mas funcional. Depois entraremos em outros detalhes do Flask.

Para criar um projeto usando o Flask (ou, na verdade, outro projeto qualquer) é aconselhável criar antes um ambiente virtual para que os pacotes instalados não conflituem com outros em projetos diferentes. A criação e manutenção de ambientes virtuais está descrita na página Ambientes Virtuais, PIP e Conda. Alguns IDEs, como o Pycharm realizam automaticamente esse processo. No meu caso ele ficará abrigado em ~/Projetos/flask/venv. Para simplificar denotarei esse ambiente simplesmente pelo nome flask, que é também o nome da pasta que abriga esse projeto.

Nesse ambiente instalamos o Flask com pip install flask. Uma estrutura básica de ambiente já estará montada após esse passo. Em seguida criamos um arquivo do python, de nome meu_site.py.

# meu_site.py
from flask import Flask
app = Flask(__name__)

@app.route("/")
def homepage():
    return "Esta é a minha homepage"

if __name__ == "__main__":
    app.run()

# é exibido no console do python
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Esse arquivo não deve se chamar flask.py para evitar conflito de nomes.

A variável __name__, passada para o construtor do Flask contém o nome do módulo principal e é usada para determinar a localização do aplicativo no app. Outros diretórios e arquivos, como pastas de templates, arquivo de estilo e imagens, serão localizadas à partir dessa raiz.

Aplicativos do Flask incluem um servidor de desenvolvimento que pode ser iniciado com o comando run. Esse comando busca pelo nome do aplicativo na variável de ambiente FLASK_APP. Se queremos rodar o aplicativo meu_site.py executamos na linha de comando:

# Linux ou macOS
(venv) $ export FLASK_APP=meu_site.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

# Microsoft Windows
(venv) $ set FLASK_APP=meu_site.py
(venv) $ flask run
 * Serving Flask app "hello"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
 
 # alternativamente se pode iniciar o servidor com
 app.run()

No Pycharm, ou outros IDES, você pode executar diretamente esse código.

Da biblioteca flask importamos apenas (por enquanto) a classe Flask. Uma instância da classe é criada com app = Flask(__name__) onde a variável __name__ contém o nome do projeto. A linha @app.route("/") é um decorador que informa que a função seguinte será rodada na raiz / do site. Quando esse arquivo .py é executado dentro de uma IDE ou usando python meu_site.py, na linha de comando, é exibido no console várias mensagens, entre elas a url http://127.0.0.1:5000/, que pode ser clicada ou copiada para a linha de endereço do navegador. Isso resulta na exibição, dentro do navegador, da página:


Clientes e Servidores: O navegador age como o cliente que envia ao servidor uma solicitação, através de uma URL digitada na barra de endereços. O servidor da web transforma essa solicitação em ações a serem realizadas do lado do servidor e retorna uma página com conteúdo de texto e multimídia, renderizados pelo navegador. O Flask fica do lado do servidor, construindo a resposta. Entre outras coisas ele possui um mapeamento entre as URLs e as funções route() que serão executadas no código *.py.

O endereço e a porta 127.0.0.1:5000 são padrões para o Flask. app.run() cria um servidor que atende à requisição HTTP do navegador, exibindo a página html. Qualquer texto retornado pela função homepage() é renderizado no formato html e exibido no navegador. Por exemplo, se fizermos as alterações, colocando o texto entre tags h1:

@app.route("/")
def homepage():
    return "<h1>Esta é a minha homepage</h1>"

if __name__ == "__main__":
    app.run(debug=True)

o texto agora é renderizado como um título de nível 1:

o mesmo texto será exibido mas agora com formatação de título, a tag h1. Todas as demais tags podem ser utilizadas. O parâmetro debug=True faz com que alterações no código sejam imediatamente repassadas para as requisições ao servidor, sem a necessidade de rodar todo o projeto novamente. Com isso basta recarregar a página do navegador para que alterações sejam exibidas, clicando no ícone de atualização ou pressionando F5. No mode debug os módulos dois módulos chamados reloader e debugger estão ativados por default. Com o debugger ativado as mensagens de erro são direcionadas para a página exibida. O mode debug nunca deve ser ativado em um servidor em produção pois isso fragiliza a segurança do site.

Também podemos ativar o módulo no código que executa o aplicativo:

(venv) $ export FLASK_APP=meu_site.py
(venv) $ export FLASK_DEBUG=1
(venv) $ flask run

O decorador @app.route("/") registra a função homepage() junto com a página raiz do site. Outras páginas vão executar outras funções. Por exemplo, uma página de contatos pode ser inserida por meio da inserção de nova função no código. Nesse caso criaremos a função contatos().

# meu_site.py

from flask import Flask
app = Flask(__name__)

@app.route("/")
def homepage():
    return "<h1>Esta é a minha homepage</h1>"

@app.route("/contatos")
def contatos():
    txt = (
    "<h1>Página de Contatos</h1>"
    "<ul>"
    "<li>Contato 1</li>"
    "<li>Contato 2</li>"
    "</ul>"
    )
    return txt

if __name__ == "__main__":
    app.run(debug=True)

Usamos acima a concatenação de string com parênteses: (str1 str2 ... strn).
Agora, além da homepage temos a página de contatos em 127.0.0.1:5000/contatos, com a seguinte aparência.

A funções contatos() e homegage() são chamadas funções de visualização (view functions).

Html e templates: Notamos agora que o código em meu_site.py contém sintaxe misturada de Python e html e pode ficar bem complexo em uma página real exibida na web. Para evitar isso o Flask permite a criação de templates. Fazemos isso da seguinte forma: no diretório raiz onde está o projeto (o mesmo onde foi gravado meu_site.py) criamos o diretório template (o nome default do Flask). Dentro dele colocamos nossos templates. Por exemplo, criamos os arquivos homepage.html,

# homepage.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Homepage: teste Flask</title>
</head>
<body>
    <h1>Este é o título da Homepage</h1>
    <p>Com os devidos parágrafos...</p>
</body>
</html>

e contatos.html:

# contatos.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Contatos</title>
</head>
<body>
    <h1>Página de Contatos</h1>
    <ul>
    <li>Contato 1</li>
    <li>Contato 2</li>
    </ul>
    </body>
</html>

Vários IDEs podem auxiliar na criação desses arquivos html, fornecendo um esqueleto básico a ser preenchido pelo programador.

Além disso modificamos nosso código python para usar a renderização dos templates, importando render_template.

# meu_site.py
from flask import Flask, render_template
app = Flask(__name__)

@app.route("/")
def homepage():
    return render_template("homepage.html")

@app.route("/contatos")
def contatos():
    return render_template("contatos.html")

if __name__ == "__main__":
    app.run(debug=True)

Quando esse código é executado temos a referência ao link que, se aberto, mostra as páginas criadas: digitando 127.0.0.1:5000 abrimos nossa homepage:

Mas, se digitarmos 127.0.0.1:5000/contatos a outra página é exibida:

Uma página pode receber parâmetros do código em python. Por exemplo, digamos que queremos exibir uma página para cada produto existente em uma loja virtual que vende frutas. Nesse caso acrescentamos no código de meu_site.py:

@app.route("/frutas/<nome_da_fruta>")
def frutas(nome_da_fruta):
    return render_template("frutas.html")

Para receber esse parâmetro temos que gravar a página frutas.html na pasta templates, com um conteúdo que receba essa variável.

# frutas.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Frutas disponíveis</title>
</head>
<body>
    <h1>Frutas</h1>
    <p>Você escolheu a fruta: {{nome_da_fruta}}</p>
</body>
</html>

Se for digitado no campo de endereços do navegador, ou passado por meio de um link na tag <a href="http://127.0.0.1:5000/frutas/laranja">Laranja</a> a parte do endereço <nome_da_fruta> = laranja é passado como valor de parâmetro na função frutas("laranja") que é disponibilizado dentro do código html como {{nome_da_fruta}}.

Resumindo: @app.route("/frutas/<nome_da_fruta>") envia uma string na variável nome_da_fruta para a função frutas que, por sua vez repassa ao código html. Dentro do html a variável fica disponível como {{nome_da_fruta}} (dentro de uma dupla chave).

Por exemplo, se digitamos na barra de endereços do navegador http://127.0.0.1:5000/frutas/laranja teremos a exibição de

Essa técnica pode ser usada, por ex., para criar páginas para diversos usuários usando um único template usuario.html.

@app.route('/usuario/<nome>')
def usuario(nome):
    return render_template("usuario.html")
# ou
@app.route('/usuario/<int:id>')
    return render_template("usuario.html")

A parte do código <int:id> é um filtro que transforma a entrada digitada em inteiro, quando possível e será melhor explicada adiante.

Formatando com CSS

O texto dentro de uma página html (HyperText Markup Language) pode ser formatado de algumas formas diferentes, usando css (Cascading Style Sheets). Quando se trata do uso de um framework a forma preferida consiste em apontar no cabeçalho para um arquivo externo css. No Flask isso é feito da seguinte forma: um arquivo css é gravado na pasta static da pasta do projeto. Digamos que gravamos o arquivo /static/styles.css com um conteúdo mínimo, apenas para demonstração, tornando vermelhas as letras do título e azuis as letras dos parágrafos:

# arquivo /static/styles.css
h1 { color:red; }
p { color:blue; }

No cabeçalho das páginas html, dentro da tag <head> colocamos um link para o arquivo de estilos:

# homepage.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <link rel="stylesheet" type="text/css" href="static/styles.css">
    <title>Homepage: teste Flask</title>
</head>

Agora, ao acessar a homepage veremos:


Com todas essas alterações o projeto tem agora a estrutura de pastas mostrada. Na figura à esquerda todas as pastas, inclusive aquelas criadas pelo gerenciador de ambientes virtuais são mostradas. No meu caso elas foram criadas automaticamente pelo IDE Pycharm, mas podem ser criadas pelo programador sem dificuldade. Na figura à direita são mostradas apenas as pastas criadas pela programador diretamente. Um projeto com esse formato roda perfeitamente, apesar de não contar com as vantagens do ambiente virtual (veja artigo).

Outras estruturas de código podem ser inseridas nos templates, como veremos.

Opções de comando de linha

Quando se roda o flask diretamente no terminal podemos ver uma mensagem de ajuda com (venv) $ flask --help, verificar os caminhos definidos no app ou entrar em uma shell interativa.

# Exibir ajuda do flask    
(venv) $ flask --help
  Usage: flask [OPTIONS] COMMAND [ARGS]...
  
    A general utility script for Flask applications.
  
    Provides commands from Flask, extensions, and the application. Loads the
    application defined in the FLASK_APP environment variable, or from a wsgi.py
    file. Setting the FLASK_ENV environment variable to 'development' will
    enable debug mode.
  
      $ export FLASK_APP=hello.py
      $ export FLASK_ENV=development
      $ flask run
  
  Options:
    --version  Show the flask version
    --help     Show this message and exit.
  
  Commands:
    routes  Show the routes for the app.
    run     Run a development server.
    shell   Run a shell in the app context.
  
# exibe os caminhos ativos no aplicativo
  (venv) $ flask routes
  Endpoint  Methods  Rule
  --------  -------  -----------------------
  contatos  GET      /contatos/
  frutas    GET      /frutas/
  frutas    GET      /frutas/
  homepage  GET      /
  static    GET      /static/
  
# entra em uma shell interativa    
  (venv) $ flask shell

A shell do flask inicia uma sessão python no contexto do atual aplicativo onde podemos executar testes ou tarefas de manutenção. O comando flask run admite vários parâmetros:

(venv) $ flask run --help
  Usage: flask run [OPTIONS]
  
    Run a local development server.
  
    This server is for development purposes only. It does not provide the
    stability, security, or performance of production WSGI servers.
  
    The reloader and debugger are enabled by default if FLASK_ENV=development or
    FLASK_DEBUG=1.
  
  Options:
    -h, --host TEXT                 The interface to bind to.
    -p, --port INTEGER              The port to bind to.
    --cert PATH                     Specify a certificate file to use HTTPS.
    --key FILE                      The key file to use when specifying a
                                    certificate.
    --reload / --no-reload          Enable or disable the reloader. By default
                                    the reloader is active if debug is enabled.
    --debugger / --no-debugger      Enable or disable the debugger. By default
                                    the debugger is active if debug is enabled.
    --eager-loading / --lazy-loading
                                    Enable or disable eager loading. By default
                                    eager loading is enabled if the reloader is
                                    disabled.
    --with-threads / --without-threads
                                    Enable or disable multithreading.
    --extra-files PATH              Extra files that trigger a reload on change.
                                    Multiple paths are separated by ':'.
    --help                          Show this message and exit.  

O argumento --host informa ao servido qual é o ambiente web que pode acessar nosso servidor de desenvolvimento. Por default o servidor de desenvovimento do Flask só aceita chamadas do computador local, em localhost. Mas é possível configurá-lo para receber chamadas da rede local ou de ambientes mais amplos. Por exemplo, como o código

(venv) $ flask run --host 0.0.0.0
 * Serving Flask app "hello"
 * Running on http://0.0.0.0:5000/ (Press CTRL+C to quit)

todos os computadores conectados pelo mesmo ip terão acesso ao aplicativo.

Implantação de um aplicativo Flask

O processo de implantação (ou deploy) de um aplicativo consiste nas etapas necessários para colocá-lo acessível para seus usuários. No caso de um aplicativo web a implantação significa estabelecer um servidor ou usar servidores já disponíveis, que os usuários possam acessar, e colocar seu aplicativo como um de seus serviços.

O desenvolvimento do aplicativo se dá em um ambiente de desenvolvimento onde podem existir condições próprias para o debug e nem todas as medidas de segurança estão implementadas. Depois ele passa para a etapa de uso, no ambiente de produção. Uma conta no Heroku pode ser criada, e um site com poucos acessos pode ser mantido sem custos. Se o site for escalonado e crescer a conta deve ser atualizada e paga. A lista abaixo contém links para o Heroku e outros provedores.

Bibliografia

Livros sobre Flask

  • Aggarwal, Shalabh: Flask Framework Cookbook, 2.Ed., Packt, Birmingham-Mumbai, 2019.
  • Ashley, David: Foundation Dynamic Web Pages with Python Create Dynamic Web Pages with Django and Flask, Apress, 2020.
  • Gaspar, D.;StoufferHaider, J.: Mastering Flask Web Development, 2.Ed., Packt, Birmingham-Mumbai, 2018.
  • Grinberg, Miguel: The Flask Mega-Tutorial, Edição do autor, 2020.
  • Grinberg, Miguel: Flask Web Development, Developing Web Applications with Python, O’Reilly, Sebastopol, 2018.
  • Haider, Rehan: Web API Development With Python, CloudBytes, 2020.
  • Maia, Italo: Building Web Applications with Flask, 2.Ed., Packt, Birmingham-Mumbai, 2015.
  • Relan, Kunal: Building REST APIs with Flask, 2.Ed., Apress, 2019.

Referências na Web

Sobre HTML e CSS

todos acessados em fevereiro de 2022.


Ambientes Virtuais, PIP e Conda


Ambiente virtual

Um ambiente virtual é uma área isolada de seu computador onde pacotes específicos são instalados para o uso, sem o problema de conflitarem com outras versões instaladas. Com isso cada projeto pode ter suas próprias dependências, diferentes das possíveis dependências em outros projetos. Até mesmo versões diferentes do python podem ser usadas. Um projeto do Python pode usar diversos pacotes e módulos, sendo que alguns deles podem não estar na biblioteca padrão. Vários ambientes podem ser criados e gerenciados separadamente, sem limite na quantidade, pois são apenas diretórios contendo scripts. Esses ambientes podem ser criados usando as ferramentas no comando de linha venv, virtualenv ou pyenv. Nos concentraremos aqui na ferramenta venv.

Criando ambientes virtuais

Na construção de um aplicativo uma versão específica de uma biblioteca, ou até do próprio Python, pode ser necessária. Para isso a linguagem oferece a possibilidade de se criar ambientes virtuais: um ambiente independente armazenado em uma árvore de diretórios própria contendo a instalação do Python e pacotes em versão específica.

O módulo venv é usado para criar e gerenciar ambientes virtuais. Ele seleciona e organiza a versão do Python e dos módulos usados no projeto.

Para criar um ambiente virtual você deve decidir em que diretório ele deve ser abrigado. Depois execute o módulo venv como um script, no prompt de comando, informando o caminho do diretório. Uma boa prática é criar uma pasta oculta .venv na sua pasta raiz ou pasta de projetos para abrigar todos os seus ambientes virtuais. No exemplo criaremos a pasta ~/Projetos/.venv/aprendendo:

$ python3 -m venv ~/Projetos/.venv/aprendendo
Figura 1: estrutura de arquivos com venv.

Dentro da pasta aprendendo é criada uma estrutura de pastas contendo uma cópia do interpretador e alguns arquivos de configuração, mostrada na figura 1.

Essas pastas tem o conteúdo:

  • bin: arquivos que interagem com o ambiente virtual
  • include: cabeçalhos C que compilam os pacotes Python
  • lib: uma cópia da versão do Python junto com uma pasta site-packages onde cada dependência está instalada

Parte desses arquivos são links simbólicos (ou symlinks) que apontam para as corretas versões das ferramentas do python.

Na pasta bin ficam os scripts de ativação usados para definir o ambiente para uso. Depois de criado o ambiente pode ser ativado executando activate.

# No windows:
$ ~\Projetos\.venv\aprendendo\Scripts\activate.bat

# No Linux (bash shell)
$ source ~/Projetos/.venv/aprendendo/bin/activate

Ao ser carregado um novo ambiente o prompt de comando muda para indicar qual ambiente está em uso. Nesse prompt carregamos o Python (no meu caso 3.8.8 Anaconda), importamos a biblioteca sys e verificamos os caminhos em uso.

(aprendendo) $ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26) 
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
   ['', '/home/usr/.anaconda3/lib/python38.zip', '/home/usr/.anaconda3/lib/python3.8', '/home/usr/.anaconda3/lib/python3.8/lib-dynload', '/home/usr/Projetos/.venv/aprendendo/lib/python3.8/site-packages']
>>> sys.prefix
   '/home/usr/Projetos/.venv/aprendendo'

Esse ambiente está isolado do meio externo. Por exemplo, no meu caso o pandas está instalado globalmente. No entanto ele não pode ser apontado por um código rodando em no ambiente (aprendendo).

>>> import pandas
    Traceback (most recent call last):
      File "", line 1, in 
    ModuleNotFoundError: No module named 'pandas'

Para sair do ambiente reservado usamos deactivate no terminal.

(aprendendo) (base) [guilherme@gui ~]$ deactivate
(base) [guilherme@gui ~]$ python
Python 3.8.8 (default, Apr 13 2021, 19:58:26)
[GCC 7.3.0] :: Anaconda, Inc. on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pandas

Como o pandas está instalado globalmente, nenhuma mensagem de erro é gerada e o módulo fica disponível para uso.

A ativação de um ambiente significa a especificação do local onde estão os executáveis e bibliotecas importadas. Para reativar o ambiente nos “deslocamos” até a pasta onde ele está instalado e executamos activate.

$ cd /home/usuario/Projetos/.venv/aprendendo
$ source bin/activate
# o prompt é alterado
(aprendendo) $ python
>>> import sys
>>> sys.prefix
    '/home/guilherme/Projetos/.venv/aprendendo'
Observação Importante: Para instalar env com versão específica do Python

Pode ocorrer que existam mais de uma versão do Python instalada em seu computador. Para criar um ambiente com outra versão devevemos executar o script do venv na versão que desejamos para o ambiente virtual. Por exemplo, para um ambiente com python 3.11 (supondo que ele esteja instalado nesse computador) devemos executar:

$ python3.11 -m venv ~/Projetos/.venv/VsCode
$ source ~/Projetos/.venv/VsCode/bin/activate
$ python
>>> Python 3.11.0a7 (main, Apr  7 2022, 00:00:00) [GCC 11.2.1 20220127 (Red Hat 11.2.1-9)] on linux
>>> Type "help", "copyright", "credits" or "license" for more information.

Nesse caso criamos um ambiente virtual no diretório ~/Projetos/.venv/VsCode.

Gerenciando ambientes virtuais com pip

Uma vez dentro do novo ambiente você pode instalar pacotes usando pip que, por default, encontra e instala pacotes do Python Package Index. PIP é um gerenciador padrão de pacotes (ou bibliotecas) do Python, que acessa um reservatório de pacotes publicados no Python Package Index, ou PyPI. Em versões mais recentes ele vem instalado por default. Um pacote é um conjunto de arquivos que executam um ou várias funções. Eles podem ser importados em um aplicativo para extender a funcionalidade do Python padrão. PIP funciona por meio de comandos de linha, digitados no prompt do sistema operacional.

A sintaxe de venv é a seguinte: [parâmetros opcionais] | indice um ou outro. Apenas ENV_DIR é obrigatório e posicional.

venv [-h] [–system-site-packages] [–symlinks | –copies] [–clear] [–upgrade]
[–without-pip] [–prompt PROMPT] [–upgrade-deps] ENV_DIR [ENV_DIR …]
Cria o ambiente virtual em um ou mais diretórios especificados.
ENV_DIR Diretório onde criar o ambiente virtual,
-h, –help exibe (o presente) texto de ajuda,
–system-site-packages Dá acesso ao ambiente virtual para a pasta site-packages do sistema.
–symlinks Tenta usar symlinks no lugar de cópias, quando os symlinks não são default na plataforma.
–copies Tenta usar cópias no lugar de symlinks, mesmo que symlinks sejam o default na plataforma.
–clear Apaga o conteúdo do diretório de ambiente, se existe, antes da criação do ambiente.
–upgrade Atualiza o diretório de ambiente, caso o python do ambiente tenha sido atualizado.
–without-pip Interrompe a instalação ou atualização via pip nesse ambiente. (Por default o pip é acionado).
–prompt PROMPT Estabelece um prefixo alternativa para o prompt desse ambiente.
–upgrade-deps Atualiza as dependências do pip setuptools para o última versão disponível em PyPI.

Depois que o ambiente é criado você deve ativá-lo com o script source pasta/do/ambiente/bin/activate.

Instalação de PIP

Você pode verificar a presença do pip (ou conferir a versão) com pip --version, no prompt de comando.

$ pip --version
  pip 22.0.3 from /home/usuario/Projetos/.venv/aprendendo/lib/python3.8/site-packages/pip (python 3.8)    

O output do comando mostra a versão do pip e do python sendo usados no ambiente virtual instalado na pasta /home/usuario/Projetos/.venv/aprendendo.

Alternativamente, é possivel encontrar onde está instalado o pip:

# no Windows    
C:\> where pip3  
# no Linux
$ which pip
~/.anaconda3/bin/pip
$ which pip3
~/.anaconda3/bin/pip3

Caso o pip não esteja instalado, isso pode ser feito de duas formas: ensurepip e get-pip.py.

No prompt do terminal de seu sistema (que representaremos por $, comentários por #) digite:

$ python -m ensurepip
# ou 
$ python -m ensurepip --upgrade

A chave -m garante que pip seja executado como um módulo. Na segunda forma se garante que apenas versões mais novas que atual (se presente) seja instalada. Nenhuma ação será executa se já existe a instalação, ou se está em sua versão mais atual, no segundo caso. pip será instalado globalmente ou no ambiente virtual, se esse estiver ativo.

Outra alternativa é baixar o script get-pip.py e executá-lo com a primeiro linha no código. A segunda linha é uma forma de atualizá-lo.

# instalar pip
$ python get-pip.py
# fazer atualização de pip
$ python -m pip install --upgrade pip

Instalando módulos com o PIP

O uso geral de pip é o seguinte:

# no linux/Mac    
$ python -m pip <argumentos>
# ou
$ pip <argumentos>
# no windows    
$ py -m pip <argumentos>

Para instalar um módulo com o PIP executamos pip com o argumento install e o nome do módulo. Vários módulos podem ser instalados com uma única linha.

$ python -m pip install 
# ou    
$ pip install 
# por exemplo, para instalar o Flask
$ pip install Flask
# vários módulos
$ python -m pip install   ... 

Para controlar qual é a versão a ser instalado usamos:

python -m pip install Modulo             # instalar a última versão
python -m pip install Modulo ==1.0.4     # instalar versão especificada
python -m pip install 'Modulo >=1.0.4'   # especificar a versão mínima

É possível passar para pip uma lista de requisitos para a exata reprodução de um ambiente.

# para gerar o arquivo relativa a um ambiente:
$ python -m pip freeze > requirements.txt
# para reproduzir a instalação:
$ python -m pip install -r requirements.txt    

O arquivo requirements.txt contém uma lista dos argumentos do pip install. Essa lista pode ser gerada com freeze.

pip pode ser usado para instalar pacotes de outros repositórios. Por exemplo, se você deseja instalar o pacote rptree disponível em TestPyPI package index, ou no GitHub.

# no TestPyPI    
python -m pip install -i https://test.pypi.org/simple/ rptree
# no GitHub
python -m pip install git+https://github.com/realpython/rptree

Observação: pode ocorrer que em seu computador o python 3 esteja instalado com o nome python3.

<h2=”idm”>Listando e desinstalando módulos

Módulos instalados podem ser vistos com o argumento list. A lista obtida reflete a instalação do Flask e suas dependências. Uma lista com pacotes desatualizados é obtida com a chave list –outdated.

$ python -m pip list
  Package      Version
  ------------ -------
  click        8.0.3
  Flask        2.0.2
  itsdangerous 2.0.1
  Jinja2       3.0.3
  MarkupSafe   2.0.1
  pip          22.0.3
  setuptools   49.2.1
  Werkzeug     2.0.2    

# listar apenas pacotes desatualizados
$ python -m pip list --outdated
  Package    Version Latest Type
  ---------- ------- ------ -----
  setuptools 49.2.1  60.8.1 wheel

Pode ocorrer que você deseje usar outro pacote e queira remover o antigo de seu computador. A desinstalação é feita com uninstall. pip desinstala pacotes com versões desatualizadas antes de fazer uma atualização para versão mais nova.

Um cuidado deve ser tomado: Quando um pacote é instalado é possível que ele possua dependências que são instaladas juntas com ele. Se você tem muitos pacotes instalados é possível que mais de um use a mesma dependência. Por isso é importante verificar se ele é dependência de outro ou se possui dependências. Para isso usamos python -m pip show <modulo>.

$ python -m pip show Flask
Name: Flask
Version: 2.0.2
Summary: A simple framework for building complex web applications.
Home-page: https://palletsprojects.com/p/flask
Author: Armin Ronacher
Author-email: armin.ronacher@active-4.com
License: BSD-3-Clause
Location: /home/guilherme/.anaconda3/lib/python3.8/site-packages
Requires: itsdangerous, Jinja2, click, Werkzeug
Required-by: 

Vemos no output que Flask possui as dependências (Requires: )itsdangerous, Jinja2, click e Werkzeug. Por outro lado ele não é exigido por nenhum outro modulo (Required-by:) portanto pode ser seguramente desinstalado. Para isso usamos uninstall.

$ python -m pip uninstall Flask

O mesmo procedimento deve ser usado com as dependências, caso você queira apagá-las.

Busca por pacotes

pip pode fazer buscas por um pacote com o comando:

python -m pip search "query"

A pesquisa retorna uma lista de pacotes com uma breve descrição de cada um.
Importante: pip search deixou de funcionar em dezembro de 2020. Um substituto para esse comando é poetry (que deve ser instalado em seu sistema). Por exemplo, uma busca por pacotes com “pandas” no nome: (o output está truncado).

$ poetry search "pandas"
  pandas (1.4.0)
     Powerful data structures for data analysis, time series, and statistics
  pandas3 (0.0.1)
     Boto3 extension to help facilitate data science workflows with S3 and Pandas
  pandas-alchemy (0.0.2)
     SQL based, pandas compatible DataFrame & Series


Poetry é similar ao npm do JavaScript, gerenciando pacotes e auxiliando na criação de distribuições de aplicativos e bibliotecas, além da inserção no PyPI. Outro pacote é o Pipenv que gerencia pacotes e controla ambientes virtuais. Real Python: Pipenv Guide.

Estrutura de um projeto Python

O Python é bastante flexível na questão de estrutura de pastas para um projeto. No entanto algumas sugestões foram dadas para um desenho ótimo para um projeto. Aqui eu sigo as sugestões de Lucas Tonin.
Vamos denominar nosso projeto de meu_projeto. Se estamos usando, como é recomendado, um ambiente virtual podemos criá-lo com python3 -m venv ~/Projetos/.venv/meu_projeto, que já estabelece uma estrutura mínima de pastas. Vamos por hora ignorar as pastas relativas ao ambiente virtual.

Para definir uma terminologia chamamos de projeto python tudo aquilo que estará no diretório base, que em nosso caso é ~/Projetos/.venv/meu_projeto. Todos os arquivos relacionados ao desenvolvimento, teste e arquivos auxiliares ficam nesse diretório. Chamamos de pacote (package) ao conteúdo de um subdiretório dentro do projeto com o mesmo nome. O pacote contém o código-fonte do aplicativo. Ele isola o código fonte de todos os outros arquivos. Depois de pronto a instalaçao do projeto inclui apenas os arquivos contidos nesse diretório, ignorando código fonte e testes.

Arquivo __init__.py: O pacote deve necessariamente conter pelo menos um arquivo como o nome __init__.py. A presença desse informa ao python que esse diretório é um pacote. __init__.py é executado automaticamente quando esse pacote é carregado e deve conter as inicializações para o aplicativo. Duas coisas importantes podem ser aí incluídas: (a) uma variável ROOT_DIR com o caminho absoluto do atual pacote, onde estiver no momento; (b) as configurações de logger, quando existir.

from os.path import dirname, abspath
ROOT_DIR = dirname(abspath(__file__))
# inicialização de logs

Um arquivo de documentação, README.md: geralmente esses arquivos são escritos em MARKDOWN. Ele deve conter uma descrição de seu projeto para outros usuários de seu código, ou para você mesmo no caso de retomar após um tempo esse trabalho, além de instruções de instalações. Um exmplo simples:

# Meu Projeto
Um aplicativo simples para a importação de arquivos *csv* e exportação para banco de dados SQL.
## Instalação
Para instalar execute `pip install /caminho/meu_projeto`

Usando setup.py: o arquivo setup.py contém informações sobre configurações do pacate a ser instalado. No mínimo ele deve conter:

import setuptools
setuptools.setup(name='meu_projeto', packages=['meu_projeto'])

que informa o nome do projeto e do pacote. O parâmetro packages informa ao pip que apenas esse diretório será instalado.

Para projetos maiores e mais complexos, que envolvem muitas dependências, é útil acrescentar um arquivo requirements.txt Este arquivo lista os pacotes necessários para o projeto, que não fazem parte da biblioteca padrão. Com ele o pip pode baixar e instalar automaticamente todas as dependências, se não o encontrar já instalado. Por exemplo, se o projeto depende do numpy (um pacote para computação científica) a arquivo deverá conter pelo menos a linha

numpy==1.18.2

O arquivo setup.py deve ser modificado para que essa informação seja usada.

# setp.py
import setuptools
with open('requirements.txt', 'r') as f:
    install_requires = f.read().splitlines()

setuptools.setup(name='meu_projeto',
                 packages=['meu_projeto'],
                 install_requires=install_requires)


Um arquivo LICENCE, que descreve a licença sob a qual você está distribuindo seu projeto, pode ficar no diretório base, onde pode ser facilmente encontrado. Finalmente, uma pasta separa para os testes usados para testar a correção do código.

O projeto fica portanto com a seguinte estrutura mostrada na figura.

Usando Jupyter Notebook em ambiente virtual

Anaconda é uma plataforma de distribuição de Python e R muito usada para a ciência de dados e aprendizado de máquina. Ele simplifica a instalação de pacotes como pandas, NumPy, SciPy, e pode ser usada com diversas outras linguagens. Conda é o gerenciador padrão de pacotes do Anaconda, multiplataforma e agnóstico à linguagem e que pode ser usado para instalar pacote de terceiros. O Anaconda Navigator, instalado junto com o Anaconda, é uma interface gráfica que permite o gerenciamento de pacotes coda, com busca, instalação, atualização e desinstalação, execução dos aplicativos incluidos na Anaconda Cloud ou outro repositório Anaconda local. Todo esse sitema está disponível para Windows, macOS e Linux.

O Anaconda Cloud é um serviço de nuvem que abriga pacotes, notebooks e ambientes Python para variadas situações e casos, incluindo pacotes conda e PyPI públicos e privados. Ele permite o uploud de pacotes de usuário e notebooks, sem a necessidade de um login ou conta na nuvem.

Jupyter Notebook é uma ambiente interativo de interface do usuário da Web onde se pode rodar código nas linguagens instaladas, criar documentos de notebook contendo texto (em Markdown), imagens e vídeos. Esses documentos podem ser partilhados e publicados na nuvem, onde podem ser alterados e executados por outros usuários.

O Jupyter Notebook faz um gerenciamento de ambiente próprio. Mas tambem podemos criar um ambiente virtual específico para ele. Isso é bastante útil pois esse ambiente é usualmente dedicado à computação científica e aplicações com grandes volumes de dados, exigindo bibliotecas específicas. Para isso criamos um ambiente virtual, que chamaremos de jupyter. Depois ativamos o ambiente e instalamos o Jupyter Notebook dentro desse ambiente.

# criamos o ambiente virtual    
$ python3 -m venv ~/Projetos/.venv/jupyter_venv
# ativamos esse ambiente
$ cd /home/usuario/Projetos/.venv/jupyter_venv
$ source bin/activate 
# instala o Jupyter Notebook no ambiente
(jupyter) $ ipython kernel install --user --name=jupyter_venv

Para usar o ambiente virtual abra o jupyter e selecione o kernel no menu kernel | change kernel. A opção para o ambiente jupyter_venv deve estar disponível, como mostra a figura, como uma das opções de kernel a usar.

Para desinstalar o ambiente fazemos:

$ jupyter-kernelspec uninstall jupyter_venv

Gerenciador conda


Para quem está trabalhando com a distribuição Python Anaconda (site) é mais interessante usar o gerenciador conda, que além de gerenciar pacotes e suas dependências controle também ambioentes virtuais. Ele pode ser usado com Python, R, Ruby, Lua, Scala, Java, JavaScript, C/ C++, FORTRAN e outras.

Com o conda você pode pesquisar por pacotes, instalar os pacotes desejados ou construir um pacote do usuário com build (conda-build deve ser instalado).

A versão de conda pode ser verificada, e atualizada se necessário. Essa atualização pode incluir atualização de outros pacotes e remoção de pacotes não usados.

# verificar versão    
$ conda --version
  conda 4.10.3
# informações mais detalhadas podem ser obtidas
$ conda info
    active environment : base
    active env location : /home/guilherme/.anaconda3
# -----(outpup truncado)-----

# atualizar versão
$ conda update conda
# se existir versão mais recente
  Proceed ([y]/n)? y

A pesquisa e instalação de pacotes é feita com search e install. A construção (build) de pacotes é feitas com build.

# pesquisar   
$ conda search scipy
# instalação
$ conda install scipy
# o novo pacote deve estar na lista
$ conda list

# construir um pacote
$ conda build meu_projeto

Versões podem ser especificadas, inclusive com o uso de operadores lógicos.

conda install numpy=1.11                  #(instala versão especificada)
conda install numpy==1.11                 #(idem)
conda install "numpy>1.11"                #(versão superior a 1.11)
conda install "numpy=1.11.1|1.11.3"       #(versão 1.11.1 ou 1.11.3)
conda install "numpy>=1.8,<2"          #(versão maior ou igual a 1.8 mas inferior a 2)

A barra | é o operador OR: “pacote=1.1|1.3” significa 1.1 ou 1.3.
A vírgula , é o operador AND: “pacote=1.1,1.3” significa ambos 1.1 e 1.3.
O igual = é o operador fuzzy: “pacote=1.11” pode ser “1.11”, “1.11.1”, , “1.11.8”, etc.
O duplo igual == é o operador exato: “pacote==1.11” pode ser “1.11”, “1.11.0”, , “1.11.0.0”, etc.

Gerenciamento de ambientes com conda

Um novo ambiente, que chamaremos de cientifico pode ser criado, e simultanemanete instalado nele o pacote pandas. Caso um versão do Python diferente da versão default do Anaconda instalado usamos a declaração conda create --name nome_ambiente python=n, onde n é a versão desejada.

# criar um ambiente com um pacote
$ conda create --name cientifico pandas
  Proceed ([y]/n)? y
  
# ativar o ambiente
$ conda activate cientifico

# para verificar a versão do python em uso
$ python --version
  Python 3.8.8

# para criar ambiente coom outra versão do python
conda create --name cientifico python=3.9

Uma lista de todos os ambientes disponíveis pode ser vista com info --envs. As pastas listadas dependem do local onde os ambientes foram criados.

$ conda info --envs
  conda environments:
      base           /home/username/Anaconda3
      cientifico   * /home/username/Anaconda3/envs/cientifico

O ambiente ativo aparece com um asterisco *.

Um canal conda é um local na rede onde pacotes estão armazenados. Por default Conda busca em uma lista de canais e baixa arquivos de Repo Anaconda, onde alguns pacotes podem ser prorietários. Outros repositórios podem ser apontados com o conda, por exemplo o Conda Forge, uma organização parte do GitHub que contém um grande número de pacotes gratuitos. O parâmetro --override-channels é usado para que os canais default (gravados em .condarc) sejam ignorados.

# para apontar para o conda-forge
$ conda install scipy --channel conda-forge

# múltilpos repositórios podem ser apontados
$ conda install scipy --channel conda-forge --channel bioconda
# argumentos que aparecem na frente são pesquisados primeiro

# para pesquisar em repositório local, ignorando os defaults
$ conda search scipy --channel file:/caminho/local-channel --override-channels

Para instalar um pacote presente no conda-forge também é possível fazer:

$ conda config --add channels conda-forge
$ conda config --set channel_priority strict
$ conda install "nome-do-pacote"

Muito mais é possível com o Conda: consulte as instruções em Conda Docs.

Argumentos positionais
comando descrição
clean Remove pacotes e caches não utilizados.
compare Compara pacotes entre ambientes conda.
config Modifica os valores de configuração em .condarc. (feito após a configuração do comando git).
Grava arquivo .condarc do usuário
create Cria um ambiente conda a partir de uma lista de pacotes especificados.
help Exibe uma lista de comandos conda disponíveis e suas ajudas.
info Exibe informações sobre a instalação atual do conda.
init Inicializa o conda para interação do shell. [Experimental]
install Instala uma lista de pacotes em um ambiente conda especificado.
list Lista pacotes vinculados em um ambiente conda.
package Utilitário de pacote conda de baixo nível. (EXPERIMENTAL)
remove Remove uma lista de pacotes de um ambiente conda especificado.
uninst Alias para remove.
run Execute executável em um ambiente conda. [Experimental]
search Pesquisa pacotes e exibe informações associadas.
update Atualiza pacotes conda para a versão compatível mais recente.
upgrade Alias para update.
Argumentos opcionais
comando descrição
-h, –help Mostra ajuda,
-V, –version Mostra versão.

Bibliografia

Gráficos com Bokeh

O que é Bokeh


Bokeh é uma biblioteca de visualização de dados interativa em Python que existe desde 2013. Ela pode ser usada para a plotagem de gráficos em diversos níveis de sofisticação, representando conjuntos simples ou complexos de dados. A biblioteca pode ser usada por usuários com pouca experiência em programação ou programadores experientes com acesso aos seus comandos mais intrincados. Os gráficos do Bokeh podem ser interativos e embutidos em páginas da web.

Algumas definições básicas na terminologia de Bokeh são necessárias:

Application um aplicativo Bokeh é um documento renderizado e executado no navegador.
Glyphs glifos são os blocos de construção do Bokeh como linhas, círculos, retângulos e outras formas,
Server o servidor Bokeh é usado para compartilhar e publicar gráficos e aplicativos interativos para um público de sua escolha
Widgets os widgets do Bokeh são controles tais como menus suspensos, controles deslizantes e outras ferramentas de interface gráfica com o usuário que permitem interatividade

Instalação

Para instalar o Bokeh, se você tem Anaconda ou Miniconda, basta usar o comando: conda install bokeh.
Usando pip a biblioteca pode ser instalada com: pip install bokeh.

Comandos básicos

Dois tipos de saídas podem ser obtidas: o gráfico enviado para um arquivo output_file('arquivo.html') ou embutidos no Jupyter Notebook, output_notebook(). Bokeh possui uma interface similar à do matplotlib, que é denominada bokeh.plotting. A classe principal dessa interface é Figure que contém os métodos para a inclusão de glyphs em um gráfico.

» # importar as classes necessárias
» from bokeh.io import output_notebook, show
» from bokeh.plotting import figure
» output_notebook()
Figura 1
» # dados a plotar » x = [0,1, 0,3] » y = [0,10,90,10] » # instanciar um objeto figure » fig = figure(plot_width=450, plot_height=300) » # desenhar uma linha ligando os pontos dados » fig.line(x,y) » # exibir a figura 1 » show(fig)

A variável fig contém um objeto da classe com largura e altura especificadas, e instrução relativas às ferramentas a serem apresentadas, do lado direito no caso. O comando fig.line(x,y) usa o glyph line (linha) para ligar os pontos dados nas duas listas.

Glyphs

Glyphs são todos os elementos gráficos como linhas, círculos e cruzes marcadores de pontos, etc. Diferentes glyphs podem ter parâmetros ajustáveis diferentes. No exemplo aplicamos uma cor de fundo à figura, largura e altura. fig.circle() recebe os parâmetros posição (x,y), tamanho, que no caso é variável, cada círculo com raio size=y, largura de linha (as circunferências) line_width=5, e cor color=['red', 'blue','green','yellow']. Cada um dos discos tem uma cor diferente.

» # define dados    
» x = [1,2,3,4]
» y = [10,40,90,160]
Figura 2
» # instancia figura com cor de fundo e dimensões dadas » fig = figure(background_fill_color='#aabbff', » plot_width=450, plot_height=300) » fig.circle(x,y, size=y, line_width=5, color=['red', 'blue','green','yellow'], alpha=.5) » # exibir figura 2 » show(fig)

Os seguintes glyphs estão disponíveis:

asterisk() cross() diamond() diamond_cross()
circle() circle_x() circle_cross() triangle()
inverted_triangle() square() square_x() square_cross() x()

Alguns exemplos de uso de glyphs line, circle, cross, asterisk, x estão abaixo. As ordenadas y foram calculadas para formarem uma sequência de parábolas empilhadas, exceto pela reta horizontal amarela larga de fundo.

» # define valores da abscissa. Ordenadas serão calculadas    
» x = np.arange(10)

» plot = figure(plot_width=650, plot_height=300)
» plot.line(x, 100, color='yellow', line_width=140, alpha=.2,)
» plot.circle(x, x**2, size = 20, color='red', alpha=.5, line_width=7)
» plot.cross(x, x**2+50, size = 20, color='blue', alpha=.8, line_width=7)
» plot.asterisk(x, x**2+100, size = 40, color='green', alpha=.8, line_width=7)
» plot.x(x, x**2+150, size = 40, color='black', alpha=.8, line_width=7)
# figura 3
» show(plot)
Figura 3

As propriedades de cada glyph podem ser calculadas e dependentes em qualquer fonte de dados. Na caso abaixo usamos a própria ordenada x para calcular alguns desses parâmetros. A propriedade color=['yellow','blue']*5 garante que os 10 ‘diamantes’ plotados alternem entre as cores amarelo e azul.

» x = np.arange(10)    
» plot = figure(plot_width=650, plot_height=300)
» plot.circle_cross(x, x, size = 5+x, color='#ffaaff', alpha=1, line_width=7+x)
» plot.circle_dot(x, x, size = 30-2*x, color='#66aaff', alpha=.5, line_width=2)
» plot.inverted_triangle(x, x+5, size = 30-2*x, color='red', alpha=.9, line_width=2)
» plot.diamond(x, x+5, size = 30-2*x, color=['yellow','blue']*5, alpha=.8, line_width=2)
» show(plot)
» # figura 4 é plotada

» # outro plot com tamnho e cor variáveis
» x = np.arange(10)
» plot = figure(plot_width=600, plot_height=300)

» for k in range(100):
»     plot.circle(k, (k-50)**2, size = k*2, color=(255*k/100, 200, 255),
»                 fill_color=(2.5*k, 100, 255-2.5*k), alpha=.4, line_width=2)
» show(plot)
» # figura 5 é plotada

Gráficos de Barras (Bar Plots )

Para gráficos de barras a sintaxe é um pouco diferente. As coordenadas x são o ponto central da barra vertical, top é a altura. A largura width= 1 significa nenhum espaçamento entre barras. As cores podem ser uma só ou uma lista, de mesmo tamanho que o número de barras. Para as barras horizontais o comprimento das barras é dado por right e a largura da barra é height.

» x = [8,9,10]
» y = [1,4,2]

» # barras verticais
» plot = figure(plot_width=600, plot_height=300)
» # plot.vbar para traçar barras verticais
» plot.vbar(x,top = y, color = ['blue','red','green'], width= .8, alpha=.5)
» show(plot)     # exibe gráfico 6

» # barras horizontais
» plot = figure(plot_width=600, plot_height=300)
» plot.hbar(x, right = y, color = ['#77aaff','#aa77ff','#ff77aa'], height= .9, alpha=.5)
» show(plot)     # exibe gráfico 7


O desenho da regiões ou patches é feito com plot.patches. As regiões são descritas por meios das coordenadas de suas arestas, dois pares de listas para cada figura. As propriedades fill_color, line_color, line_width, alpha receberam listas de 3 elementos, um para cada figura. Se um valor único for passado ele será válido para todas as figuras.

» # regiões a colorir
» x_coords = [[1,1,3,], [2,2,2.5], [1.5,1.5,4,4]]
» y_coords = [[2,6,4], [3,6,7], [3,6,7,2]]

» plot = figure(plot_width=600, plot_height=300)
» plot.patches(x_coords, y_coords, fill_color = ['#77aaff','#aa77ff','#ff77aa'],
               line_color ='black', alpha=.4)
» show(plot)      # figura 8
Figura 8

Gráficos de Dispersão (Scatter Plots )

Gráficos de dispersão podem ser feitos com qualquer um dos glyphs. No exemplo abaixo a mesma plotagem é feita com círculos e com cruzes de tamanhos diversos, para efeito estético.

» from bokeh.models import Range1d
» plot = figure(plot_width=400, plot_height=250,
»               x_axis_label = 'Coordenada x (abcissa)',
»               y_axis_label = 'Ordenada y', title='Gráfico de dispersão')
» plot.x_range = Range1d(0, 5)
» plot.y_range = Range1d(0, 8)
» fcor = ['red','green','blue','brown','violet']
» x = np.array([1,2,3,4,4])
» y = np.array([5,6,2,2,4])
» plot.circle(x,y, size =x*15, color = '#aa55ff', fill_color=fcor, fill_alpha=.3)
» plot.diamond(x,y, size = x*15, color = 'red', alpha=.5,
»              fill_alpha=.4, fill_color=fcor[::-1])

» show(plot)    # figura 9
Figura 9

Observe que as coordenadas x, y poderiam ser listas. Como são arrays (do numpy) as operações para o cálculo do tamanho são permitidas. As faixas de coordenadas e ordenadas plotadas são controladas por x_range, y_range e estabelecidas por meio da função Range1d(m, n) (importada de bokeh.models). Os parâmetros color e alpha se referem ao traçado do glyph, enquanto fill_color e fill_alpha ao seu preenchimento. Relembrando, fcor[::-1] retorna a lista em ordem reversa.

Dataframes e ColumnDataSource

Usamos, até aqui, listas e arrays como fonte de nossos dados e serem plotados. Também podemos usar dataframes como fontes e o processo não é muito diferente. Se um dataframe tem uma coluna x e outra y plotamos o gráfico x × y simplesmente passando as series como parâmetros para x e y: plot.line(x = df['x'], y = df['y']).

Para montar um gráfico um pouco mais elaborado vamos usar os dados já descritos na seção sobre matplotlib. São dados sobre o número de nascimentos em países do mundo de 1950 até 2020, e a estimativa à partir de 2021. Importamos o arquivo .csv para o dataframe dfBrasil e selecionamos apenas as linhas relativas ao Brasil, até o ano de 2020. Esse dataframe é usado para plotar o gráfico de linhas. Outro dataframe, dfDecada, contendo apenas linhas com anos múltiplos de 10, é usado para plotar círculos. O raio do círculo é proporcional ao número de nascimentos.

» import pandas as pd
» dfNasc = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecionamos apenas linhas sobre o Brasil, até 2020
» dfBrasil = dfNasc[(dfNasc['Entity']=='Brazil') & (dfNasc['Year'] < 2021)]
» dfBrasil = dfBrasil.rename(columns={'Year':'ano', dfBrasil.columns[3]:'nasc'})
» # mantemos apenas colunas 'ano', 'nasc'
» dfBrasil = dfBrasil[['ano', 'nasc']]
» dfBrasil.head(2)
↳          ano         nasc
  4050    1950    2439820.0
  4051    1951    2467186.0

» # criamos outro df, apenas com anos multiplos de 10
» dfDecada = dfBrasil[dfBrasil['ano']%10==0]

» cor = ['salmon','gold','teal','plum','powderblue','coral','wheat','azure']
» plot = figure(plot_width=400, plot_height=250,
»               x_axis_label = 'Ano',
»               y_axis_label = 'Nascimentos (milhões)',
»               title='Número de Nascimentos no Brasil')
» plot.line(x = dfBrasil['ano'], y = dfBrasil['nasc']/1e6, color='black')
» plot.circle(x = dfDecada['ano'], y = dfDecada['nasc']/1e6,
»             size=dfDecada['nasc']/1e5, fill_color = cor,
»             fill_alpha=.5)
» show(plot)    # figura 10
Figura 10

Uma forma útil de fazer a conexão com os dados é o objeto ColumnDataSource. Ela é especialmente útil quando se usa a mesma fonte para diversas plotagens e para vários widgets. ColumnDataSource cria um dicionário onde as chaves podem ter nomes definidos pelo usuário e as valores correspondentes são os dados contidos em colunas do dataframe (ou outra fonte).

Vamos retornar aos dados relativos aos nascimentos nos países do mundo. Dessa vez vamos manter apenas dados sobre o Brasil e a Indonésia (escolhido porque é um país que tem população próxima à brasileira), apenas nos anos de 1950 até 2020. Nessa tabela os países recebem os códigos Code='BRA' e 'IDN', respectivamente.

» dfNasc = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecionamos as linhas sobre o Brasil e a Indonésia, até 2020
» dfBI = dfNasc[((dfNasc['Code']=='BRA') | (dfNasc['Code']=='IDN')) & (dfNasc['Year'] < 2021)]
» dfBI = dfBI.rename(columns={'Year':'ano', dfBI.columns[3]:'nasc'})

Desses dados criamos um dataframe apenas com dados brasileiros, outro com dados sobre a Indonésia. Para mesclar esses dataframes alteramos as colunas ‘nasc’ respectivamente para ‘BRA’ e ‘IDN’.

» dfB = dfBI[['ano','nasc']][dfBI['Code']=='BRA'].rename(columns={'nasc':'BRA'})
» dfB.head(3)
↳          ano          BRA
  4050    1950    2439820.0
  4051    1951    2467186.0
  4052    1952    2523577.0

» dfI = dfBI[['ano','nasc']][dfBI['Code']=='IDN'].rename(columns={'nasc':'IDN'})
» dfI.head(3)
↳          ano          IDN
  14700    1950    2867664.0
  14701    1951    2939269.0
  14702    1952    3078414.0
Para ler mais sobre a operação do pandas realizada, similar a um INNER JOIN do sql, consulte o artigo Pandas e SQL Comparados, nesse site.

Ambos os dataframes têm 71 linhas. Usamos pandas.merge() para juntar esses dataframes pelo campo ‘ano’, um processo similar ao INNER JOIN do sql. Depois criamos três novas colunas: (1) campo dif, com a diferença por ano entre os números brasileiros e indonésios, (2), difM, a média entre os dois e (3) raio, descrito no comentário † abaixo.

» dfBI = pd.merge(dfB, dfI, on='ano')
» dfBI.head(3)
↳       ano          BRA          IDN
  0    1950    2439820.0    2867664.0
  1    1951    2467186.0    2939269.0
  2    1952    2523577.0    3078414.0

» dfBI['dif'] = dfBI['IDN'] - dfBI['BRA']
» dfBI['difM'] = (dfBI['IDN'] + dfBI['BRA'])*.5
» dfBI['raio'] = dfBI['dif']/33000                   # veja comentário †

» # o dataframe fica assim:
» dfBI
↳          ano           BRA           IDN          dif          difM         raio
    0     1950     2439820.0     2867664.0     427844.0     2653742.0     12.964970
    1     1951     2467186.0     2939269.0     472083.0     2703227.5     14.305545
    2     1952     2523577.0     3078414.0     554837.0     2800995.5     16.813242

() A terceira coluna adicional, raio, é a diferença vezes um fator para que os discos em plot.circle() preencham o espaço entre os nascimentos nos dois países, centrados na média. Essa plotagem aqui tem apenas efeito visual e para demonstrar os parâmetros do plot.

» from bokeh.models import Range1d
» from bokeh.plotting import ColumnDataSource
    
» # cria o objeto ColumnDataSource
» data = ColumnDataSource(dfBI)
» plot = figure(width=900, height=250, x_axis_label = 'Ano', y_axis_label = 'Nascimentos e diferenças',
»               background_fill_color='#cfefff', border_fill_color='#ddeeff',
»               title='Nascimentos no Brasil e Indonésia')

» plot.x_range = Range1d(1950, 2035)
» plot.y_range = Range1d(0, 5.5E6)

» plot.line(x = 'ano', y = 'BRA', source = data, color = 'red', legend_label = "Brasil")
» plot.line(x = 'ano', y = 'IDN', source = data, color = 'green', legend_label = "Indonésia")
» plot.x(x = 'ano', y = 'dif', source = data, color = 'blue', legend_label = "diferença")
» plot.asterisk(x = 'ano', y = 'difM', source = data, color = 'black', legend_label = "média")
» plot.circle(x = 'ano', y = 'difM', source = data, fill_color = 'whitesmoke', alpha=.2, size = 'raio')

» show(plot)    # figura 11
Figura 11

Nesse gráfico introduzimos as legendas para cada plot. O campo difM foi plotado duas vezes, uma com um asterisco, outro com círculos com tamanhos determinados pelo campo raio. As faixas de plotagem, ranges, foram determinados para incluir gráfico e legendas. Cor de fundo para o gráfico e bordas são definidas com background_fill_color e border_fill_color.

Para o próximo gráfico baixamos para a subpasta dados do atual projeto o arquivo owid-covid-data.csv, publicado por Our World in Data com dados diários sobre a vacinação mundial contra o covid, entre 01/01/2020 e 26/09/2021. Deste aproveitamos apenas algumas colunas para plotar gráficos para efeito de demonstração do bokeh.

» # importamos os dados para um dataframe
» dfVacina = pd.read_csv('./dados/owid-covid-data.csv')

» # o dataframe tem 64 colunas e 119454 linhas
» dfVacina.shape      # (119454, 64)

» # podemos ver os nomes das colunas com
» dfVacina.columns    # nomes omitidos aqui

» # usamos apenas as colunas no dicionário
» colunas = {'date':'data',
»            'iso_code':'code',
»            'total_cases':'total',
»            'gdp_per_capita':'pib',
»            'human_development_index':'idh',
»            'life_expectancy':'expVida',
»            'total_deaths_per_million':'mortes',
»            'people_vaccinated_per_hundred':'vacinados'           
»           }
» # renomeamos as colunas
» dfVacina = dfVacina.rename(columns=colunas)

» # uma lista dos novos nomes:
» lst = list(colunas.values())
» # geramos novo df apenas com essas colunas
» df = dfVacina[lst]
» # eliminamos os linhas com NaN
» df = df.fillna(method='bfill')      # veja comentário ‡

» # as três primeiras linhas são
» df.head(3)
↳              dia    code   total         pib      idh   expVida    mortes   vacinados
   0    2020-02-24     AFG     5.0    1803.987    0.511     64.83     0.025         0.0
   1    2020-02-25     AFG     5.0    1803.987    0.511     64.83     0.025         0.0
   2    2020-02-26     AFG     5.0    1803.987    0.511     64.83     0.025         0.0

» # finalmente montamos um dataframe contendo apenas o último dia registrado
» dfUltimo = dfU[dfU['dia']=='2021-09-26']

() O método df.fillna(method='bfill') preenche valores nulos com o valor encontrado na mesma coluna, em linha posterior. (Leia aqui sobre tratamento de dados ausentes).

Lembramos que code identifica o país, total é o número total de casos de infecção por covid, mortes é o número total de mortes, por milhão e vacinados é o número de pessoas vacinadas, por 100 mil.

Podemos, em alguns casos, desejar incluir no gráfico um valor calculado a partir de um ou mais campos da tabela. Por ex., considerando que o campo idh varia entre 0,4 até 0,95, podemos usar esse campo, multiplicado por um fator, como informação do tamanho dos círculos plotados. Para fazer isso poderíamos incluir uma coluna extra com esse valor, como já foi feito em exemplos anteriores. Mas quando usamos o ColumnDataSource temos uma forma mais direta de fazer o mesmo. Podemos passar valores calculados no dicionário de valores que alimenta o ColumnDataSource.

» from bokeh.plotting import ColumnDataSource
» data = ColumnDataSource(data = {
»                        'idh' : dfUltimo['idh'],
»                        'expVida' : dfUltimo['expVida'],
»                        'tamanho': dfUltimo['idh']*20,
»                        'grande': dfUltimo['idh']*40,
»                        'alfa': dfUltimo['idh']*.08})
» plot = figure(width=600, height=300, x_axis_label = 'IDH',
»               y_axis_label = 'Exp. Vida', outline_line_color='black',
»               background_fill_color='#F5F1E3', title='IDH x Expectativa de Vida')

» plot.circle(x = 'idh', y = 'expVida', source = data, color='blue', alpha=.6,
»             fill_color = 'white', fill_alpha=1,  size = 'tamanho')
» plot.circle(x = 'idh', y = 'expVida', source = data, color='black', alpha= .1,
»            fill_color = 'red', fill_alpha='alfa', size = 'grande')

» show(plot)     # figura 12
Figura 12

Os campos do dataframe foram passados como valores em um dicionário cujas chaves são usadas como nome de campos nas plotagens. Os campos 'tamanho': dfUltimo['idh']*20 e 'grande': dfUltimo['idh']*40 são calculados para servir como informação para o tamanho (size ) dos círculos. O segundo círculo plotado tem apenas efeito estético, com um tamanho maior que o primeiro. O campo calculado alfa (uma fração do idh) é usado para regular a transparência dos discos vermelhos maiores.

O uso de ColumnDataSource permite que mais de um dataframe forneça dados para o gráfico. No entanto todas as series envolvidas devem ter o mesmo tamanho. Para ver isso vamos separar os dados sobre o Brasil e os EUA em duas tabelas separadas.

» # separa os dados relativos ao Brasil e os EUA
» dfBU = df[(df['code']=='BRA') | (df['code']=='USA')].copy()    # comentário §

» # para usar as datas no eixo x transformamos o campo 'dia' de string em datetime
» dfBU.loc[:,'dia'] = pd.to_datetime(df.loc[:,'dia'], format='%Y/%m/%d')    

» # com essa transformação a coluna passa a conter um datetime (timestamp). Por ex.:
» dfBU.loc[15250][0]
Timestamp('2020-02-26 00:00:00')

» # criamos dataframes para os dois países                      # comentário ‡
» dfUS = dfBU[(dfBU['code']=='USA') & (dfBU['dia'] &ge '2020-02-26')]
» dfBR = dfBU[dfBU['code']=='BRA']

(§) O uso de df2 = df1.copy() realiza uma cópia e não apenas pega um slice de df1. Esse procedimento evita mensagens de erro na linha seguinte, quando um campo do dataframe será alterado.

() No dataframe original existe um número maior de valores para os EUA. O corte na data especificada faz com que dfUS e dfBR tenham o mesmo tamanho.

Podemos agora plotar gráficos do número de mortes por COVID no Brasil e EUA, no mesma figura.

» cds = ColumnDataSource(data = {
»                        'dataBRA' : dfBR['dia'],
»                        'dataUSA' : dfUS['dia'],
»                        'mortesBRA' : dfBR['mortes'],
»                        'mortesUSA' : dfUS['mortes']
»                        })

» plot = figure(width=600, height=300,
»               x_axis_type = 'datetime', x_axis_label = 'data', y_axis_label = 'mortes',
»               background_fill_color='#fafaff', title='Mortes no Brasil e EUA')

» plot.circle(x = 'dataBRA', y = 'mortesBRA', source = cds, color='green' ,alpha=.2,
»             fill_color = 'yellow', fill_alpha=.3, size = 15, legend_label='EUA')


» plot.circle(x = 'dataBRA', y = 'mortesUSA', source = cds, color='blue' ,alpha=.2,
»             fill_color = 'red', fill_alpha=.3, size = 15, legend_label='EUA')

» plot.legend.location = 'top_left'

» show(plot)    # figura 13
Figura 13

Introduzimos nesse gráfico o uso de x_axis_type = 'datetime' para informar que o eixo x receberá dados de uma series temporal. plot.legend.location = 'top_left' informa a posição para as legendas.

Layouts

Layouts permitem a organização de gráficos em linhas e colunas múltiplas. Neles é possível vincular escalas de eixos entre gráficos diferentes.

Para explorar os layouts vamos usar o dataframe já montado df, que contém os campos dia, code, total, pib, idh, expVida, mortes, vacinados, descritos acima. Com ele construiremos 4 gráficos e os exibiremos em linhas, colunas e matrizes. A tabela inclui dados dos países ao longo de vários anos e, portanto, não há uma interpretação muito clara de seu significado. O objetivo é apenas o aprendizado da técnica.

» # transformando a coluna dia para um datetime
» df.loc[:,'dia'] = pd.to_datetime(df.loc[:,'dia'], format='%Y/%m/%d')

» #  a fonte de todos os gráficos é a mesma, nesse caso
» from bokeh.plotting import ColumnDataSource
» cds = ColumnDataSource(data = df)

» # gráfico 1
» plot1 = figure(width=300, height=200, x_axis_type = 'datetime',
»                x_axis_label = 'Data', y_axis_label = 'Mortes',
»                background_fill_color='#fafaff', title='Mortes no Mundo')

» plot1.dot(x = 'dia', y = 'mortes', source = cds, color='rosybrown' ,alpha=.5)

» # gráfico 2
» plot2 = figure(width=300, height=200,
»                x_axis_label = 'Expectativa de vida', y_axis_label = 'mortes',
»                background_fill_color='#fafffa', title='Expectativa de Vida x PIB')

» plot2.dot(x = 'expVida', y = 'pib', source = cds, color='red' ,alpha=.1)

» # gráfico 3
» plot3 = figure(width=300, height=200,
»                x_axis_type = 'datetime', x_axis_label = 'data', y_axis_label = 'mortes',
»                background_fill_color='#ffefff', title='PIB x Mortes')

» plot3.dot(x = 'pib', y = 'mortes', source = cds, color='blue' ,alpha=.05)

» # gráfico 4
» plot4 = figure(width=300, height=200, x_axis_label = 'PIB', y_axis_label = 'IDH',
»                background_fill_color='#9f9fff', title='PIB x IDH no mundo')
» plot4.dot(x = 'pib', y = 'idh', source = cds, color='yellow')

No código acima construimos quatro gráficos. Abaixo exploramos as possibilidades de layouts em linha, em coluna e em matriz.

» from bokeh.layouts import row, column
» # agrupar 2 gráficos em uma linha
» linha_layout = row(plot1,plot2)
» show(linha_layout)

» coluna_layout = column(plot3,plot4)
» show(coluna_layout)


» matriz_layout = column(row(plot1,plot2), row(plot3,plot4))
» show(matriz_layout)

Uma solução também interessante consiste em apresentar todos os gráficos no mesmo espaço, usando as classes Tabs e Panel. No código abaixo criamos 3 painéis e passamos nos argumentos os gráficos já construídos. Cada painel pode conter linhas e colunas, vistas anteriormente e passados no argumento child, além de um título que será usado nas guias ou tabs. Os painéis são inseridos em um objeto Tabs e exibidos.

» # importamos as classes necessárias
» from bokeh.models.widgets import Tabs, Panel
» # criamos 3 paineis
» tab1 = Panel(child = plot1, title = 'Mortes')
» tab2 = Panel(child = row(plot2,plot3), title = 'Exp Vida, PIBxMortes')
» tab3 = Panel(child = plot4, title = 'PIB x IDH')
» # insere os paineis no objeto Tabs
» objeto_tabs = Tabs(tabs = [tab1, tab2, tab3])
» # exibe o objeto
» show(objeto_tabs)

Ao clicar em uma guia o painés correspondente é exibido. Na figura estão mostrados a 1ª guia (figura 17) e a 3ª (figura 18).

Um layout de rede (grid layout) pode reunir gráficos em uma matriz, gerando resultado similar ao mostrado na figura 16. Para isso podemos usar o seguinte código.

» from bokeh.layouts import gridplot
» # cria uma rede ou grid
» grid_layout = gridplot([plot1, plot2], [plot3, plot4])
» show(grid_layout)
» # uma figura como a figura 16 é plotada.

Ao montar o grid_layout um espaço em branco pode ser inserido com None no lugar da variável do gráfico.

Algumas vezes é importante que dois ou mais gráficos tenham a mesma escala em um ou ambos os eixos. Para isso usamos o código como o seguinte.

» # criamos plots com a mesma escala (aqui no eixo do x)
» plot2.x_range = plot1.x_range
» # criamos um layout  (aqui em linha)
linha_layout = row(plot2, plot1)
show(linha_layout)

Anotações e Widgets

Para os próximos exemplos vamos usar o aqquivo population.csv, baixado do site Our World in Data, na página sobre população mundial.

O arquivo ./dados/population.csv foi baixado no link acima.

import pandas as pd
» # Importar dados para um dataframe
» df = pd.read_csv('./dados/population.csv')    

» # as colunas têm os nomes
» df.head(0)
↳ Entity   Code   Year   Total population (Gapminder, HYDE & UN)

» # 4 colunas e 53307 linhas
» df.shape # (53307, 4)

» # renomeamos as colunas
» colunas = {'Entity':'pais',
»            'Code':'codigo',
»            'Year':'ano',
»            'Total population (Gapminder, HYDE & UN)':'populacao'}
» df = df.rename(columns=colunas)

» # as colunas agora têm os nomes
» df.head(0)
↳ pais   codigo   ano   populacao

Já vimos como colocar títulos e legendas nas gráficos. No exemplo abaixo o título e posição são ajustados como uma propriedade de plot, diferente do parâmetro usado antes. Além disso podemos marcar regiões do gráficos com cores diferentes e incluir texto explicativo para realçar algum aspecto dos dados. Para isso usamos as classes Label e LabelSet.

Para alimentar esse gráfico vamos criar 3 ColumnDataSouces diferentes: para população e ano geramos cdsUSA para os EUA, cdsBRA para o Brasil, ambos após 1750. cdsLabel é usado para inserir anotações sobre os anos de independência e abolição da escravidão para os dois países.

» cdsUSA = ColumnDataSource(data = {
»     'ano' : df[(df['codigo']=='USA')  & (df['ano'] >= 1750)]['ano'],
»     'pop' : (df[(df['codigo']=='USA')  & (df['ano'] >= 1750)]['populacao'])/1e6,
» })
» cdsBRA = ColumnDataSource(data = {
»     'ano' : df[(df['codigo']=='BRA')  & (df['ano'] >= 1750)]['ano'],
»     'pop' : (df[(df['codigo']=='BRA')  & (df['ano'] >= 1750)]['populacao'])/1e6,
» })

» cdsLabel = ColumnDataSource(data=
»      dict(x=[1776, 1800, 1882, 1888],  y=[50, 100, 200, 260],
»           nota=['Indep. EUA (1776)', 'Abol. EUA (1857)',
»           'Indep. BR (1882)', 'Abol. BR (1888)']))

Agora estamos prontos para plotar esses dados. As únicas importações novas são das classes Label, LabelSet. Os dois gráficos de barra abaixo recebem os campos ano e pop, cada um relativo a um dos países.

» from bokeh.io import output_file, show, output_notebook
» from bokeh.plotting import figure
» from bokeh.plotting import ColumnDataSource
» from bokeh.models import Label, LabelSet

» output_notebook()

» grafico = figure(plot_width=600, plot_height=300, x_axis_label = 'ano',
                   y_axis_label = 'População (em milhões)')
» grafico.title.text = 'População do Brasil e do EUA de 1800 até o presente'
» grafico.title_location = 'above'

» grafico.vbar(x = 'ano', top = 'pop', source=cdsUSA,
               color = 'red', width= .1, legend_label = 'EUA')
» grafico.vbar(x = 'ano', top = 'pop', source=cdsBRA,
               color = 'green', width= 1, legend_label = 'Brasil')

» labels = LabelSet(x='x', y='y', text='nota', x_offset=0,
                    y_offset=0, source=cdsLabel, render_mode='canvas')

» texto = Label(x=1750, y=150, render_mode='css',
»               text='Independência e Abolição', text_color='blue',
»               border_line_color='#a0a0f0', border_line_alpha=1.0,
»               background_fill_color='linen', background_fill_alpha=1.0)

» grafico.add_layout(labels)
» grafico.add_layout(texto)
» grafico.legend.location = 'top_left'
» show(grafico)

Os objetos Label, LabelSet são criados com seus respectivos atributos e depois inseridos no grafico.

Usando mapas de cor

Para atribuir cores para uma categoria de dados, separando visualmente a informação para cada categoria, podemos atribuir uma cor a cada uma delas usando CategoricalColorMapper. Nele associamos a uma lista de fatores (factors ou dados categóricos) com uma lista de cores (em palette).

No exemplo inicializamos a variável mapaDeCor como um CategoricalColorMapper atribuindo os parâmetros factors e palette aos nomes das categorias e uma lista de cores. A associação é feita através do parâmetro transform no scatter plot. Novamente dois plots são traçados para efeito estético.

» from bokeh.io import output_notebook, show
» from bokeh.plotting import figure, CategoricalColorMapper
» from bokeh.models import ColumnDataSource, Range1d
» output_notebook()

» cor = ['salmon','gold','firebrick','plum','powderblue','teal','wheat','red']
» nome = ['Otto', 'Ana', 'Joana', 'Jorge', 'Marco', 'Agildo','Lu','Zana']
» dicio= dict(nome=nome,
»             altura=[1.70, 1.65, 1.48, 1.88, 1.58, 1.62, 1.83, 1.91],
»             peso=[97, 65, 89, 76, 67, 74,65, 94]
»            )
» mapaDeCor = CategoricalColorMapper(factors=nome, palette=cor)

» cds = ColumnDataSource(data=dicio)

» p = figure(title='Alunos: distribuição peso x altura',
»            x_range=Range1d(60, 110), y_range=Range1d(1.2, 2.2),
»            plot_width=400, plot_height=250)

» p.scatter(x='peso', y='altura', size=20, source=cds,
»           color=dict(field='nome', transform=mapaDeCor), alpha=.2)
» p.scatter(x='peso', y='altura', size=10, source=cds,
»           color=dict(field='nome', transform=mapaDeCor))
» p.xaxis[0].axis_label = 'Peso (kgs)'
» p.yaxis[0].axis_label = 'Altura (metros)'

» labels = LabelSet(x='peso', y='altura', text='nome',
                    x_offset=0, y_offset=8, source=cds)

» p.add_layout(labels)
» show(p)

Bibliografia

  • Jolly, Kevin: Hands-On Data Visualization with Bokeh, Interactive web plotting for Python using Bokeh, 2018 Packt Publishing, Mumbay.
  • Site Bokeh: Documentation, acessado em agosto de 2021.
  • Site Bokeh: First Steps, acessado em agosto de 2021.
  • Site Our World in Data, contendo grande variedade de tabelas com dados sobre vários temas, do mundo.
  • Rodés-Guirao, Lucas: COVID-19 Dataset by Our World in Data no Github. Acessado em outubro de 2021.

Python: Iteradores, Itertools e Funções Geradoras


Iteradores

Nessa seção para imprimir vários resultados eu uso quase sempre print(x, end=' ') para que uma nova linha não seja lançada após cada impressão. O objetivo é diminuir o espaço de página usada e facilitar a leitura.

Já vimos que objetos que são sequências e coleções podem ser lidos iterativamente com o uso do operador for.

» lista = ['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff']
» for t in lista:
»     print(t, end=' < ')
↳ Aa < Bb < Cc < Dd < Ee < Ff <

Laços for são claros e concisos. Por trás desse resultado simples, a instrução for chama a função iter() na sequência (ou coleção) que retorna um objeto iterador. Dentro do iterador existe o método __next__() que acessa os elementos no contêiner um de cada vez. Ao final da iteração, quando se extinguem os elementos, __next__() levanta uma exceção StopIteration que é reconhecida pelo laço como o fim da iteração. O método __next__() pode ser chamado através da função interna next(), como mostra o exemplo:

» iterador = iter('OMS')
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
» print(next(iterador))
↳ O
↳ M
↳ S
↳ StopIteration:

No código acima usamos iter(sequencia) que retorna um iterável da sequência ‘OMS’. StopIteration é uma mensagem de erro ao final da iteração, com o iterador esgotado. Ela aparece aqui resumida.

Podemos construir uma classe iterável implementado nela os métodos __iter__() e __next__(). No caso abaixo a classe simplesmente retorna a sequência invertida, do último elemento para o primeiro

» class Inverter:
»     def __init__(self, data):
»         self.data = data
»         self.index = len(data)

»     def __iter__(self):
»         return self

»     def __next__(self):
»         if self.index == 0:
»             raise StopIteration
»         self.index -= 1
»         return self.data[self.index]

# instanciamos um objeto Inverter usando uma lista como argumento
» inv = Inverter(['Aa', 'Bb', 'Cc', 'Dd', 'Ee', 'Ff'])

» for t in inv:
»     print(t, end=' < ')
↳ Ff < Ee < Dd < Cc < Bb < Aa <

# seq é Inverter usando uma string como argumento
» seq = Inverter('Joazeiro')
» for t in seq:
»     print(t, end=' < ')
↳ o < r < i < e < z < a < o < J <     

Sequências podem ser transformados em iteradores com as funções:

iter(objeto, sentinel) retorna o objeto (uma sequência) como iterável,
interrompe a iteração quando o valor retornado for igual à sentinel (opcional),
reversed(objeto) retorna a sequência como iterável, em ordem inversa.

que, como vimos, são úteis quando usadas com loops for, while. Por exemplo, com uma lista (ou uma tupla):

» x = ["apple", "banana", "cherry"]
» print(next(x))
↳ TypeError: 'list' object is not an iterator

» # transformado a lista em um iterador
» x = iter(["apple", "banana", "cherry"])
» print(next(x), next(x), next(x))
↳ apple banana cherry


Um objeto zip() é um iterador que junta duas sequências e retorna tuplas com elementos das sequências em seus argumentos.

» z = zip(['a', 'b', 'c', 'd'], [1, 2, 3, 4])
» print(next(z))
» print(next(z))
↳ ('a', 1)
↳ ('b', 2)

Da mesma forma map retorna um iterável.

» m = map(lambda x, y: x**y, [8, 2, 9], [5, 3, 7])
» print(next(m))
» print(next(m))
» print(next(m))
↳ 32768
↳ 8
↳ 4782969

# outro exemplo
» list(map(len, ['Abacate', 'Uva', 'Jacoticaba']))
↳ [7, 3, 10] 

Observe que um objeto pode ser um iterável (como uma lista) mas não ser um iterador. No entanto ele pode ser transformado um um iterador.

» numeros = [1, 2, 3, 4, 5]
» next(numeros)
↳ TypeError: 'list' object is not an iterator

» i = iter(numeros)
» next(i)
↳ 1
» next(i)
↳ 2
» # o estado do iterador é armazenado entre iterações
» for x in i:
»     print(x, end=' ')
↳ 3 4 5

Muitas das funções do Python retornam iteradores ao invés de listas ou tuplas. Por exemplo enumerate, reversede open(file)retornam iteradores.

» alunos = ['Pedro', 'Maria', 'Marco']
» nAluno = enumerate(alunos)        # enumerate
» next(nAluno)
↳ (0, 'Pedro')

» for t in nAluno:
»     print(t)
↳ (1, 'Maria')
↳ (2, 'Marco')

» invertido = reversed(alunos)       # reversed
» next(invertido)
↳ 'Marco'

» f = open('./dados/linhas.txt')     # arquivo
» next(f)
↳ 'Esta é a linha 1\n'

Por outro lado muitas funções internas (e das bibliotecas) aceitam iteráveis (e iteradores) como parâmetros. Abaixo exemplos do uso de zip, dict recebendo iteradores.

» numeros = [10, 20, 30]
» quadrados = (n**2 for n in numeros)
» quadrados    # é um objeto generator (que é um iterador)
↳ <generator object <genexpr> at 0x7f67280c1120>

» z = zip(numeros, quadrados)        # zip recebe um iterador como argumento
» print(next(z), next(z), next(z))
↳ (10, 100) (20, 400) (30, 900)

» # Um dicionário pode receber um iterável como argumento
» alunos = ['Pedro', 'Maria', 'Marco']
» nAluno = enumerate(alunos)         # um iterador
» d = dict(nAluno)
» d
↳ {0: 'Pedro', 1: 'Maria', 2: 'Marco'}

Módulo itertools

Outros módulos foram tratados em A Biblioteca Padrão dessas notas.

Um módulo da biblioteca padrão interessante para manipulação de iteradores é o itertools, que contém diversos métodos para manipulação de iteráveis e funções que retornam iteráveis. Elas são voltadas para a velocidade de execução e uso otimizado de memória.

Existem dois tipos de interadores: iteradores infinitos continuam a rodar indefinidamente se nenhuma condição de parada for imposta, enquanto os iteradores finitos são criados com um número determinado de ciclos determinados. O módulo Itertools possui funções de cada tipo:

iteradores infinitos count, repeat, cycle,
iteradores finitos chain, compress, tee, dropwhile, takewhile.

Iteradores finitos

Função chain(): A função chain reune listas transformando-as em um único iterável.

» import itertools as it
» abc = ['Aa', 'Bb', 'Cc']
» vxz = ['Vv', 'Xx', 'Zz']
» for txt in it.chain(abc, vxz):
»     print(txt, end=' ')
↳ Aa Bb Cc Vv Xx Zz 

» # o objeto retornado por chain é um iterável: 
» letras = it.chain(abc, vxz)
» for i in range(3):
»     print(next(letras), end=' ')
» print(next(letras), next(letras), next(letras))
↳ Aa Bb Cc Vv Xx Zz

Os argumentos de chain(iteravel,...) podem ser de qualquer tipo, desde que iteráveis. Por exemplo, podemos juntar uma lista, um dicionário e um it.count():

» lista = ['Aa', 'Bb', 'Cc']
» dicio = {'G': 'g', 'H': 'h'}
» conta = it.count()
» for k in it.chain(lista, dicio.items(), conta):
»     print(k, end=' ')
↳ Aa Bb Cc ('G', 'g') ('H', 'h') 0 1 2 3 4 5 6 7 8 9 10 ...

O 4º e 5º loop retornam as tuplas do dicionário. Em seguida os inteiros são retornados. (A exibição foi interrompida manualmente!)

Função compressed(iteravel, seletor): compressed recebe um iterável e um seletor (uma lista de booleanos) e retorna um iterável filtrado pelo seletor, contendo apenas elementos onde o seletor é True.

» aluno = ['Paulo', 'Maria', 'Ricardo']
» aprovado = [False, True, True]
» for n in it.compress(aluno, aprovado):
»     print(n, end=' ')
↳ Maria Ricardo 

Os argumentos de compressed podem ser iteráveis com infinitos elementos. Por exemplo, abaixo construímos o iterador com elementos True ou False dependendo de ser o número par ou impar.

» par = (x%2==0 for x in it.count())
» for pares in it.compress(it.count(), par):
»     print(pares, end=' ')
↳ 0 2 4 6 8 10 12 14 16 18 20 22 24 26 28 30 32 34 ...

Função tee(iteravel): recebe um iterável e retorna dois objetos clonados do original. O iterável original fica esgotado depois da operação. Também é possível passar um parâmetro para gerar n clones, em itertools.tee(iteravel, n).

» elementos = (x**x for x in range(6) if x**x%2==0)
» lista1, lista2 = it.tee(elementos)
» print(lista1)                      # as listas são objetos itertools._tee
↳ <itertools._tee object at 0x7fc032da5ac0>

» print(list(lista1), list(lista2))
↳ ['1', '2', '3'] ['1', '2', '3']
» print(list(elementos))             # a lista original fica esgotada
↳ []

# um parâmetro pode ser passado para gerar n clones
» vitaminas = ['A', 'B12']
» vitas = it.tee(vitaminas, 4)
» for i in vitas:
»     print(list(i), end=' ')
↳ ['A', 'B12'] ['A', 'B12'] ['A', 'B12'] ['A', 'B12'] 

Função dropwhile(função, iteravel): recebe um iterável e uma função seletora (que avalia cada elemento do iterável como um booleano) e retorna os elementos do iterável à partir do primeiro retorno False da função. Em outras palavras ela descarta os elementos avaliados como True no início da lista (ou iterável). Podemos obter a lista à partir do último valor avaliado como False com reversed(lista).

» def par(x):                       # retorna True se x é par
»     return x % 2 == 0
» numeros = [0, 2, 4, 8, 16, 17, 32, 64, 67, 128]

» # o primeiro False ocorre em 17
» for t in it.dropwhile(par, numeros):
»     print(t, end=' ')
↳ 17 32 64 67 128

» # o mesmo pode ser conseguido com uma função lambda
» print(list(it.dropwhile(lambda t: t%2==0 , numeros)))
↳ [17, 32, 64, 67, 128]

» # lista anterior ao último valor avaliado como False (em ordem reversa)
» print(list(it.dropwhile(lambda t: t%2==0 , reversed(numeros))))
↳ [67, 64, 32, 17, 16, 8, 4, 2, 0]

Função takewhile(função, iteravel): retorna todos os termos iniciais de um iterável até que um de seus elementos avalie como False pela função. Os demais elementos, mesmo que avaliando como True, são descartados.

» anos = [10,30,50, 70, 90, 110, 1, 2, 3]
» take = it.takewhile(lambda x: x < 100, anos)
» for a in take:
»     print(a, end= ' ')
↳ 10 30 50 70 90 

Função itertools.zip_longest(iter1, iter2): diferente da função interna zip(), zip_longest(iter1, iter2) retorna um iterador com o comprimento da maior entre iter1, iter2, substituindo valores ausentes por None.

» # usando zip (built-in)    
» x = [0, 1, 2, 3, 4, 5]
» y = ['A', 'B', 'C']
» list(zip(x, y))
↳ [(0, 'A'), (1, 'B'), (2, 'C')]

» # usando itertools.zip_longest
» list(it.zip_longest(x, y))
↳ [(0, 'A'), (1, 'B'), (2, 'C'), (3, None), (4, None), (5, None)]

Iteradores infinitos

Função count(): a funçãocount() produz uma sequência de comprimento indefinido de inteiros, apropriada para contagem.

» import itertools as it
» t = 0
» for x in it.count():
»     if x > 10: break
»     print(x, end=' ')
↳ 0 1 2 3 4 5 6 7 8 9 10    

Se uma condição de parada do loop não for inserida ele gera uma quantidade indefinida de números. A função pode receber os parâmetroscount(inicio, passo), para indicar o início da contagem é o incremento.

» for i in it.count(10, 2):
»     if i > 24:
»         break
»     else:
»         print(i, end=' ')
↳ 10 12 14 16 18 20 22 24

Um iterador não retorna elementos indexados, o que significa que não podemos tomar apenas uma fatia (ou slice). No entanto podemos usar a função itertools.isslice().

Função isslice(iterador, n): Retorna os primeiros n elementos do iterador.

» quadrados = (n**2 for n in it.count())
» list(it.islice(quadrados, 10))
↳ [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Outra abordagem possível é usar itertools.takewhile (ou itertools.dropwhile) para fazer filtragens:

» algunsQuadrados = (n**2 for n in it.count(25, 5))
» list(it.takewhile(lambda x: x<2000, algunsQuadrados))
↳ [625, 900, 1225, 1600]

Função repeat(objeto, n): retorna o objeto repetido n vezes, em um iterável. Se n for omitido (o que é o mesmo que passar n=None) o ciclo se repete indefinidamente.
Função cycle(objeto): é o mesmo que repeat() retornando o objeto um número indefinido de vezes.

»  função repeat
» for i in it.repeat([1,2,3], times = 3):
»     print(i, end = ' ')
↳ [1, 2, 3] [1, 2, 3] [1, 2, 3]

» função cycle
» l = ['Far', 'West', 'Wing']
» i = 0
» for t in it.cycle(l):
»     if i < 5:
»         print(t, end=' ')
»     else:
»         break
»     i += 1
↳ Far West Wing Far West 

Funções Geradoras

Como sabemos, uma função do Python é executada de forma sequencial e sem interrupções, até que uma instrução return seja encontrada. Nada depois disso é executado. (Lembrando: uma função sem return retorna None.)

» def exibeLinhas():
»     print('linha 1')
»     print('linha 2')
»     print('linha 3')

» exibeLinhas()
↳  linha 1
↳  linha 2
↳  linha 3

Instrução yield: É possível pausar uma função no meio de sua execução, retomando depois no ponto de pausa. Com isso é possível criar funções que agem como um iterador. Uma função contendo pelo menos uma instrução yield, é chamada uma função geradora. Geradores são uma generalização de iteradores que produzem seus dados apenas sob demanda, sendo por isso chamados de lazy (prequiçosos).

» def funcaoGeradora():
»     print('Linha 1 é retornada')
»     yield 1
»     print('Linha 2 é retornada')
»     yield 2
»     print('Linha 3 é retornada')
»     yield 3

» gera = funcaoGeradora()
» print(gera)
↳ <generator object funcaoGeradora at 0x7f69643c8dd0>
» print(next(gera))
↳ Linha 1 é retornada
↳ 1
» print(next(gera))
↳ Linha 2 é retornada
↳ 2
» print(next(gera))
↳ Linha 3 é retornada
↳ 3
» print(next(gera)) # um erro é lançado "StopIteration"

Esse tipo de função retorna um objeto gerador que é, em termos de comportamento, um iterador. A iteração sobre esse objeto é feita com a função interna next(). Quando o iterador está esgotado o uso de next() retorna uma exceção.

yield pode ser usado para retornar qualquer valor no iterador, inclusive None, se um valor for omitido. yield substitui a instrução return que, se usada, interrompe o ciclo do iterador. Uma função geradora pode ser transformada, com todos os seus valores, em uma lista, com list(geradora).

» def geradora():
»     for i in range(10):
»         yield i

» g = geradora()
» while True:
»     try:
»         print(next(g), end=' ')
»     except:
»         break
↳ 0 1 2 3 4 5 6 7 8 9

» g = geradora() # para repopular o iterador
» for i in g:
»     print(i, end=' ')
↳ 0 1 2 3 4 5 6 7 8 9

» g = geradora()
» lista = list(g)
» print(lista)
↳ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

A função geradora só pode ser percorrida uma vez, ficando esgotada no final. Como mostrado no último bloco de código, da mesma forma que uma função geradora pode ser percorrida com next(), o iterador por ela gerado pode ser percorrido em um loop for.

Relembrando: Uma classe pode retornar um objeto iterável por meio dos métodos especiais __iter__() e __next__(). O código next(g) é equivalente à chamar o método interno do objeto iterável, g.__next__().

» # class iteradora
» class Quadrados:
»     def __init__(self, quantos):
»         self.quantos = quantos
»         self.atual = 0​

»     def __iter__(self):
»         return self

»     def __next__(self):
»         quad = self.atual ** 2
»         self.atual += 1
»         if self.atual > self.quantos:
»             raise StopIteration
»         return quad

» q = Quadrados(11)
» for s in q:
»     print(s, end=', ')
↳ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100, 

As funções geradoras podem conseguir o mesmo resultado de forma mais compacta. As operações de iteração, de obter o elemento seguinte e a parada por exceção são automaticamente fornecidas. Elas retornam um objeto gerador, que é um iterável.

» def quadrados(quantos):
»     for n in range(quantos):
»         yield n**2

» q = quadrados(11)
» for s in q:
»     print(s, end=', ')        
↳ 0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100,

Uma função geradora pode guardar o estado em que terminou a última chamada até ser chamada novamente. Por exemplo, podemos contruir uma geradora para exibir os elementos da sequência de Fibonacci, indefinidamente.

» # Ex.: geradora da sequência Fibonacci
» def fibonacci():
»     a, b = 1, 1
»     while True:
»         yield a
»         a, b = b, a + b
        
» # inicializamos um objeto da sequência
» f = fibonacci()
» # as 10 primeiras iterações resultam em
» for i in range(10):
»     print(next(f), end=' ')
↳ 1 1 2 3 5 8 13 21 34 55 

» # as 10 iterações seguintes
» for i in range(10):
»     print(next(f), end=' ')
↳ 89 144 233 377 610 987 1597 2584 4181 6765 

Podemos também calcular quantos números primos se desejar, desde que o computador tenha capacidade de processamento e memória para isso.

» def numerosPrimos():
»     num = 2
»     yield num
»     while True:
»         num += 1
»         primo = True
»         for i in range(2, int(num/2)):
»             if(num % i) == 0: # achou um divisor
»                 primo = False # logo não é primo
»                 break
»         if primo:
»             yield num

» for p in numerosPrimos():
»     print(p, end=' ')
»     if p > 100:
»         break
↳ 2 3 4 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101                 

Os dois últimos exemplos ilustram o fato de que a função geradora armazena seu estado entre as chamadas e os retornos com yield. Como já vimos o loop for cuida das chamadas a next() e o encerramento do laço, quando existir.

Método send(): Outra característica importante dos geradores é a possibilidade de interagir com o código do iterador. Um valor pode ser passado para yield através do método send(). Em outras palavras, além de retornar um valor calculado dentro da função geradora yield recebe o valor passado por send(), que funciona como next() mas passa um valor para yield.

» # definimos uma função geradora com send
» def usandoSend():
»     while True:
»         recebido = yield
»         print('Recebido =', recebido)

» f = usandoSend()    # inicializamos um objeto gerador
» next(f)             # a primeira interação deve ser feita para a entrada no iterador

» # passando integer 0 para yield
» f.send(0)
↳ Recebido = 0
» # passando string para yield
» f.send('palavra')
↳ Recebido = palavra
» # uma iteração send envia None para yield
» next(f)
↳ Recebido = None

Com essa funcionalidade o código que chama o iterador pode modificar o comportamento da função e, portanto, dos valores retornados. No exemplo abaixo o gerador retorna inteiros, de 1 em 1, exceto se um incremento extra for enviado por meio de send().

» # definimos uma função geradora com send
» def usandoSend():
»     n = 0
»     while True:
»         n +=1
»         incremento = yield n
»         if recebido:
»             n += incremento

» # inicializamos o gerador
» f = usandoSend()    # inicializamos um objeto gerador
» # os 4 primeiros outputs são
» print(next(f), next(f), next(f), next(f))
↳ 1 2 3 4
» # incremento 4 primeiros outputs são
» p = f.send(10)
» print(p)
↳ 15
» # os 4 próximos outputs são
» print(next(f), next(f), next(f), next(f))
↳ 16 17 18 19

O exemplo seguinte usa send() e será usado na próxima seção para ilustrar o uso de throw().

» # definimos um gerador
» def gerador_letras(texto):
»     local = 0
»     while True:
»         mandou = yield texto[local]
»         if mandou:
»             local = mandou
»         else:
»             local += 1

» # inicializamos um gerador            
» let = gerador_letras('Apocalipse Zumbi')
» next(let)
↳ 'A'
» # enviamos via send
» let.send(11)
↳ 'Z'
» next(let)
↳ 'u'

Método throw(): throw() permite que se lance uma exceção no ponto onde o gerador foi interrompido, na última execução. Ele ativa a exceção e retorna o valor seguinte (como faria yield), ou uma StopIteration se o gerador estiver esgotado. A exceção deve ser tratada dentro da função geradora, caso contrária será repassada para o código que chamou a função. A função abaixo permite que se veja em que ponto está a execução do gerador, usando throw().

» # definindo gerador com tratamento para exceção
» def gerador_letras(texto):
»     local = 0
»     while True:
»         try:
»             mandou = yield texto[local]
»         except Exception:
»             print(f'Exceção atingida na posição {local}')
»         if mandou:
»             local = mandou
»         else:
»             local += 1

» let = gerador_letras('Sei que nada será como antes')
» print(next(let), next(let))
↳ S e

» # pula a posição para 4
» let.send(4)
↳ 'q'

» # levanta exceção (que, no caso, mostra posição atual no gerador)
» let.throw(Exception)
↳ Exceção atingida na posição 4
↳ 'q'

» print(next(let), next(let))
↳ u e
  • throw(exceção): permite que se envie para o iterador qualquer tipo de exceção,
  • close(): fecha o iterador e levanta a exceção GeneratorExit.
» # fechando o iterador
» f.close()
» f.send('palavra')   # uma exceção é lançada
↳ StopIteration

yield from: permite que um gerador chame outros, de forma sequencial. No exemplo abaixo o gerador_principal esgota primeiro o gerador1, depois o gerador2, em sequência.

» # geradores de geradores
» def gerador1():
»     yield 'Linha 1 do gerador 1'
»     yield 'Linha 2 do gerador 1'​

» def gerador2():
»     yield 'Linha 1 do gerador 2'
»     yield 'Linha 2 do gerador 2'

» def gerador_principal():
»     yield from gerador1()
»     yield from gerador2()

» delegando = gerador_principal()
» print(next(delegando))
» print(next(delegando))
» print(next(delegando))
» print(next(delegando))
↳ Linha 1 do gerador 1
↳ Linha 2 do gerador 1
↳ Linha 1 do gerador 2
↳ Linha 2 do gerador 2

» # o mesmo resultado seria obtido com
» delegando = gerador_principal()
» for i in delegando:
»     print(i)    # linhas de output omitidas

Observação: Alguns objetos built-in são geradores e têm internamente implementados os métodos yield, next, iter, StopIteration . São eles:
range, dict.items, zip, map e File Objects.

A conjectura de Collatz

O exemplo de função geradora a seguir ilustra um problema interessante na matemática, denominado conjectura de Collatz. Collatz se perguntou se uma operação aritmética simples repetida sobre números inteiros positivos produziria sempre uma sequência terminada em 1. A sequência geralmente considerada é a seguinte: iniciando com um inteiro n cada termo da sequência é gerado da seguinte forma. Se o número é par o seguinte é n/2; se o número é impar o seguinte é 3n + 1. Para todos os inteiros testados a conjectura é verificada. Por exemplo, começando com n=7 temos: {7, 22, 11, 34, 17, 52, 26, 13, 40, 20, 10, 5, 16, 8, 4, 2, 1}. No entanto até hoje não foi possível provar o caso geral e o problema permanece em aberto.

Para gerar a sequência vamos construir um iterador que recebe um parâmetro n, o número inicial da sequência. Observe que a sequência seria infinita sem um corte pois 1 ⟶ 4 ⟶ 2 ⟶ 1, um loop infinito. Portanto a interrompemos manualmente quando ela atinge 1. O iterador é usado pela função collatz(n) que retorna a sequência inteira e quantas iterações foram feitas (protanto quantos elementos existem na sequência).

» # sequência de Collatz
» def iterador(numero):
»     while True:
»         yield numero
»         numero = int(numero/2 if numero%2==0 else 3 * numero + 1) 

» def collatz(n):
»     itt = iterador(n)
»     quantos = 0

»     while True:
»         quantos += 1
»         val = next(itt)
»         print(val, end=' ')
»         if val == 1:
»             print(f'\nFinal alcançado em {quantos} iterações!')
»             break

» collatz(7)
↳ 7 22 11 34 17 52 26 13 40 20 10 5 16 8 4 2 1 
↳ Final alcançado em 17 iterações!
Sequência de Collatz para n = 7

Geradores e gerenciamento de memória


Geradores são uma forma prática de interagir com grande volume de dados sem esgotar a memória do computador. Eles permitem que um grande volume de dados possam ser acessados em blocos, por partes. Cada bloco pode ser retornado sob demanda, enquanto o gerador armazena seu estado entre cada chamada.

Bibliografia

Tabela de cores Html e CSS


As bibliotecas gráficas do Python, Matplotlib, Seaborn e Bokeh aceitam cores no sistema css e a maioria dos nomes ou shortcuts. O código hexadecimal é formado por 3 números, de 0 até 255 (ou 00 até FF), para daterminar a intensidade das cores R, G, B (vermelho, verde, azul). Dessa forma FFFFFF representa (255, 255, 255), ou branco; 000000 representa (0, 0, 0) ou preto. FF0000 é vermelho puro, 00FF00 é verde puro, 0000FF é azul puro. Além disso se pode utilizar o parâmetro alpha=a, onde 0 ≤ a ≤1 para a opacidade da cor, sendo 1 opacidade máxima.

Cores podem ser definidas como:

  • no formato hexadecimal (como #123456), com strings do CSS4 rgb(), rgba() ou hsl(): como rgb(0 127 0 / 1.0), rgba(255, 0, 127, 0.6) ou hsl(60deg 100% 50% / 1.0).
  • como tupla de inteiros (r, g, b), onde r, g, b são inteiros entre 0 e 255.
  • como a tuple (r, g, b, a), onde r, g, b são inteiros entre 0 e 255 e a é número de ponto flutuante entre 0 e 1.
  • um inteiro sem sinal representando RGBA values no padrão 0xRRGGBBAA, 0xffff00ff ou 0xff0000ff.

Segue uma tabela com as cores em código hexadecimal e seu nome.

Cor Hexadecimal Html/CSS Cor Hexadecimal Html/CSS Cor Hexadecimal Html/CSS
#000000 Black #000080 Navy #00008B DarkBlue
#0000CD MediumBlue #0000FF Blue #006400 DarkGreen
#008000 Green #008080 Teal #008B8B DarkCyan
#00BFFF DeepSkyBlue #00CED1 DarkTurquoise #00FA9A MediumSpringGreen
#00FF00 Lime #00FF7F SpringGreen #00FFFF Aqua
#00FFFF Cyan #191970 MidnightBlue #1E90FF DodgerBlue
#20B2AA LightSeaGreen #228B22 ForestGreen #2E8B57 SeaGreen
#2F4F4F DarkSlateGray #2F4F4F DarkSlateGrey #32CD32 LimeGreen
#3CB371 MediumSeaGreen #40E0D0 Turquoise #4169E1 RoyalBlue
#4682B4 SteelBlue #483D8B DarkSlateBlue #48D1CC MediumTurquoise
#4B0082 Indigo #556B2F DarkOliveGreen #5F9EA0 CadetBlue
#6495ED CornflowerBlue #663399 RebeccaPurple #66CDAA MediumAquaMarine
#696969 DimGray #696969 DimGrey #6A5ACD SlateBlue
#6B8E23 OliveDrab #708090 SlateGray #708090 SlateGrey
#778899 LightSlateGray #778899 LightSlateGrey #7B68EE MediumSlateBlue
#7CFC00 LawnGreen #7FFF00 Chartreuse #7FFFD4 Aquamarine
#800000 Maroon #800080 Purple #808000 Olive
#808080 Gray #808080 Grey #87CEEB SkyBlue
#87CEFA LightSkyBlue #8A2BE2 BlueViolet #8B0000 DarkRed
#8B008B DarkMagenta #8B4513 SaddleBrown #8FBC8F DarkSeaGreen
#90EE90 LightGreen #9370DB MediumPurple #9400D3 DarkViolet
#98FB98 PaleGreen #9932CC DarkOrchid #9ACD32 YellowGreen
#A0522D Sienna #A52A2A Brown #A9A9A9 DarkGray
#A9A9A9 DarkGrey #ADD8E6 LightBlue #ADFF2F GreenYellow
#AFEEEE PaleTurquoise #B0C4DE LightSteelBlue #B0E0E6 PowderBlue
#B22222 FireBrick #B8860B DarkGoldenRod #BA55D3 MediumOrchid
#BC8F8F RosyBrown #BDB76B DarkKhaki #C0C0C0 Silver
#C71585 MediumVioletRed #CD5C5C IndianRed #CD853F Peru
#D2691E Chocolate #D2B48C Tan #D3D3D3 LightGray
#D3D3D3 LightGrey #D8BFD8 Thistle #DA70D6 Orchid
#DAA520 GoldenRod #DB7093 PaleVioletRed #DC143C Crimson
#DCDCDC Gainsboro #DDA0DD Plum #DEB887 BurlyWood
#E0FFFF LightCyan #E6E6FA Lavender #E9967A DarkSalmon
#EE82EE Violet #EEE8AA PaleGoldenRod #F08080 LightCoral
#F0E68C Khaki #F0F8FF AliceBlue #F0FFF0 HoneyDew
#F0FFFF Azure #F4A460 SandyBrown #F5DEB3 Wheat
#F5F5DC Beige #F5F5F5 WhiteSmoke #F5FFFA MintCream
#F8F8FF GhostWhite #FA8072 Salmon #FAEBD7 AntiqueWhite
#FAF0E6 Linen #FAFAD2 LightGoldenRodYellow #FDF5E6 OldLace
#FF0000 Red #FF00FF Fuchsia #FF00FF Magenta
#FF1493 DeepPink #FF4500 OrangeRed #FF6347 Tomato
#FF69B4 HotPink #FF7F50 Coral #FF8C00 DarkOrange
#FFA07A LightSalmon #FFA500 Orange #FFB6C1 LightPink
#FFC0CB Pink #FFD700 Gold #FFDAB9 PeachPuff
#FFDEAD NavajoWhite #FFE4B5 Moccasin #FFE4C4 Bisque
#FFE4E1 MistyRose #FFEBCD BlanchedAlmond #FFEFD5 PapayaWhip
#FFF0F5 LavenderBlush #FFF5EE SeaShell #FFF8DC Cornsilk
#FFFACD LemonChiffon #FFFAF0 FloralWhite #FFFAFA Snow
#FFFF00 Yellow #FFFFE0 LightYellow #FFFFF0 Ivory
#FFFFFF White

Matplotlib


Quando lidamos com dados precisamos, muitas vezes, visualizar de forma gráfica esses dados. Em muitas tarefas é útil, ou até mesmo essencial, que as conclusões das análises sejam mostradas através de gráficos. Mesmo nas fases iniciais de uma análise, na preparação e limpeza de dados, a visualização é importante visualizar para se compreender padrões, tendências e anomalias, tais como pontos fora da curva. Existem no Python inúmeras bibliotecas para visualização de dados e montagem de gráficos. Matplotlib é o módulo básico para uso em conjunto com o pandas.

A biblioteca é grande, com extensas possibilidades e tem sido usada como base para a elaboração de outros módulos gráficos, como o Seaborn. O aprendizado da biblioteca inteira pode demandar um esforço considerável mas o uso básico, suficiente para muitos projetos, não demanda tanto empenho. Além disso o pandas tem uma vinculação natural com a biblioteca, como veremos.

Instalação

Matplotlib é instalado junto com a distribuição do python via Anaconda. Se você não está usando Anaconda é possível encontrar maiores instruções de instalação no site de Matplotlib.

Um pouco de história

Matplotlib começou a ser desenvolvida em 2003 por John D. Hunter, um neurocientista que usava MATLAB e queria aperfeiçoar a visualização de dados obtidos por meio de EEGs (eletroencelografia) em sua pesquisa sobre epilepsia. Hoje uma a comunidade de desenvolvedores colaboram para manter e aperfeiçoar a biblioteca.

Como muitos usuários e desenvolvedores estavam acostumados ao ambiente do MATLAB, onde todas as funções estão disponíveis globalmente sem a necessidade de importações, o módulo pylab foi desenvolvido. Ele existe para trazer funções e classes do NumPy e matplotlib para o namespace global. Isso significa que o comando from pylab import * em uma sessão significa a importação desses módulos e é desnecessária para quem está acostumado com o estilo do python. Como já vimos a importação de muitos módulos, funções e classes pode provocar conflito entre as partes importadas e os métodos built-in do python.

De fato, o uso de ipython --pylab para quem usa o comando de linha, ou %pylab de dentro do Jupyter, simplesmente faz uma chamada interna à from pylab import *. Nesse sentido se recomenda, para quem trabalha com IPython e Jupiter Notebook, que se use a “mágica” %matplotlib.

Por todos esses motivos usaremos a abordagem usual do python:

» import matplotlib.pyplot as plt
» import numpy as np
» np.random.seed(444)
» # para exibição dos gráficos no ambiente do jupyter notebook usamos
» %matplotlib
» # para exibição incorporada dentro do notebook
» %matplotlib inline
» # ou, para exibição dentro do notebook, com controles de zoom e arraste
» %matplotlib notebook

Numpy será usado para as contruções de arrays e geração de dados aleatórios. A informação de np.random.seed() serve para que os geradores produzam os mesmos números em seções posteriores, para reproducibilidade. A mágica %matplotlib faz com que os gráficos sejam exibidos. Nesse caso uma nova janela é aberta com uma barra de menus com acesso à ampliação, arraste, gravação em vários formatos (como pdf, jpg, png), e parâmetros do gráfico. Já a inserção de %matplotlib inline faz com que os gráficos fiquem embutidos no próprio notebook e sejam gravados com ele. A janela de controle não aparece. Usando %matplotlib notebook temos o gráfico embutido com acesso à controles de zoom, arraste e gravação em arquivo.

Técnica Básica

O matplotlib pode receber como fonte de dados listas, arrays do numpy, Series e dataframes do pandas. Por exemplo, o código seguinte recebe listas gera as figuras 1 e 2 abaixo:

» %matplotlib inline
» plt.plot([0,3,0,5,0,7,0])
» # a figura 1 é gerada

» plt.plot([0,1,2,3,4,5,6],[0,3,0,5,0,7,0])
» # o mesmo que antes (figura 1 é gerada)

» x = np.arange(101)-50
» y = x**2
» plt.plot(x,y)
» # a figura 2 é gerada

Quando apenas uma lista é fornecida plot usa os índices como coordenada horizontal (abcissa) e os valores da lista como coordenadas verticais (ordenadas). Quando duas listas de mesmo tamanho são fornecidas a primeira é usada para os valores das abcissas, a segunda como ordenadas. No segundo exemplo, que gera a figura 2, foram usadas coordenadas (x, x2) (uma parábola) com x variando no intervalo [-50, 50].

Usando %matplotlib notebook o gráfico é exibido inline mas trazendo controles de ajuste da imagem. Ao se clicar no botão azul (pode ter outra cor na sua instalação) os controles desaparecem e a imagem fica estática.

» %matplotlib notebook
» x = np.arange(40)
» plt.plot(x,np.exp(x/10))    # exibe a figura 3

» # outro exemplo: seno
» # dados a plotar 
» x = np.arange(0.0, 2.0, 0.01)
» y = np.sin(2 * np.pi * x)

» fig, ax = plt.subplots()
» ax.plot(x, y)

» ax.set(xlabel='eixo x', ylabel='y = seno(x)', title='Gráfico de seno(2 pi x)')
» ax.grid()

» fig.savefig('seno.jpg')
» plt.show()                  # exibe a figura 4

No primeiro caso plotamos simplesmente o gráfico de y = exp(x/10). No segundo exemplo criamos um array no intervalo [0, 2) em passos de .01. A coordenada é y = sen(2πx). Usamos as funções exponencial e seno do numpy para lidar com a operação vetorializada (que pode ser aplicada sobre todo o array). Já veremos com maiores detalhes os métodos de matplotlib.pyplot.

Hierarquia de objetos do Matplotlib

Mesmo em exemplos simples, como os anteriores, Matplotlib usa uma hierarquia de objetos. Por hierarquia se entende que objetos dependem de outros, como em ramos de uma árvore. Repetindo o gráfico da figura 4 temos:

†: O termo axes do matplotplib não se refere a “eixos” e sim a “figuras” individuais, dentro de uma Figure.

Figure é o objeto básico ou o mais externo de um gráfico. Ele pode conter diversos Axes, que são gráficos ou plotagens individuais. Axes, por sua vêz, podem conter legendas, marcas gráficas, curvas e caixas de texto. Cada um desses elementos são objetos do python com métodos e propriedades que podem ser manipuladas individualmente.

Vamos verificar em código como esses objetos são criados e manipulados.

» # geramos os dados a imprimir
» x = np.arange(0.0, 2.0, 0.01)
» y = np.sin(2 * np.pi * x)

» # O objeto básico do matplotlib.pyplot (aliás plt) é figure
» fig = plt.figure()
» # De figure derivamos um axes (subplot) e plotamos 3 curvas
» ax = fig.add_subplot()

» ax.plot(x, y)
» ax.plot(x, x)
» ax.plot(x, x**2)

» # definimos os labels dos eixos x e y e o título do gráfico
» ax.set(xlabel='eixo x', ylabel='seno(x), x, x^2',
»        title='y = seno, reta e parábola')
» # acrescentamos um quadriculado (grid)
» ax.grid()

» # salvamos a figura no disco
» fig.savefig('seno.jpg')
» # exibimos o resultado
» plt.show()

Ao objeto ax acrescentamos 3 plots (y=sen(2πx), y=x, y=x2), os labels dos eixos, o título do gráfico e o quadriculado de fundo. Opcionalmente a figura pode ser salva. A figura só é exibida quando plt.show() é executado.

O método add_subplot admite diversos parâmetros: fig.add_subplot(m,n,r) significa criar um gráfico em m linhas, n colunas, na posição r. Um subplot significa dividir a região destinada ao gráfico em m×n partes onde se pode colocar sub-gráficos.

Por ex., vamos criar uma figura com 4 subplots, plotando uma curva diferente em cada uma delas.

» x = np.arange(0.0, 2.0, 0.01)-1
» fig = plt.figure()

» ax1 = fig.add_subplot(2,2,1)               # 2 linhas, 2 colunas: a 1ª figura
» ax2 = fig.add_subplot(2,2,2)               # a 2ª figura
» ax3 = fig.add_subplot(2,2,3)
» ax4 = fig.add_subplot(2,2,4)

» ax1.plot(x, np.sin(10*x))
» ax2.plot(x, x)
» ax3.plot(x, x**2)
» ax4.plot(x, x**3)
» fig.savefig('figura5.jpg')

» plt.show()

A mesma figura pode ser obtida fazendo os plots diretamente para os axes:

» fig = plt.figure()
» ax1 = fig.add_subplot(2, 2, 1)
» plt.plot([-1, 0, 1, 2])
» ax2 = fig.add_subplot(2, 2, 2)
» ax3 = fig.add_subplot(2, 2, 3)
» plt.plot(np.random.randn(50).cumsum(), 'k--')
» ax4 = fig.add_subplot(2, 2, 4)
» plt.plot([1.5, 3.5, -2, 1.6])

O método plt.plot(dados) se refere ao eixo ativo que é aquele criado ou usado por último. No caso acima nenhuma figura foi plotada no 2 º retângulo.

O procedimento de criar vários subplots dentro de um mesmo gráfico pode resumido por meio do método
fig, axes = plt.subplots().
subplots() retorna uma tupla onde o 1&orm; elemento é uma Figure, o objeto básico de um plot, e o 2º são os axes que recebem as curvas.

Esses axes podem ser referenciados individualmente pela notação de array. Por ex.: em
fig, axes = plt.subplots(2, 3)
temos axes[0,0] até axes[1,2].

np.random.randn(50).cumsum()
retorna a soma cumulativa dos elementos de um array de 50 elementos “aleatórios”.

pyplot.subplots possui as opções:

nrows número de linhas
ncols número de colunas
sharex todos os subplots devem ter os mesmos “ticks” no eixo x
sharey todos os subplots devem ter os mesmos “ticks” no eixo y
subplot_kw dicionário de chaves para criar cada subplot
**fig_kw chaves adicionais, como plt.subplots(2,2,figsize=(8,6))

Formatação dos gráficos

Tamanho

O tamanho de uma figura, por default dada em polegadas, é definido pelo parâmetro figsize em plt.figure(figsize=(largura, altura)).

» x = np.arange(.1, 10, 0.01)
» largura = 5; altura = 2
» plt.figure(figsize=(largura, altura))
» plt.plot(x, np.log(x))
» plt.show()                       # gerado o gráfico na figura 8

O mesmo gráfico é gerado usando esse parâmetro no construtor da figure, mas desenhado com linhas pontilhadas, devido ao parâmetro ‘k–‘ em ax.plot(x, np.log(x), 'k--').

» x = np.arange(.1, 10, 0.01)
» fig = plt.figure(figsize=(5, 2))
» ax = fig.add_subplot()
» ax.plot(x, np.log(x), 'k--')
» plt.show()                       # gerado o gráfico na figura 9

Espaçamento entre subplots, cores e marcadores

O espaçamento entre figuras de um gráfico com subplots, que por default é um espaço não nulo, pode ser ajustado por meio do método Figure.subplots_adjust(). Por conveniência o mesmo método pode ser acessado diretamente pela função:
subplots_adjust((left=None, bottom=None, right=None, top=None, wspace=None, hspace=None).
wspace e hspace indica quanto espaço percentual em relação à largura e altura da figura, respectivamente.
Por exemplo, para juntar os subplots fazemos ambos igual a zero.

» x = np.arange(.1, 10, 0.01)
» fig, axes = plt.subplots(2, 2)
» axes[0, 0].hist(np.random.randn(1000), bins=100, alpha=.5)
» axes[0, 0].hist(np.random.randn(500), bins=50, color='r', alpha=.5)
» axes[0, 1].hist(np.random.randn(500), bins=50, color='gold', alpha=1)
» axes[1, 0].hist(np.random.randn(500), bins=50, color='#ff0000')
» axes[1, 1].plot(x, 3*np.log(x), color='#55aaff')
» axes[1, 1].plot(x, x, color='#000000')
» plt.subplots_adjust(wspace=0, hspace=0)


Para usar os mesmos eixos em todos os 4 gráficos usamos os parâmetros sharex, sharey, em
fig, axes = plt.subplots(2, 2), sharex=True, sharey=True).

Em hist(np.random.randn(1000), bins=100) traçamos o histograma de dados aletórios (100 números) separados em 100 bins. No 1º axes traçamos 2 histogramas, o 1º com a cor azul default, o 2º com color=’r’, um atalho para ‘red’ ou vermelho, com transparência de 50%, alpha=.5. Uma lista de cores nomeadas, como color=’gold’ pode ser encontrada no site do matplotlib. Também podemos usar o código de cores html que consiste em 3 números hexadecimais de 0 até 255 (ou 00 até ff em hexadecimal), no sistema rgb (vermelho, verde, azul). Diversos editores de imagens ou de código disponilizam um seletor de cores que retorna a cor nesse sistema. O site HTML-COLOR.CODES também tem um seletor online.


O método axes.plot(), além de aceitar arrays para abcissas e coordenadas, pode receber também o string especificador de cor e tipo de linha. Para imprimir em verde (‘green’) com linha tracejada usamos
ax.plot(x, y, 'g--'),
que é uma forma resumida de passar parâmetros. Isso é o mesmo que:
ax.plot(x, y, linestyle='--', color='g').
Considerando que x e y são arrays de mesmo tamanho, alguns exemplos desses parâmetros são:

» plot(x, y)            # plot x, y com linha e estilo default
» plot(x, y, 'bo')      # plot x, y com marcadores azuis circulares
» plot(x, y, 'rv')      # plot x, y com marcadores vermelhos, triângulo para baixo
» plot(y)               # plot y usando seus índices como coordenadas-x
» plot(y, 'r+')         # idem, usando cruzes vermelhas

No Jupyter Notebook use plt.plot? para ver uma lista completa dos parâmetros desse método.

Ao desenhar um gráfico pode ser interessante marcar os pontos sobre as curvas contínuas. Isso é feito com markers ou marcadores.

» from numpy.random import randn
» plt.plot(randn(30).cumsum(), color='green', linestyle='dashed', marker='o')

Uma forma abreviada para o mesmo comando é: plt.plot(randn(30).cumsum(), 'go--'), onde os parâmetros são passados em uma única string, com g para green (verde), o para o marcador circular e -- para o estilo de linha tracejado.

Por default os pontos de um plot são ligados por linhas. Para outro estilo usamos drawstyle:

» data = np.random.randn(20).cumsum()
» plt.plot(data, 'b--', label='default', marker='v')                      # linha azul
» plt.plot(data, 'r-', drawstyle='steps-post', label='passos')            # linha vermelha
» plt.legend(loc='best')


A linha azul é tracejada (‘b–‘), no estilo default e com marcadores ‘v’ (triângulos). O parâmetro label cria legendas, nesse caso indicando o texto ‘defaul’. A linha vermelha tem estilo drawstyle=’steps-post’ (em passos) e é marcada na legenda como ‘passos’. plt.legend(loc='best') informa que o melhor local para colocar essa legenda seja encontrado automaticamente. Outros valores seriam: loc='right', 'center', 'upper right', etc.

Marcas, etiquetas e legendas (ticks, labels, legends)

» dados = np.random.randn(1000)
» cumulativo = dados.cumsum()

» fig = plt.figure()
» ax = fig.add_subplot(1,1,1)
» ax.plot(10*dados + 10)
» ax.plot(cumulativo)


As duas plotagens são sobrepostas no único axes criado. O operação 10*dados+10 serve apenas para efeito estético da apresentação dos dados aleatórios.

Vamos usar os mesmos dados para verificar as propriedades de ajuste do título global do gráfico, labels nos eixos x e y, label do gráfico e ticks para melhorar a apresentação do gráfico anterior.

» fig = plt.figure()
» ax = fig.add_subplot(1,1,1)
» ax.set_title('Alterando eixos com matplotlib')
» ticks = ax.set_xticks([0, 250, 500, 750, 1000])
» ticks = ax.set_yticks([-20, 0, 20, 40, 60, 80])

» labels = ax.set_xticklabels(['seg' ,'ter', 'qua', 'qui', 'sex'],
»                             rotation=45, fontsize='large')
» labels = ax.set_yticklabels(['-A' ,'O', 'A', 'B', 'C','D'],
»                             fontsize='large')

» ax.set_xlabel('Ao longo dos dias...')
» ax.set_ylabel('Observado')

» ax.plot(10*dados, color='#55aaff', alpha=.5, label='dados')
» ax.plot(cumulativo, color='red', label='cumulativo')
» ax.legend(loc='best')

Anotações e desenhos nos subplots.

Diversos tipos de anotações, setas e desenhos podem ser incluídos nos gráficos. Para traçar os gráficos seguintes vamos usar o arquivo .csv do Our World in Data, baixados para a pasta ./dados.

Primeiro importamos o arquivo baixado .csv para um dataframe. Esse arquivo contém dados dos países do mundo, nos anos de 1950 até 2099, contendo número de nascimentos verificados até 2020 e valores interpolados para os anos seguintes. O dataframe original tem o seguinte formato:

Em seguida selecionamos apenas os dados sobre o Brasil.

» dados = pd.read_csv('./dados/number-of-births-per-year.csv')
» dados=dados[(dados['Entity']=='Brazil')]
» dados.head(2)

» # as colunas 3 e 4 têm nomes longos, que vamos renomear
» dados.columns[3],dados.columns[4]
↳ ('Estimates, 1950 - 2020: Annually interpolated demographic indicators - Births (thousands)',
↳  'Medium fertility variant, 2020 - 2099: Annually interpolated demographic indicators - Births (thousands)')

» dados.rename(columns={'Year':'ano',
»                       dados.columns[3]:'nasc',
»                       dados.columns[4]:'inter'}, inplace=True)
» # copiamos os dados da coluna de interpolação, após 2020, para a colunas de nascimentos
» dados.loc[dados['nasc'].isna(), 'nasc'] = dados['inter']

» # vamos mantes apenas as colunas 'ano' e 'nasc'
» dados = dados[['ano', 'nasc']]
» dados.head()
↳         ano          nasc
↳ 4050    1950    2439820.0
↳ 4051    1951    2467186.0
↳ 4052    1952    2523577.0
↳ 4053    1953    2583285.0
↳ 4054    1954    2646311.0

Para usar como anotações no gráfico encontramos os anos em que  nascimentos foram máximo e mínimo, além do ano em que se inicia a interpolação, 2020.

» maior=dados[dados['nasc']==dados['nasc'].max()]
» menor=dados[dados['nasc']==dados['nasc'].min()]

» ano_maior = maior['ano'].values[0]
» nasc_maior = int(maior['nasc'].values[0])

» ano_menor = menor['ano'].values[0]
» nasc_menor = int(menor['nasc'].values[0])

» interX = 2020   # início da interpolação
» interY = int(dados[dados['ano']==2020]['nasc'].values[0])

» txt = ('Máximo de nascimentos:\t {} no ano {}.\n'
»         'Mínimo de nascimentos:\t {} no ano {}.\n'
»         'Início da interpolação:\t {} no ano {}.'
»       )

» print(txt.format(nasc_maior, ano_maior,nasc_menor, ano_menor, interY, interX))
↳ Máximo de nascimentos:	 3929646 no ano 1983.
↳ Mínimo de nascimentos:	 1504597 no ano 2099.
↳ Início da interpolação:	 2859135 no ano 2020.

Com esses dados imprimimos o gráfico (sem muita preocupação estética). Uma primeira curva é traçada em preto, incluindo os anos de 1950 até 2020. A segunda curve se inicia em 2021 até o final e é tracejada em vermelho, para indicar a interpolação. Uma terceira curva tem efeito decorativo, em azul e transparente.

» fig = plt.figure()
» ax = fig.add_subplot()
» ax.set_title('Nascimentos (em milhões), por ano', size=18)
» ax.plot(dados[dados['ano']<2021]['ano'], dados[dados['ano']<2021]['nasc'], color='black', alpha=1)
» ax.plot(dados[dados['ano']>2020]['ano'], dados[dados['ano']>2020]['nasc'], 'r--')
» ax.plot(dados['ano'], dados['nasc'], 'b', linewidth=5, alpha=.2)
» ax.grid(color='grey', alpha=.3 )
» ax.annotate('Máximo', xy=(ano_maior, nasc_maior), size=13)
» ax.annotate('Mínimo', xy=(ano_menor, nasc_menor), size=13)
» ax.annotate('Interpolado', xy=(interX, interY), size=13)
» ax.arrow(ano_maior-10, nasc_maior, 10, 0)
» ax.arrow(interX-10, interY-10, 10, 10)            # a figura 17 abaixo é plotada


Para dar um zoom na figura podemos limitar as faixas de valores no eixo x e eixo y com ax.set_xlim(a,b) e ax.set_xlim(m,n), respectivamente, em torno do ponto de interesse. No exemplo fazemos um zoom em torno do ponto de máximo, obtendo o gráfico 18 acima.

» ax.set_xlim([1980, 1990])
» ax.set_ylim([3.76e6, 3.95e6])
» fig.get_figure()                                  # a figura 18 acima é plotada

Figuras sobre o plot

Diversas formas mais comuns estão disponíveis para inserção nos plots, e são chamadas de patches no matplotlib. Algumas delas estão diretamente em matplotlib.pyplot como retângulos, círculos e polígonos. Muitas outras estão em matplotlib.patches. Para traçar figuras construimos os patches com os métodos apropriados e os acrescentamos ao subplot usando ax.add_patch().

retângulo: plt.Rectangle((x, y), largura, altura), onde (x, y) são as coordenadas do ponto inferior esquerdo,
círculo: plt.Circle((x_0, y_0), raio), onde (x_0, y_0) são as coordenadas do centro,
polígono: plt.Polygon([[x_0, y_0], [x_1>, y_1],…, [x_n, y_n]).

No caso do polígono a área interna às retas que ligam os pontos é colorida.

» fig = plt.figure()
» ax = fig.add_subplot()
» retangulo = plt.Rectangle((0.2, 0.2), 0.6, 0.4, color='#aabbcc')
» circulo = plt.Circle((0.4, 0.6), 0.3, color='plum', alpha=0.3)
» poligono1 = plt.Polygon([[0.1, 0.1], [0.8, 0.7], [.3,.7], [0.6, 0.1]], color='turquoise', alpha=0.8)
» poligono2 = plt.Polygon([[0.2, 0.2], [0.8, 0.8]], color='red', alpha=0.8)
» ax.add_patch(retangulo)
» ax.add_patch(circulo)
» ax.add_patch(poligono1)
» ax.add_patch(poligono2)

Para o segundo “polígono” apenas dois pontos foram fornecidos e ele é representado pela reta (vermelha) que liga esses pontos.

Claro que gráficos mais elaborados podem ser montados com uma combinação de figuras como retas, polígonos, círculos, etc. No caso abaixo uma cor é escolhida “aleatoriamente” para plotar um série de 2 triângulos, um com um vértice em (0,1), outro em (1,0). Os dois outros vértices dos triângulos são coincidentes, e se deslocam sobre a reta (t,t) com t de 0 a 1, com espaçamento .1. A cor tem transparência alpha=.6 para que as cores apareçam em tons pastéis.

A função cor() retorna uma tupla (r,b,g) onde cada componente representa as cores vermelho, verde e azul, com valores de 0 até 1.

» def cor():
»     return (np.random.random(), np.random.random(), np.random.random())

» fig = plt.figure()
» ax = fig.add_subplot()
» for t in np.linspace(0,1,100):
»     c = cor()
»     poligono1 = plt.Polygon([[0, 1], [t, t], [t+.1, t+.1]], color=c, alpha=.6)
»     poligono2 = plt.Polygon([[1, 0], [t, t], [t+.1, t+.1]], color=c, alpha=.6)
»     ax.add_patch(poligono1)
»     ax.add_patch(poligono2)                       # a figura 20 é gerada


O código abaixo gera círculos de raios aleatórios, espalhados em torno da reta (t,t), afastados dela por uma variacão também aleatória.

» fig = plt.figure()
» ax = fig.add_subplot()
» plt.axis('equal')
» for t in np.linspace(0,1,100):
»     circulo = plt.Circle((t*np.random.random(), t*np.random.random()), np.random.random()/10, color=cor(), alpha=0.5)
»     ax.add_patch(circulo)
» ax.set_xlim([0, 1])
» ax.set_ylim([0, 1])
» plt.savefig('circulos.pdf')                       # a figura 21 é gerada

Ao final a figura gerada é gravada em disco com o formato “pdf”. Outros formatos podem ser escolhidos, como “jpeg”, “png”, “svg”, assim como a resolução em dots per inches, (dpi ), que tem default = 100, além da cor de fundo e bordas.

Configuração do matplotlib

Por padrão o matplotlib possui um esquema de cores e outros parâmetros, como largura e tipo de linhas, previamente definidos e voltados para plotar figuras prontas para publicação. No entanto, vários destes parâmetros podem ser personalizados através de ajustes nos valores globais tais como tamanho, espaçamento entre subplots, cores, família e tamanhos de fonte, estilos de grade, etc.

Uma forma de alterar esses padrões está no método plt.rc (parametro, opcoes) onde parametro é uma string com o nome do parâmetro que se quer modificar, e opcoes é uma sequência de argumentos de palavras-chaves com os novos valores.

Entre outras opções parametro pode ser figure, axis, xtick, ytick, grid, legend. As opções podem ser passadas de várias formas. O exemplo mostra como ajustar todas as figuras de uma sessão para o tamanho 20×15. Depois fazemos ajustes às fontes, usando um dicionário.

» # ajustar tamanho da figura
» plt.rc ('figure', figsize = (20, 15))

» # parâmetros associados às fontes, em um dicionário
» font_options = {'family' : 'monospace', 'weight' : 'bold', 'size' : 'small'}
» plt.rc ('font', **font_options)

Uma personalização mais ampla pode ser feita no arquivo de configurações. Para encontrar esse arquivo use os comandos:

» # no prompt do sistema digite
» python -c "import matplotlib; print(matplotlib.matplotlib_fname())"

» # de dentro do jupyter notebook (ou de qualquer ambiente em que você trabalhe):
» import matplotlib
» print(matplotlib.matplotlib_fname())
↳ /home/usuario/.anaconda3/lib/python3.8/site-packages/matplotlib/mpl-data/matplotlibrc

Esse output é relativo ao sistema e à distribuição que está sendo usada, lembrando que é possível existir mais de uma instalação em um computador. No caso mostrado está em uso o anaconda e jupyter no linux mint. Cada usuário pode encontrar um local diferente. Esse arquivo não deve ser editado diretamente mas copiado para a pasta home do usuário (no linux) com o nome .matplotlibrc. Desta forma ele será carregado durante a inicialização do pacote. A análise desse arquivo é uma boa forma de se conhecer as possibilidades na personalização, sendo que as opções estão comentadas.

As atuais configurações globais podem ser vistas com o comando

» import matplotlib as mpl
» print(mpl.rcParams)

matplotlib.rcParams é usado para alterar esses parâmetros, um de cada vez. matplotlib.rc pode alterar os valores default para vários parâmetros de um grupo específico, como tipos de lihnes, fontes, textos, etc.

» matplotlib.rcParams['lines.markersize'] = 20
» matplotlib.rcParams['font.size'] = '15.0'

matplotlib.rcdefaults() reseta todos os parâmetros para seus valores originais.

Usando matplotlib com o pandas

A própria biblioteca do pandas embute diversas funcionalidades do matplotlib, sem que esse tenha que ser carregado explicitamente. Isso significa que podemos criar gráficos sem passar por todas as etapas de sua construção.

Por exemplo, uma instância de Series possui o método series.plot().

» s1 = pd.Series(np.random.randn(100).cumsum())
» s1.plot(use_index=False)                          # grafico 22-a é plotado

» s2 = pd.Series([x**2 for x in np.arange(-10,10,.1)], index=np.arange(-10,10,.1))
» s2.plot()                                         # grafico 22-b é plotado


Na figura 21 o eixo x recebe valores dos índices da série, que por default vai de 0 até 99. Na segunda o índice que foi declarado é usado. Para evitar o procedimento de usar o índice como ordenada passamos o parâmetro series.plot(use_index=False).

Para um dataframe cada series correspondente a cada coluna é plotada separadamente. Abaixo construimos e plotamos um dataframe de quatro colunas, cada uma delas representando valores de um seno com frequências diferentes.

» s1 = pd.Series([np.sin(x) for x in np.arange(0,10,.1)])
» s2 = pd.Series([np.sin(2*x) for x in np.arange(0,10,.1)])
» s3 = pd.Series([np.sin(3*x) for x in np.arange(0,10,.1)])
» s4 = pd.Series([np.sin(4*x) for x in np.arange(0,10,.1)])

» df=pd.concat({'A': s1, 'B': s2, 'C': s3, 'D': s4} , axis=1)

» df.plot()                                                        # a figura 23 é plotada
» df.plot(color=['k','r','b','y'], alpha=.6, logx=True, grid=True) # a figura 24 é plotada


O dataframe df contém 4 colunas, cada uma com os valores de seno(πx), seno(2πx), seno(3πx), seno(4πx), com x variando de 0 a 10 em passos de 0,1. No segundo gráfico, figura 24, alguns parâmetros foram passados, como uma lista de cores, a existência de quadriculado (grid) e a instrução para usar uma escala logarítmica em x.

A instrução dataframe.plot() é um atalho para dataframe.plot.line() que representa como curvas os pontos passados. Outros parâmetros podem ser passados, exatamente como no uso direto de matplotlib:

Argumento Descrição
label texto para a legenda
ax objeto subplot do matplotlib onde plotar. Se vazio os plots vão para o subplot ativo
style string de estilo, como ‘ko–‘, passado para o matplotlib
alpha opacidade do plot (de 0 to 1)
kind opções: ‘area’, ‘bar’, ‘barh’, ‘density’, ‘hist’, ‘kde’, ‘line’, ‘pie’
logy use escala logaritmica no eixo y
use_index use o index para os labels de x
rot rotação de texto nos labels (0 até 360)
xticks valores a usar para marcas no eixo x
yticks valores a usar para marcas no eixo y
xlim limites para o eixo x (ex.: [0, 10])
ylim limites para o eixo y
grid exibir grade quadriculada de fundo (grid), default=exibir

Alguns parâmetros são específicos para dataframes.

Argumento Descrição
subplots bool. Plota cada coluna em um subplot separado
sharex se subplots=True, use o mesmo eixo x, com marcas e limites
sharey se subplots=True, use o mesmo eixo y, com marcas e limites
figsize tupla indicando tamanho da figura
title texto para o título
legend bool. Inclui legenda do subplot (default=True)
sort_columns plot colunas em ordem alfabética no nome; default= ordem no dataframe

Plotagem em barras

A plotagem em barras (bar plots ) pode ser feita com plot.bar() e plot.barh() (com barras verticais e horizontais).

Para experimentar com esses tipos de plotagens vamos usar os mesmos dados importados anteriormente, que contém uma lista de países com o número de nascimentos por ano de 1950 até 2020, e a estimativa à partir de 2021. Importamos o arquivo .csv para um dataframe e selecionamos apenas as linhas relativas ao ano de 2020. Linhas relativas à continentes e outras partes do mundo que não países possuem coluna Code = NaN e são excluídas. Renomeamos as colunas para mais fácil manuseio e mantemos apenas as colunas relativas ao país, ano e número de nascimentos.

» # importação do csv em um dataframe
» dados = pd.read_csv('./dados/number-of-births-per-year.csv')
» # selecão do ano = 2020 e apenas países
» dados = dados[(dados['Code'].notnull()) & (dados['Year']==2020)]
» # renomeando colunas
» dados = dados.rename(columns={'Entity':'país','Year':'ano', dados.columns[3]:'nasc'})
» # mantendo apenas colunas relevantes
» dados = dados[['país', 'nasc']]

» # use a coluna 'país' como índice
» dados.set_index('país', inplace=True)

» # o dataframe final:
» dados.head(4)
↳                      nasc
↳        país
↳ Afghanistan     1215628.0
↳     Albania       32888.0
↳     Algeria      995368.0
↳      Angola     1311356.0

Imprimimos os 2 tipos de barplot com o código abaixo, usando 10 países para as barras verticais e 20 para as horizontais. Para isso inicializamos uma figura com 1 linha e 2 colunas. O parâmetro figsize=(15, 16) indica que nossa figura terá a largura 15 e altura 6 (em polegadas). Dados os nomes longos de países o gráfico ficou sobreposto, o que seria controlado aumentando-se a separação entre axes.

» fig, axes = plt.subplots(2, 1, figsize=(10, 10))
» dados[:10].plot.bar(ax=axes[0], color=['r','b','g'], rot=30, grid=True)
» dados[:20].plot.barh(ax=axes[1], color='g', alpha=0.7, grid=True)


Gráficos desse tipo são desenhados para series e dataframes com apenas uma coluna. Se o dataframe possui várias colunas o gráfico de barras plota uma barra para cada coluna.

Para o próximo exemplo usaremos os dados disponibilizados no GapMinder, já usados e descritos nesse site. Desses dados manteremos apenas as colunas que renomearemos como “pais”, “ano” e “pop” (população), ficando com um dataframe com os países de mundo e suas populações nos anos listados abaixo.

Dessa coleta de dado separamos apenas os 5 países com maior população no último ano, 2007.

» # baixar dados do gapminder
» url =(
»       'https://raw.githubusercontent.com/jennybc/'
»       'gapminder/master/data-raw/08_gap-every-five-years.tsv'
»        )

» # criamos o dataframe dfPaises. O arquivo importado tem campos separados por tabs
» dfPaises = pd.read_csv(url, sep='\t')

» # para ver colunas e forma geral usamos
» dfPaises.head(2)
↳         country   continent    year    lifeExp        pop     gdpPercap
↳ 0   Afghanistan        Asia    1952     28.801    8425333    779.445314
↳ 1   Afghanistan        Asia    1957     30.332    9240934    820.853030

» # usamos apenas 3 colunas
» dfPaises = dfPaises[['country','year','pop']]
» # e as renomeamos
» dfPaises.rename(columns={'country':'pais', 'year':'ano'}, inplace=True)

» # o resultado é
» dfPaises
↳             pais     ano        pop
↳ 0    Afghanistan    1952    8425333
↳ 1    Afghanistan    1957    9240934
↳ 2    Afghanistan    1962   10267083

» # para ver os anos registrados examinamos o conjunto (set)
» set(dfPaises['ano'])
↳ {1952, 1957, 1962, 1967, 1972, 1977, 1982, 1987, 1992, 1997, 2002, 2007}

» # países mais populosos, em 2007
» dfPaises[dfPaises['ano']==2007] \
»          .sort_values(by=['pop'], axis=0, \
»           ascending=False, inplace=False)['pais'] \
»          .head(5)
↳ 299             China
↳ 707             India
↳ 1619    United States
↳ 719         Indonesia
↳ 179            Brazil

O último comando, para selecionar os países mais populosos, está quebrada pelo caracter \ (back slash) que é ignorado (a linha é executada por inteiro). Essa linha pode ser compreendida assim:

dfPaises[dfPaises['ano']==2007]                         : seleção só de linhas com ano = 2007
.sort_values(by=['pop'], axis=0, ascending=False)       : ordena pela coluna 'pop' em ordem inversa
['pais'].head(5)                                        : só a coluna 'pais', 5 primeiros valores

Em seguida montamos um dataframe para cada desses países e os concatenamos para um dataframe mais geral que contém linhas indexadas pela ano e colunas com o nome do país. (Outras técnicas de agrupamento serão vistas mais tarde.)

» china = dfPaises[dfPaises['pais']=='China'][['ano', 'pop']]\
»         .set_index('ano').rename(columns={'pop':'china'})
» india = dfPaises[dfPaises['pais']=='India'][['ano', 'pop']]\
»         .set_index('ano').rename(columns={'pop':'india'})
» usa = dfPaises[dfPaises['pais']=='United States'][['ano', 'pop']]\
»       .set_index('ano').rename(columns={'pop':'usa'})
» indonesia = dfPaises[dfPaises['pais']=='Indonesia'][['ano', 'pop']]\
»             .set_index('ano').rename(columns={'pop':'indonesia'})
» brasil = dfPaises[dfPaises['pais']=='Brazil'][['ano', 'pop']]\
»          .set_index('ano').rename(columns={'pop':'brasil'})

As linhas de seleção de dados do país podem ser compreendidas assim:

  dfPaises[dfPaises['pais']=='Brazil']      : seleciona apenas linhas relativas ao país 'Brazil'
  [['ano', 'pop']]                          : desse df copia apenas as colunas 'ano' e 'pop'
  .set_index('ano')                         : use a coluna 'ano' como índice
  .rename(columns={'pop':'brasil'})         : renomeie coluna 'pop' para 'brasil'
» # os paises são concatenados em um único df
» df = pd.concat([china, india, usa, indonesia, brasil],  axis=1)
» # o nome da lista de colunas será usado no plot
» df.columns.name = 'População'

» # e o resultado é
» df.head(3)
↳ População     china        india          usa   indonesia      brasil
↳ ano
↳ 1952      556263527    372000000    157553000    82052000    56602560
↳ 1957      637408000    409000000    171984000    90124000    65551171
↳ 1962      665770000    454000000    186538000    99028000    76039390

» # esse dataframe pode ser exibido em gráfico de barras
» cor = ['salmon','gold','teal','plum','powderblue']
» df.plot.bar(figsize=(10,5), grid=True, color = cor, title='Países mais populosos (de 1952 até 2007)', rot=45)

Na plotagem acima uma paleta de cores foi passada para o parâmetro color. Cada uma delas é usada para um país. O nome das colunas, df.columns.name = 'População' é usado como título da legenda.

Para gerar gráficos de barras empilhadas (stacked bar ) passamos o valor stacked=True. Nos exemplos plotamos a versão horizontal e vertical do mesmo gráfico acima.

» df.plot.barh(stacked=True, alpha=.7)         # a figura 27 é plotada

» df.plot.bar(stacked=True, alpha=.7)          # a figura 28 é plotada

Histogramas

Um histograma é uma representação gráfica, similar a um gráfico de barras, de uma distribuição de pontos. Os pontos são distribuídos em faixas igualmente divididas e o gráfico é o conjunto de retângulos com base de tamanho igual à largura das faixas e altura correspondente ao número de pontos em cada faixa.

Criamos uma série com 1000 números aleatórios, multiplicados por 100. O resultado é, no caso dessa execução, um conjunto distribuído entre -315 e 325 (aproximadamente). A partir desses dados traçamos o histograma e o gráfico de densidade ou density plot.

» ser = pd.Series((np.random.randn(1000)*100))
» ser.describe()
↳ count    1000.000000
↳ mean        3.747373
↳ std       102.637489
↳ min      -314.443835
↳ 25%       -65.343082
↳ 50%         4.835384
↳ 75%        73.314604
↳ max       324.011000

» ser.plot.hist(bins=10, grid=True, color='b', alpha=.4) # figura 19

» ser.plot.kde() # o mesmo que ser.plot.density()        # figura 30


O gráfico de densidade consiste na plotagem de uma função de distribuição de probabilidade que poderia ter gerado os dados na series. A técnica usual consiste em usar uma mistura de “núcleos” ou “kernels”. Esses gráficos são também chamados de estimativa de núcleos de densidade (kernel density estimate, KDE ).

Seaborn

Seaborn é outra biblioteca do Python voltada para a visualização de dados, baseada no matplotlib. Ela apresenta uma interface de mais alto nível e aprimoramento da qualidade estética dos gráficos. Com o Seaborn se pode conseguir gráficos bem elaborados e de boa aparência com um número menor de linhas de código.

Para os exemplos com o seaborn vamos usar os dados do Gapminder já descritos. O dataframe importado tem 1704 linhas com dados sobre os países, 6 colunas ‘country’, ‘continent’, ‘year’, ‘lifeExp’, ‘pop’, ‘gdpPercap’, respectivamente ‘pais’, ‘continente’, ‘ano’, ‘Expectativa de vida’, ‘população’, ‘PIB percapita’.

» url =(
»       'https://raw.githubusercontent.com/jennybc/'
»       'gapminder/master/data-raw/08_gap-every-five-years.tsv'
»        )
» # criamos o dataframe dfPaises. O arquivo importado tem campos separados por tabs
» dfPaises = pd.read_csv(url, sep='\t')

» # para restringir o volume dos dados armazenamos as fatias
» df2007 = dfPaises[dfPaises['year']==2007]
» dfBrasil = dfPaises[dfPaises['country']=='Brazil']

O seaborn deve ser importado. Para uma gráfico de barras mais simples informamos e base de dados e os nomes de colunas a usadas como valores para os eixos. Para não congestionar o gráfico usamos apenas os 10 primeiros países.

» import seaborn as sns
» sns.barplot(data=df2007[:10], x='lifeExp', y='gdpPercap')
» # é plotada a figura 31 abaixo

Muitas configuarações podem ser aplicadas sobre esse gráfico básico. Algumas são usadas abaixo, como a orientação das barras, textos e rotação nos eixos x e y.

» sns.set_style('darkgrid')
» graf = sns.barplot(data=df2007[:10], x='lifeExp', y='gdpPercap', orient='h')
» graf.set(xlabel = 'Expectativa de vida (anos)',
                     ylabel = 'PIB percapita', title ='Expectativa de vida x PIB')
» graf.set_xticklabels(labels=df2007[:10]['lifeExp'].round(1))
» graf.set_yticklabels(labels=df2007[:10]['country'], rotation=30)
» # é plotada a figura 32 acima

Valores válidos para sns.set_style() são 'white', 'dark', 'whitegrid', 'darkgrid', 'ticks'.

Esses gráficos plotados não sugerem qualquer relação entre a renda percapita e a expectativa de vida, o que é natural uma vez que escolhemos apenas os primeiros 10 países, em ordem alfabética. Claro que barplots não são apropriados para exibir um número muito grande de dados. Para isso podemos usar seaborn.regplot que plota o gráficos de dispersão (scatter plots ) e uma reta correspondente a um ajuste do modelo de regressão linear. Esse último gráfico mostra que existe correlação entre expectativa de vida e renda percapita.

» sns.regplot(x='lifeExp', y='gdpPercap', data=df2007) # figura 33


É comum em análise de dados que se queira ter uma visão geral de relacionamentos entre as variáveis (ou colunas de um dataframe. Para isso um pairplot faz o cruzamento entre todas as variáveis. O método seaborn.pairplot(), por default, cria uma matriz de Axes comparando aos pares as variáveis numéricas do dataframe usado como fonte de dados. Na diagonal dessa matriz uma distribuição univariada é exibida para mostrar a distribuição dos dados em cada coluna.

» sns.pairplot(df2007[['gdpPercap', 'lifeExp']],
»              diag_kind='kde', plot_kws={'color':'r','alpha': .9})


O parâmetro plot_kws recebe um dicionário de propriedades com valores.

Outro método é seaborn.catplot() que traz diversas funcionalidades para representar relações entre variáveis numéricas ou categóricas. Para uma amostra criamos um dataframe com valores de uma parábola e um seno.

» dfGraf=pd.DataFrame(np.arange(20), columns = ['x'])
» dfGraf['quadrado']=dfGraf['x']**2
» dfGraf['seno']=np.sin(dfGraf['x'])

» sns.catplot(x='x', y='quadrado', kind='strip', data=dfGraf)      # plota a figura 35
» sns.catplot(x='x', y='seno', kind='bar', data=dfGraf)            # plota a figura 36

Bibliografia

Todos os sites acessados em setembro de 2021.

Consulte bibliografia completa em Pandas, Introdução neste site.