Ninguém tem obrigação de aceitar seu código

  • publicado em 06 de julho de 2023

Tem uma coisa que há algum tempo tem me incomodado um pouco. A ideia de que «qualquer coisa» serve como contribuição de software livre e a consequência disso que é uma galera achando que qualquer porcaria de pull request tem que ser aceito.

Programar dá trabalho

Como ouvi esses dias: «é triste, mas o óbvio tem que ser dito» e o óbvio aqui é que programar dá trabalho, mexer num projeto que você não conhece mais ainda. E o trabalho é dobrado porque além do trabalho de escrever o código alguém tem que verificar o que foi feito. Então o pr não é algo que chega de graça é trabalho dos dois lados.

E mais, pra chegar um pull request num repositório significa que o projeto já existe e tem utilidade pros outros. O trabalho já posto no projeto é muito maior que o trabalho posto no pr. Quando você manda um melhoria pra um projeto que já existe significa que alguém já pôs muito trabalho naquilo escrevendo, documentando, polindo. O seu pr é só uma gota no oceano. Sempre que alguém reclama que perdeu horas e teve o patch rejeitado eu lembro do Eric Idle dizendo: Oh, it took me hours.

O software é livre, o repo é meu

Quando você usa um programa ele tem uma licença, as de software livre em termos gerais dizem que você pode usar/alterar/distribuir da maneira que quiser não tem nada aí sobre o autor do software ser obrigado a usar código dos outros e um repositório não faz muita diferença, independente de onde o código estiver hospedado ainda vai dar pra usar. E tendo o seu fork do código ainda se pode colocar qualquer coisa, por mais super específica que for, que se fosse em um repositório compartilhado não caberia porque não interessa a mais ninguém, pode-se fazer experimentos sem quebrar nada, pode alterar tudo o que quiser. O direito de fork é a melhor coisa que tem.

Isso gera uma fragmentação da base de código, que geralmente é vista como ruim, mas no final das contas acaba tendo um bom resultado porque gera mais software útil, pra mais casos e quando as modificações do fork são importantes é capaz que o projeto original aproveite. Um repositório não deveria fazer diferença a não ser que a preocupação seja o nome.

Não me importo com seu marketing pessoal

E aqui é o que eu acho que mais estava me incomodando nessa coisa toda. Há um tempo eu já estava pensando em escrever algo sobre isso, mas esses dias vi um vídeo de um desses youtubers programadores que foi feio demais. Basicamente o cara teve um pr rejeitado e fez um vídeo choroso depois. Mas aí vamos ver qual foi a “contribuição” e a coisa pega. O que o cara fez foi tirar um comentário e alterar a variável que se usava em uma condição, uma mudança que por si só não melhora em nada o programa. O responsável pelo repo falou que uma mudança assim só seria aceita se viesse acompanhada de outras pra manter o padrão do projeto e fechou o pr. Aí veio o choro.

O cara aparentemente é alguém que já é profissional há anos, ele certamente sabe fazer mais que isso e sabe que o que fez não vai melhorar em nada o código. A única razão de fazer isso foi se promover, pra poder dizer «contribuo com open source» e pra gravar um vídeo pro youtube. Gravar o vídeo pro youtube rolou…

Eu não ligo que alguém queira se promover em cima do código que a pessoa fez, mas se quiser se promover faça algo decente, não vem com essa de alterar alguma besteira só pra depois ter seu nome nos colaboradores. Isso não ajuda o projeto em nada, só toma mais tempo dos envolvidos e isso pra um cara qualquer fazer sua propaganda.

O óbvio tem que ser dito e “comunidade” de software é feita principalmente em cima de software, a contribuição mais necessária é software. Quer fazer propaganda de si? Faz, mas faz direito.


Sobre a SSPL

  • publicado em 22 de maio de 2023

Bom, eu sei que tô atrasado, mas eu sou lerdo mesmo, então vai.

A SSPL (Server Side Public Licence) é a licença criada pelo pessoal do mongodb para ser usada no lugar da AGPLv3 que era a licença do mongodb. A SSPL é basicamente a AGPLv3 com a cláusula 13 modificada. Esta cláusula diz respeito a como a licença se relaciona com a execução remota do programa licenciado.

A AGPL diz em sua cláusula 13 que um programa modificado, mesmo que seja acessado via rede, tem que ter o código fonte disponível e que um programa licenciado pela agpl pode ser combinado tranquilamente com um programa licenciado sob gpl. A SSPL por outro lado, além de exigir que um programa modificado tenha o código disponível assim como a agpl, também diz que se for oferecido um serviço de hospedagem do programa, todo o código de suporte ao serviço tenha que ser licenciado sob sspl de maneira que um usuário possa hospedar seu próprio serviço.

Por que mais uma licença?

A SSPL existe para lidar com as grandes de hospedagem (aws, microsoft e google) que oferecem serviços em cima do mongo e estão comendo uma fatiga gigante do mercado da mongo inc. tanto que a amazon anunciou um fork do mongodb assim que ele passou a ser licenciado com a sspl. A ideia da alteração feita na agpl é de forçar os cloud providers a abrirem sua infra, ou mais realisticamente, pagarem a licença pra mongo inc. E aqui nem vou entrar no mérito da briga de duas empresas multi-milionárias, a questão é mais a licença do software mesmo.

A licença foi submetida para analise da OSI e acabou não sendo aprovada, com basicamente o principal argumento que a licença é restritiva do uso do software assim violando o princípio de que o software deve poder ser usado da maneira que se quiser.

Restritiva como?

Um dos pontos do software livre é a não discriminação por uso do software. O exemplo clássico é o do aborto: um software livre não pode restringir o uso nem a uma clinica de aborto e nem a uma igreja anti-aborto e um dos argumentos era de que a licença restringia o uso baseado no tipo de uso, restringindo o uso baseado em ser um serviço ou não. E é aqui que eu acho estranho o argumento da osi.

Toda licença com copyleft tem alguma restrição na criação de trabalhos derivados, em geral exigindo que trabalhos derivados sejam lançados sobre a mesma licença e sendo assim «restringe» a liberdade de software proprietário, mas é uma restrição que faz bem ao ecossistema de software livre. No meu modo de ver a sspl para lidar com uma situação que não era contemplada com as licenças anteriores, isto é, um mundo com cloud providers gigantescos, como isto afeta a distribuição (ou falta de) dos programas. Me pareceu que o argumento da osi é o mesmo argumento que se usa contra licenças de copyleft.

A coisa do cachimbo e da boca torta

Como comentei no outro post, tem um tempo que as licenças com copyleft caíram em desuso e estão sempre “sob ataque”, e o open source é um dos responsáveis por isso. Quando do lançamento da GPLv3, que veio pra “tapar buracos” da GPLv2, um pessoal torceu o nariz. O pessoal não tinha como dizer que uma licença da fsf não é livre, mas fizeram questão de promover licenças mais amigávies aos negócios. E depois de tanto ser amigáveis aos negócios agora a osi passou a tomar lado na briga entre empresas.

No meu modo de ver, a sspl apesar de ser uma arma de negócios, é uma licença que adere aos princípios do software livre e expande isso, fazendo com que cloud providers também tenham que participar do jogo. Cinco anos já se passaram, o código do mongo continua aí disponível, pode-se usar o mongo da maneira que quiser, e se quiser montar um serviço de hospegagem de mongo também pode, só precisa abrir o seu código usado na infra. Acho justo e no espírito das licenças com copyleft.


Gera uma vez, gera outra!

  • publicado em 11 de maio de 2023

Bom, seguindo com a minha ideia de escrever mais no blog nem que seja inútil, hoje vou escrever sobre geradores em go.

Geradores são funções que a cada interação retornam o próximo item de uma sequência. Eles dão a possibilidade de se trabalhar um item da sequencia de cada vez ao invés de ter que esperar pela sequencia completa para poder iterar sobre os itens. Uma maneira de se fazer isso em go é usando channels e goroutines e é isso que vamos fazer.

Uma primeira implementação

A ideia é criarmos uma função que retorna um channel que será alimentado por uma goroutine e assim o consumidor da função poderá iterar sobre o channel.

Um exemplo clássico (e inútil) sempre usado é a sequencia de fibonacci, então vamos fazer aqui também.

package main

func Fibs() chan int {
    // Este canal será o consumido por quem usar esta função.
    ch := make(chan int)
    go func() {
        // O Canal precisa ser fechado na função interna e não
        // na externa porque senão o usuário não teria nada para
        // consumir
        defer close(ch)
        i := 0
        n := 1
        for {
            ch <- n
            i, n = n, n+i
        }
    }()
    return ch
}

func main() {
    for n := range Fibs() {
        println(n)
    }
}

Rodando este código vemos dois problemas. Um que o código não para nunca até um C-c e quando acabam os números a sequencia para de crescer e começam a aparecer uns números negativos.

Lidando com erros

A primeira ideia para resolver isso é verifiar por erro e encerrar a goroutine interior.

package main

func Fibs() chan int {
    // Este canal será o consumido por quem usar esta função.
    ch := make(chan int)
    go func() {
        // O Canal precisa ser fechado na função interna e não
        // na externa porque senão o usuário não teria nada para
        // consumir
        defer close(ch)
        i := 0
        n := 1
        for {
            ch <- n
            i, n = n, n+i
            // Aqui se o número for negativo a gente termina,
            // o canal será fechado e o consumidor vai sair do loop.
            if n < 0 {
                break
            }
        }
    }()
    return ch
}

func main() {
    for n := range Fibs() {
        println(n)
    }
}

Com isso o nosso programa já não fica rodando pra sempre e nem aparecem números estranhos, mas o consumidor também não sabe o que aconteceu, a sequencia simplesmente acabou. Se quisermos informar o consumidor sobre algum erro ocorrido precisamos de um canal que carrega uma struct com o valor e um erro.

package main

import "errors"

type GenItem struct {
    Err error
    Val int
}

func Fibs() chan GenItem {
    // Este canal será o consumido por quem usar esta função.
    // Agora o canal é um canal de GenItem para conter também
    // informação sobre o erro
    ch := make(chan GenItem)
    go func() {
        // O Canal precisa ser fechado na função interna e não
        // na externa porque senão o usuário não teria nada para
        // consumir
        defer close(ch)
        i := 0
        n := 1
        for {
            item := GenItem{
                Err: nil,
                Val: n,
            }
            ch <- item
            i, n = n, n+i
            // Aqui se o número for negativo a gente termina
            if n < 0 {
                item := GenItem{
                    // A informação sobre o que de errado aconteceu
                    Err: errors.New("Cabou os número!"),
                    Val: 0,
                }
                ch <- item
                break
            }
        }
    }()
    return ch
}

func main() {
    for item := range Fibs() {
        if item.Err != nil {
            panic(item.Err.Error())
        }
        println(item.Val)
    }
}

Assim o consumidor tem toda a segurança pra entrar em pânico tranquilamente. :)

Botando uma galera pra trampar

Até agora vimos somente uma goroutine alimentando o canal. Vamos fazer um pouco diferente dessa vez, vamos alimentar o canal com várias goroutines.

Imagine que temos uma lista de url e precisamos baixar o conteúdo de todas. Podemos usar mais de uma goroutine para baixar o conteúdo concorrentemente e ir alimentando o canal.

package main

import (
    "io/ioutil"
    "net/http"
    "sync"
)

var URLS = []string{
    "https://tupi.poraodojuca.dev/index.html",
    "https://toxicbuild.poraodojuca.dev/index.html",
    "https://mongomotor.poraodojuca.dev/index.html",
}

// Alteramos GenItem para conter as iformações do download
type GenItem struct {
    Err     error
    Content []byte
    Url     string
}

func DownloadUrls() chan GenItem {
    // Novamente o canal consumidor
    ch := make(chan GenItem)
    go func() {
        // Note que o canal é fechado por esta goroutine
        // e não pelas goroutines que fazem o downlaod as urls.
        defer close(ch)

        // Aqui usamos o WaitGroup para esperar até que todas as páginas
        // tenham sido baixadas.
        wg := new(sync.WaitGroup)
        for _, url := range URLS {
            // aqui pra cada url a gente dispara uma goroutine e seque a vida
            // quem vai alimentar o canal é essa goroutina que baixa
            // a página.

            // Adicionamos 1 para cada goroutine que baixa uma página
            wg.Add(1)
            go func(url string) {
                // Liberamos um do WaitGroup quando a função terminar
                defer wg.Done()
                content, err := DownloadUrl(url)
                item := GenItem{
                    Err:     err,
                    Content: content,
                    Url:     url,
                }
                ch <- item
            }(url)
        }
        // Aqui esperamos até que todas as páginas tenham sido baixadas
        wg.Wait()
    }()
    return ch
}

// Isso aqui não importa, só faz um download normal mesmo
func DownloadUrl(url string) ([]byte, error) {
    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        return nil, err
    }
    c := http.Client{}
    resp, err := c.Do(req)
    if err != nil {
        return nil, err
    }

    defer resp.Body.Close()
    body, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        return nil, err
    }
    return body, nil
}

func main() {
    for item := range DownloadUrls() {
        if item.Err != nil {
            println("Erro baixando página " + item.Url)
        } else {
            println("Página " + item.Url + " baixada com sucesso!")
        }

    }
}

Aqui tivemos bastantes coisas diferentes: Primeiro que as funções que alimentam os canais são funções internas a função que cria o canal e também usamos um WaitGroup para aguardar todas as goroutines produtoras terminarem. É preciso esperar terminar porque caso contrário o canal seria fechado imediatamente.

Um wait group é um contador, para cada goroutine que queremos esperar adicionaos 1 ao wait group e o que estamos esperando deve remover um do grupo com Done() quando terminar a execução. Wait() bloqueia a execução até que o contador do wait group seja zerado. Voltando pro começo

Até agora vimos como consumir um gerador até que os produtores terminem, mas e se o consumidor quiser terminar antes? Se a gente simplesmente sair do loop o canal vai ficar aberto eternamente, então o que a gente precisa fazer é antes de sair do loop fechar o canal e tratar no produtor a tentativa de escrever no canal depois de fechado. Nosso primeiro exemplo fica assim:

package main

import (
    "errors"
)

type GenItem struct {
    Err error
    Val int
}

func Fibs() chan GenItem {
    // Este canal será o consumido por quem usar esta função.
    // Agora o canal é um canal de GenItem para conter também
    // informação sobre o erro
    ch := make(chan GenItem)
    go func() {
        defer func() {
            // Quando o consumidor fechar o canal a gente vai tentar escrever
            // num canal fechado. Chamando recover() a gente recupera o controle
            // da execução.
            if r := recover(); r != nil {
                println("Recovering!")
            }
        }()
        defer close(ch)
        i := 0
        n := 1
        for {
            item := GenItem{
                Err: nil,
                Val: n,
            }
            ch <- item
            i, n = n, n+i
            // Aqui se o número for negativo a gente termina
            if n < 0 {
                item := GenItem{
                    // A informação sobre o que de errado aconteceu
                    Err: errors.New("Cabou os número!"),
                    Val: 0,
                }
                ch <- item
                break
            }
        }
    }()
    return ch
}

func main() {
    gen := Fibs()
    for item := range gen {
        println(item.Val)
        if item.Val > 1000 {
            // Aqui precisamos fechar o canal antes de sair do loop
            // os consumidores vão ter pânico quando tentarem escrever
            // no canal fechado
            close(gen)
            break
        }
    }
    println("Fim!")
}

E acho que é isso.


Que isso, Python?

  • publicado em 23 de abril de 2023

Essa é foda. O Python me mostrando traceback errado agora. Como pode?

Task exception was never retrieved
future: <Task finished name='Task-6939' coro=<BuildExecuter._run_build() done, defined at /home/pdjexception=KeyError('8ba5a94b-93bc-4a27-a4e4-05dc82c785cd')>
Traceback (most recent call last):
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/build.py, line 848, in _run_build
    await slave.build(build, **self.repository.envvars)
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/slave.py, line 367, in build
    build, envvars=envvars):
         ^^^^^^^^^^^^^^^^^^^
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/client.py, line 125, in build
    validate_cert=validate_cert)
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/slave.py, line 409, in _process_info
    async def _process_build_info(self, build, repo, build_info):
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/slave.py, line 575, in _process_step_output_info
    async def _get_step(self, build, step_uuid, wait=False):
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File /home/pdj/.virtualenvs/toxicbuild/lib/python3.11/site-packages/toxicbuild/master/slave.py, line 560, in _update_build_step_info
    return True
        ^^^^^^^^
KeyError: '8ba5a94b-93bc-4a27-a4e4-05dc82c785cd'

Na verdade esse aí foi o seguinte: Atualizei um código e antes de eu reuniciar o servidor deu um erro, então o python tá mostrando o erro em um fonte diferente. Mas foi engraçado. :P


Crônica de um bug anunciado

  • publicado em 02 de abril de 2023

Este texto eu tinha começado a escrever em 2015. Agora voltando o blog, pegando os backups achei e acho que tá bom de terminar.

O contexto da história

O software livre, pouco mais de uma década depois do seu “surgimento formal” teve seu racha que continua até hoje. Por um lado um grupo que de denominava de software livre e outro que se denominava como código aberto. Estes enfatizavam a superioridade técnica sobre o modelo de desenvolvimento fechado enquanto aqueles enfatizavam as liberdades advindas do compartilhamento de código, mas que apesar das diferenças acabavam convivendo bem. As coisas começaram a degringolar por volta de 2007, com as GPL v3 wars que culminaram com um diminuição do uso da GPL e com as licenças mais permissivas tendo um destaque maior (muitos projetos agora colocam uma licença permissiva como uma feature do software).

Assim chegamos a um ponto onde o pessoal do software livre se sente como tendo sido derrotado em certo sentido porque apesar do uso de software livre ter sido disseminado enormemente ao ponto de hoje em dia o software livre ser parte do mainstream do desenvolvimento de software nos dias de hoje, o que acabou se disseminando na verdade foi o open source, para ser mais preciso, a ideologia open source, já que o software é basicamente o mesmo.

Como começou?

A formação do movimento do software livre sempre se deu mais ou menos nas seguintes premissas: Um grupo (ou um indivíduo só) de hackers desenvolvia algum programa, e principalmente dos anos 90 em diante, colaborando via internet, fazendo com que o desenvolvimento de software livre pudesse crescer de maneira exponencial. O principal elo entre estes hackers era, obviamente, o software sendo produzido, mas além disso também uma ideologia compartilhamento de conhecimento, de bens imateriais no geral e uma certa apologia à liberdade, seja lá o que isso for. E ao mesmo tempo em que crescia o software livre, cresciam também as oportunidades de negócio e os contatos com as grandes empresas de software proprietário.

A questão de modelos de negócios já existia no movimento de software livre, com Richard Stallman falando sobre isso no manifesto gnu e pensando nos modelos de negócio propostos no manifesto gnu podemos começar a entender o porquê da primeria disputa entre capitalistas envolvendo o software livre: de um lado as nascentes empresas de internet e de outro as empresas de software proprietário. Entenda-se disputa aqui de uma maneira bem ampla, não sendo exatamente uma disputa, mas sim uma diferença de relacionamento com o software livre ditadas por questões econômicas. As empresas de internet desde o seu nascimento usaram de software livre vendo aí uma alternativa viável, melhor e mais barata aos softwares proprietários (linux + apache foi o primeiro «sucesso» do linux).

Do lado da comunidade de software livre, ao mesmo tempo, também começaram a se ressaltar as primeiras divergências, agora entre o pessoal mais “apolítico”, mais preocupado com software e o pessoal mais “ideológico”, preocupado com software, mas sempre ressaltando (basicamente) a questão do usuário como o controlador do seu ambiente computacional. O primeiro grupo com o passar do tempo começou a se chamar de open source ao invés de free software. A princípio essa divisão era apresentada - principalmente pelos propomentes do open source - como uma diferença menor, uma diferença no modo de se lidar com “os gravatas”, mas no fim era tudo a mesma coisa, todos usavam e escreviam os mesmos programas, usavam as mesmas licensas, eram todos parte da mesma coisa.

O que pegou?

As coisas continuaram mais ou menos assim até a metade/final dos anos 2000 até que foi lançada a GPLv3. O objetivo dessa nova versão da GPL é tapar os buracos na redação da gpl v2 e que deixavam brechas para usos proprietários de programas sob a gpl. Mas com a chegada da gpl v3 as diferenças entre open source e software livre se tornaram mais visíveis e estas diferenças acabaram sendo a expressão mais visível de um movimento para tirar da sala qualquer coisa que pudesse trazer pruridos numa reunião com executivos. Nessa época as licensas mais permissivas começaram a ser mais usadas, a gpl perdeu relevância e o software livre virou “oficialmente” open source. Curiosamente, em certo sentido, as “previsões” de ambos, software livre e open source se concretizaram.

Com o avanço das licensas permissivas, as empresas perderam o “medo de contaminação” com a gpl e software open source se tornou cada vez mais presente até ser padrão em muitos lugares. Mais do que isso, software livre é a espinha dorsal da internet, rodando em servidores e roteadores e atualmente também a base de muito software desenvolvido em cima de bibliotecas livres.

Oito anos depois

Foi aqui que parei de escrever em 2015 e depois de oito anos a marcha dos acontecimentos continuou acelerada no caminho que estava. Software livre faz parte do mainstream do desenvolvimento de software corporativo. Em qualquer projeto de software hoje em dia você vai encontrar muito software livre ou no software em si ou na tool chain, em algum lugar você vai encontrar algum código livre. O principal dispositivo computacional das pessoas hoje em dia é um telefone. A maioria dos telefones roda android e android é feito em cima de um monte de coisa livre (mas não gpl porque o google tem medo de infecção). Mas ao mesmo tempo o ambiente computacional no telefone é uma das coisas mais travadas que há além de estar cheio de aplicativos proprietários. Como?

No ponto de se ter um monte de coisas proprietárias feitas em cima de software livre é o resultado da proliferação de licensas sem copyleft. Licensas com copyleft tem uma caracteristica principal que é fazer com que todos participem do jogo. «Tá aqui meu código, pra participar da brincadeira você também contribui com código». Já licensas sem copyleft simplesmente dão as 4 liberdades, mas não demandam código em troca. E quando você dá um monte de coisas pra um negócio sem demandar nada em troca, não vai haver troca. Negócios são negócios.

Por outro lado a maioria das pessoas usa seus dispositivos geralmente como um terminal burro, toda a computação é feita em servidores e os dispositivos mostram o resultado e se nem a gpl v3 ganhou tração imagina a agpl que foi feita pra lidar exatamente com servidores. Então a gente chegou ao ponto onde nunca se teve tanto software livre sendo feito e usado e ao mesmo tempo o ambiente computacional das pessoas nunca foi tão travado.

Por que o bug era anunciado?

O movimento de software livre causou movimentos muito fortes e causou avanços incríveis na indústria de software. Nos anos 80 um compilador de c custava alguns milhares de dólares, nos anos 90 todo ambiente de desenvolvimento era com software proprietário e hoje as coisas são bem diferentes. Mas apesar disso as empresas de tecnologia estão no controle da informação e da computação com maior preponderância ainda. Aconteceram flame wars de todo tipo, posts gigantescos pseudo-filosóficos sobre o que é liberdade e no final das contas o que importava mesmo pros negócios é o quanto de trabalho grátis elas conseguem extrair.

O caso do software livre foi um caso de se olhar pras árvores e não ver a floresta. Enquanto mirávamos em como fazer nossos programas melhores e mais úteis, a gente esqueceu que programas de computador são só ferramentas e ferramentas são sempre usadas por pessoas dentro de um contexto. O nosso contexto é que a produção de qualquer coisa (inclusive de programas de computador) que sirva para atender alguma necessidade humana está submetida à logica da maximização e apropriação de lucros.

Por esta lógica, ter o controle dos dados, da computação, dos algoritmos usados por todos e cada vez mais importatantes é extremamente lucrativo e enquanto estivermos submetidos a este contexto, as nossas licensas de software podem ganhar muitas batalhas, mas a guerra vai sempre ser perdida.


Apresentando: MongoMotor

  • publicado em 17 de agosto de 2018

Oi, pessoal. Tudo certo?

A ideia hoje é falar um pouquinho sobre o MongoMotor, que é uma biblioteca para acesso assíncrono ao MongoDB usando Python.

O que é esse tal de assíncrono mesmo?

Em geral, operações de entrada e saída de dados (io) consomem bastante tempo aguardando a chegada/envio de dados e enquanto um programa aguarda estas operações todo o processamento é bloqueado e nada mais será feito até que os dados sejam recebidos ou enviados. Para lidar com esta situação, as soluções comuns são threads e multi-processos (vide apache) ou io assíncrono, utilizando eventos do sistema operacional (vide nginx).

Com io assíncrono, quando executamos alguma operação de io ao invés de aguardarmos o retorno, a operação é «deixada de lado» (num scheduler), liberando o código para processar outras coisas, e quando obtivermos alguma resposta da operação de io o sistema operacional enviará um evento informando que a resposta chegou e assim o processamento da operação original pode ser retomado.

Em Python, há muito tempo se tem projetos que usam a ideia de io assíncrono para resolver este problema, como o Tornado ou o Twisted, mas com a chegada do módulo asyncio à biblioteca padrão no Python 3.4 e com a inclusão da super simpática sintaxe async/await no Python 3.5, operações de io assíncrono se tornaram uma coisa muito mais corriqueira na linguagem.

Para uma super palestra sobre concorrência, veja este vídeo. E esse async/await, hem?

Bem resumidamente, com async podemos definir corotinas em Python. Corotinas são funções que quando chamadas retornam um objeto Future e precisam ter a sua execução agendada por algum io loop. Algo assim:

async def my_coro():
    """Uma corotina que não faz nada"""

    # do something
    return 'some result'

async def other_coro():
    """Uma corotina que chama outra corotina"""

    # r será o valor retornado por my_coro depois que sua execução for terminada.
    # Se chamássemos `my_coro()` sem `await` r seria uma future.
    r = await my_coro()
    # faça algo com r
    return 'a new result'

# Agora agendamos a future retornada `other_coro()`
loop = asyncio.get_event_loop()
loop.run_until_complete(other_coro())

Para mais informações veja: asyncio docs e leia esse post.

Finalmente o MongoMotor

O mongomotor tira proveito do Motor, um driver assíncrono para MongoDB, e do MongoEngine, com sua api à la Django ORM, criando assim uma biblioteca bem simples para acesso assíncrono ao mongodb. Basta criar classes representando seus documentos, declarar alguns atributos - se quiser - e é isso, já podemos acessar o mongodb de maneira assíncrona.

Código. Aleluia!

Acabou a parte chata e chegou o que todo mundo queria: código. Neste exemplo, vamos analisar as perguntas mais recentes no stackoverflow.

A primeira coisa que precisamos fazer é instalar o mongomotor. Isto é feito usando-se o pip. Num terminal digite o seguinte:

$ pip install mongomotor

Agora, com o mongomotor já instalado podemos começar a escrever o código. Num arquivo python, faça:

# -*- coding: utf-8 -*-

# A função connect é usada para connectar a um banco de dados
from mongomotor import connect

# Conectamos uma vez quando nosso programa começa e está feia a conexão.

# Usando connect() sem parâmetros, o mongomotor vai tentar se conectar ao
# mongo em localhost na porta 27017, o padrão para a instalação.
connect()

# Se necessário é possível passar outros parâmetros, além dos parâmetros de
# autenticação
connect(host='my.mongo.host', port=1234, username='myself', password='my-password')

Agora vamos definir os nossos documentos:

# A classe Document é a base para os nossos documentos que serão definidos
from mongomotor import Document

# Apesar de o mongo ser um banco de dados sem schema, usamos estes campos
# para declarar nosso schema ficando mais fácil o entendimento posterior
# do código.

# Documentos com campos dinâmicos podem ser criados Usando-se a classe
# mongomotor.DynamicDocument

from mongomotor.fields import URLField, StringField, ListField, ReferenceField, IntField


class Usuario(Document):
    """Um usuário que fez uma pergunta no stackoverflow."""

    # Este campo será um inteiro e é obrigatório, por isso o uso do
    # parâmetro required=True.
    # Usamos também o parâmetro unique=True para garantir que só exista
    # um documento com este valor.
    external_id = IntField(required=True, unique=True)
    """O id do usuário no so."""

    nome = StringField()
    """O nome usuário que será exibido. O nome, não o usuário. :P"""

    reputacao = IntField()
    """A reputação do usuário no site."""


class Pergunta(Document):

    external_id = IntField(required=True, unique=True)
    """O id da pergunta no so."""

    titulo = StringField(required=True)
    """O título da pergunta"""

    # URLField é uma string que será validada para verificar se é uma
    # url
    url = URLField(required=True, unique=True)
    """A url da pergunta no so."""

    # ReferenceField aponta para um outro documento.
    # NOTA: Esta relação é feita na apliação, não no mongodb server.
    usuario = ReferenceField(Usuario, required=True)
    """O usuário que fez a pergunta."""

    # ListField indica que o campo é uma lista. Neste caso teremos uma lista
    # de strings.
    tags = ListField(StringField())
    """A lista de tags da pergunta"""


# Para que o unique funcione precisamos criar os índices nas coleções.
Usuario.ensure_indexes()
Pergunta.ensure_indexes()

Pronto, nossos documentos já estão definidos. Para informações sobre todas as opções para definir documentos, veja aqui.

Agora podemos inserir dados e fazer buscas nos documentos. Para inserir os dados vamos user a api do stackoverflow. Para fazer requisições http assíncronas, usaremos a biblioteca aiohttp. Num terminal instale-a com:

$ pip install aiohttp

Aqui a função para baixar os dados.

import json
# Usamos aiohttp para fazer requests http assíncronos
from aiohttp import ClientSession

SO_URL = 'https://api.stackexchange.com/2.2/questions?order=desc&sort=activity&site=stackoverflow'


async def get_so_questions():
    async with ClientSession() as session:
        async with session.get(SO_URL) as response:
            r = await response.read()

    # Retorna uma lista de dicionários. Cada dicionário contém informação
    # sobre uma pergunta.
    return json.loads(r.decode())['items']

O uso do aiohttp não está no escopo deste artigo, mas a ideia aqui é fazer as operações de io (no caso os requests http) de maneira assíncrona.

Agora já podemos cadastrar alguns dados. Primeiro vamos criar um método para criar um usuário baseado na informação retornada pela api.

class Usuario(Document):
    """Um usuário que fez uma pergunta no stackoverflow."""

    # Este campo será um inteiro e é obrigatório, por isso o uso do
    # parâmetro required=True.
    # Usamos também o parâmetro unique=True para garantir que só exista
    # um documento com este valor.
    external_id = IntField(required=True, unique=True)
    """O id do usuário no so."""

    nome = StringField()
    """O nome de usuário que será exibido"""

    reputacao = IntField()
    """A reputação do usuário no site."""

    # Adicionamos este método para inserir usuários.
    @classmethod
    async def get_or_create(cls, user_info):
        """Retorna um usuário. Tenta obter um usuário através de sua
        external_id. Se não existir, cria um novo usuário.

        :param user_info: Um dicionário com informações do usuário enviado
          pela api.
        """

        external_id = user_info['user_id']
        nome = user_info['display_name']
        reputacao = user_info['reputation']
        try:
            # O atributo `objects` é um objeto do tipo QuerySet.
            # O método `get()` retorna um documento baseado nos parâmetros
            # passados a este método.
            user = await cls.objects.get(external_id=external_id)
        except cls.DoesNotExist:
            # Quando nenhum documento que se enquadra nos parâmetros
            # é encontrado uma exceção `DoesNotExist` é levantada.
            # Aqui neste caso criamos um novo documento
            user = cls(external_id=external_id, nome=nome,
                       reputacao=reputacao)
            # E salvamos o documento usando o método `save()`
            await user.save()

        return user

Agora vamos escrever um pouco de código para inserir as perguntas baseado no retorno da api

class Pergunta(Document):
    """Uma pergunta feita no stackoverflow."""

    external_id = IntField(required=True, unique=True)
    """O id da pergunta no so."""

    titulo = StringField(required=True)
    """O título da pergunta"""

    # URLField é uma string que será validada para verificar se é uma
    # url
    url = URLField(required=True, unique=True)
    """A url da pergunta no so."""

    # ReferenceField aponta para um outro documento.
    # NOTA: Esta relação é feita na apliação, não no mongodb server.
    usuario = ReferenceField(Usuario, required=True)
    """O usuário que fez a pergunta."""

    # ListField indica que o campo é uma lista. Neste caso teremos uma lista
    # de strings.
    tags = ListField(StringField())
    """A lista de tags da pergunta"""


    @classmethod
    async def adicionar_perguntas(cls, perguntas):
        """Adiciona as perguntas retornadas pela api.

        :param perguntas: Uma lista de dicionários, cada um com informações
          sobre uma pergunta.
        """

        # Lista para armazenar as perguntas a medida em que formos criando
        # os documentos para salvá-las todas de uma vez só.
        instancias = []
        for pinfo in perguntas:
            # Primeiro criamos usuário
            usuario = await Usuario.get_or_create(pinfo['owner'])

            # Agora criamos a pergunta
            external_id = pinfo['question_id']
            url = pinfo['link']
            title = pinfo['title']
            tags = pinfo['tags']
            pergunta = cls(external_id=external_id, url=url, titulo=title,
                           tags=tags, usuario=usuario)

            # Adicionamos à lista de instâncias para serem salvas depois
            instancias.append(pergunta)

        # Agora salvamos todas as instâncias de uma vez só.
        await cls.objects.insert(instancias)

E uma função pra juntar tudo e popular o banco de dados.

async def populate_db():
    """Função para popular o banco de dados com as últimas perguntas do
    stackoverflow.
    """

    # Vamos limpar tudo primeiro
    await Pergunta.drop_collection()
    await Usuario.drop_collection()

    # Agora cadastramos as perguntas mais recentes
    perguntas = await get_so_questions()
    await Pergunta.adicionar_perguntas(perguntas)

Bom, depois de inserir alguns dados no banco, vamos fazer buscas nestes dados.

async def stats():
    """Função que mostra alguns dados obtidos através da api do stackoverflow.
    """

    # O método `count()` é usado para contar a quantidade de documentos
    # em um queryset.
    total_perguntas = await Pergunta.objects.count()
    total_usuarios = await Usuario.objects.count()

    print('Temos um total de {} perguntas de {} usuários diferentes\n'.format(
        total_perguntas, total_perguntas))

    # Podemos usar o método `order_by()` para ordenar os resultados.
    # Note que não é preciso o uso de await quando estamos filtrando/ordenando
    # um queryset. A operação de io só é executada quando um documento for
    # necessário
    usuarios = Usuario.objects.order_by('-reputacao')

    # Usamos método `fisrt` para pegar o primeiro resultado do queryset.
    # Aqui sim é necessário o uso de await.
    usuario = await usuarios.first()

    print('O usuário com maior reputação é: *{}* com reputação {}'.format(
        usuario.nome, usuario.reputacao))

    # Podemos usar o método `filter()` para filtrar os resultados de um
    # queryset
    fileterd_qs = Pergunta.objects.filter(usuario=usuario)
    # E podemos iterar sobre os resultados do queryset com `async for`
    print('As perguntas de *{}* são:'.format(usuario.nome))
    async for pergunta in fileterd_qs:
        print('- {}'.format(pergunta.titulo))
        print('  tags: {}'.format(', '.join(pergunta.tags)))

    print('')

    # Com o método `item_frequencies()` podemos contas as repetições
    # de items de listas em documentos de um queryset
    popular_tags = await Pergunta.objects.item_frequencies('tags')

    tags = sorted([(k, v) for k, v in popular_tags.items()],
                  key=lambda x: x[1], reverse=True)
    most_popular = tags[0]
    print('A tag mais popular é *{}* com {} perguntas'.format(most_popular[0],
                                                              most_popular[1]))

    # Podemos filtar um queryset com base em um item de uma lista, no
    # nosso exemplo, com uma tag
    print('As perguntas de *{}* são:'.format(most_popular[0]))
    async for pergunta in Pergunta.objects.filter(tags=most_popular[0]):
        print('- {}'.format(pergunta.titulo))
        print('  tags: {}'.format(', '.join(pergunta.tags)))

        # Note que para acessar uma referência é necessário o uso
        # de await
        usuario = await pergunta.usuario
        print('  usuario: {}'.format(usuario.nome))

    print('')

Para a documentação completa de como fazer buscas usando o mongomotor veja aqui.

E é isso. Acabamos de conhecer o básico do mongomotor. Pra finalizar, vamos fazer uma função que coloca tudo isso junto:

async def main():
    print('Populando o banco de dados...')
    await populate_db()
    print('Banco de dados populado!\n')
    await stats()

E por fim, colocar isso aqui no final do nosso arquivo:

if __name__ == '__main__':

    import asyncio
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

E feito! Nosso script final ficou assim:

# -*- coding: utf-8 -*-

import json
from aiohttp import ClientSession
from mongomotor import connect
from mongomotor import Document
from mongomotor.fields import (URLField, StringField, ListField, ReferenceField,
                               IntField)

connect()

SO_URL = 'https://api.stackexchange.com/2.2/questions?order=desc&sort=activity&site=stackoverflow'


async def get_so_questions():
    async with ClientSession() as session:
        async with session.get(SO_URL) as response:
            r = await response.read()

    return json.loads(r.decode())['items']


class Usuario(Document):
    """Um usuário que fez uma pergunta no stackoverflow."""

    # Este campo será um inteiro e é obrigatório, por isso o uso do
    # parâmetro required=True.
    # Usamos também o parâmetro unique=True para garantir que só exista
    # um documento com este valor.
    external_id = IntField(required=True, unique=True)
    """O id do usuário no so."""

    nome = StringField()
    """O nome de usuário que será exibido"""

    reputacao = IntField()
    """A reputação do usuário no site."""

    # Adicionamos este método para inserir usuários.
    @classmethod
    async def get_or_create(cls, user_info):
        """Retorna um usuário. Tenta obter um usuário através de sua
        external_id. Se não existir, cria um novo usuário.

        :param user_info: Um dicionário com informações do usuário enviado
          pela api.
        """

        external_id = user_info['user_id']
        nome = user_info['display_name']
        reputacao = user_info['reputation']
        try:
            # O atributo `objects` é um objeto do tipo QuerySet.
            # O método `get()` retorna um documento baseado nos parâmetros
            # passados a este método.
            user = await cls.objects.get(external_id=external_id)
        except cls.DoesNotExist:
            # Quando nenhum documento que se enquadra nos parâmetros
            # é encontrado uma exceção `DoesNotExist` é levantada.
            #
            # Aqui neste caso criamos um novo documento
            user = cls(external_id=external_id, nome=nome,
                       reputacao=reputacao)
            # E salvamos o documento usando o método `save()`
            await user.save()

        return user


class Pergunta(Document):
    """Uma pergunta feita no stackoverflow."""

    external_id = IntField(required=True, unique=True)
    """O id da pergunta no so."""

    titulo = StringField(required=True)
    """O título da pergunta"""

    # URLField é uma string que será validada para verificar se é uma
    # url
    url = URLField(required=True, unique=True)
    """A url da pergunta no so."""

    # ReferenceField aponta para um outro documento.
    # NOTA: Esta relação é feita na apliação, não no mongodb server.
    usuario = ReferenceField(Usuario, required=True)
    """O usuário que fez a pergunta."""

    # ListField indica que o campo é uma lista. Neste caso teremos uma lista
    # de strings.
    tags = ListField(StringField())
    """A lista de tags da pergunta"""


    @classmethod
    async def adicionar_perguntas(cls, perguntas):
        """Adiciona as perguntas retornadas pela api.

        :param perguntas: Uma lista de dicionários, cada um com informações
          sobre uma pergunta.
        """

        # Lista para armazenar as perguntas a medida em que formos criando
        # os documentos para salvá-las todas de uma vez só.
        instancias = []
        for pinfo in perguntas:
            # Primeiro criamos usuário
            usuario = await Usuario.get_or_create(pinfo['owner'])

            # Agora criamos a pergunta
            external_id = pinfo['question_id']
            url = pinfo['link']
            title = pinfo['title']
            tags = pinfo['tags']
            pergunta = cls(external_id=external_id, url=url, titulo=title,
                           tags=tags, usuario=usuario)

            # Adicionamos à lista de instâncias para serem salvas depois
            instancias.append(pergunta)

        # Agora salvamos todas as instâncias de uma vez só.
        await cls.objects.insert(instancias)


# Para que o unique funcione precisamos criar os índices nas coleções.
Usuario.ensure_indexes()
Pergunta.ensure_indexes()


async def populate_db():
    """Função para popular o banco de dados com as últimas perguntas do
    stackoverflow.
    """

    # Vamos limpar tudo primeiro
    await Pergunta.drop_collection()
    await Usuario.drop_collection()

    # Agora cadastramos as perguntas mais recentes
    perguntas = await get_so_questions()
    await Pergunta.adicionar_perguntas(perguntas)


async def stats():
    """Função que mostra alguns dados obtidos através da api do stackoverflow.
    """

    # O método `count()` é usado para contar a quantidade de documentos
    # em um queryset.
    total_perguntas = await Pergunta.objects.count()
    total_usuarios = await Usuario.objects.count()

    print('Temos um total de {} perguntas de {} usuários diferentes\n'.format(
        total_perguntas, total_perguntas))

    # Podemos usar o método `order_by()` para ordenar os resultados.
    # Note que não é preciso o uso de await quando estamos filtrando/ordenando
    # um queryset. A operação de io só é executada quando um documento for
    # necessário
    usuarios = Usuario.objects.order_by('-reputacao')

    # Usamos método `fisrt` para pegar o primeiro resultado do queryset.
    # Aqui sim é necessário o uso de await.
    usuario = await usuarios.first()

    print('O usuário com maior reputação é: *{}* com reputação {}'.format(
        usuario.nome, usuario.reputacao))

    # Podemos usar o método `filter()` para filtrar os resultados de um
    # queryset
    fileterd_qs = Pergunta.objects.filter(usuario=usuario)
    # E podemos iterar sobre os resultados do queryset com `async for`
    print('As perguntas de *{}* são:'.format(usuario.nome))
    async for pergunta in fileterd_qs:
        print('- {}'.format(pergunta.titulo))
        print('  tags: {}'.format(', '.join(pergunta.tags)))

    print('')

    # Com o método `item_frequencies()` podemos contas as repetições
    # de items de listas em documentos de um queryset
    popular_tags = await Pergunta.objects.item_frequencies('tags')

    tags = sorted([(k, v) for k, v in popular_tags.items()],
                  key=lambda x: x[1], reverse=True)
    most_popular = tags[0]
    print('A tag mais popular é *{}* com {} perguntas'.format(most_popular[0],
                                                              most_popular[1]))

    # Podemos filtar um queryset com base em um item de uma lista, no
    # nosso exemplo, com uma tag
    print('As perguntas de *{}* são:'.format(most_popular[0]))
    async for pergunta in Pergunta.objects.filter(tags=most_popular[0]):
        print('- {}'.format(pergunta.titulo))
        print('  tags: {}'.format(', '.join(pergunta.tags)))

        # Note que para acessar uma referência é necessário o uso
        # de await
        usuario = await pergunta.usuario
        print('  usuario: {}'.format(usuario.nome))

    print('')


async def main():
    print('Populando o banco de dados...')
    await populate_db()
    print('Banco de dados populado!\n')
    await stats()


if __name__ == '__main__':

    import asyncio
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

O script pode ser baixado aqui.

Salve isto em um arquivo chamado mmso.py e num terminal execute:

$ python mmso.py

E voilà, eis a saída do nosso programa:

Populando o banco de dados...
Banco de dados populado!

Temos um total de 30 perguntas de 30 usuários diferentes

O usuário com maior reputação é: *jww* com reputação 49941
As perguntas de *jww* são:
- Undefined reference to symbol during link GCC inline assembly
  tags: c++, gcc, linker-errors, inline-assembly

A tag mais popular é *python* com 5 perguntas
As perguntas de *python* são:
- BeautifulSoup4 findChildren() is empty
  tags: python, html, parsing, beautifulsoup
  usuario: Meghan M.
- How to update weights in neural networks?
  tags: python, neural-network
  usuario: Absolute Idiot
- assign results to dummy variable _
  tags: python, python-3.x
  usuario: cs0815
- use opencv, cv2.videocapture in kivy with android - python for android
  tags: android, python, opencv, kivy, buildozer
  usuario: Vajira Prabuddhaka
- How to get the value of a Django Model Field object
  tags: python, django, django-models
  usuario: Hugo Luis Villalobos Canto

Para informações mais detalhadas sobre o mongomotor, veja a documentação.

O mongomotor é software livre, sinta-se à vontade para contribuir. :)


Comporte-se, menino! Testando suas aplicações com behave

  • publicado em 21 de novembro de 2016

Boas, pessoal! Tudo tranquilo? Hoje vamos falar sobre como testar o comportamento de suas aplicações, fazendo testes de aceitação, usando um carinha chamado behave.

Testes de aceitação? Behave? WTF!?

Testes de aceitação são testes que verificam a correção de alguma funcionalidade do seu programa, geralmente feitos a partir de user stories definidas durante o planejamento da sessão de desenvolvimento. Estes testes validam os resultados esperados do sistema, do ponto de vista de um usuário.

O behave é uma biblioteca que te permite escrever seus testes em linguagem natural (as user stories) e escrever as ações dos testes usando a linguagem Python.

Uma aplicaçãozinha de exemplo

Suponhamos que temos uma pequena aplicação web que mostra o horário o horário atual quando acessamos a página. O código pra isso seria algo assim:

# -*- coding: utf-8 -*-
# arquivo mostra_hora.py

import datetime

from flask import Flask


def get_formated_datetime():
    dt = datetime.datetime.now()
    formated = dt.strftime('%d/%m/%Y %H:%M:%S')
    return formated


app = Flask(__name__)


@app.route('/')
def index():
    tmpl = "<html><head><title>Que horas são?</title></head>"
    tmpl += '<body><form action="/hora"><input type="submit"" value="click!"/>'
    tmpl += '</body></html>'
    return tmpl


@app.route('/hora')
def show_datetime():
    tmpl = "<html><head><title>Agora são...</title></head>"
    tmpl += '<body>O horário atual é:{}</body></html>'
    dt = get_formated_datetime()
    return tmpl.format(dt)


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

Com essa aplicaçãozinha pronta, podemos executar o seguinte comando para iniciar a aplicação:

$ python mostra_hora.py
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

Agora, quando você acessar, no seu navegador, o endereço 127.0.0.1:5000 você vai ver uma página com um botão. Clicando nesse botão você cai em outra página em que a data/hora atual são mostradas.

Os testes

Agora vamos escrever os nossos testes. A primeira coisa que temos que fazer é escrever uma user story contendo os passos que um usuário executaria no nosso site. A história fica em um arquivo com extensão .feature, então criaremos um arquivo chamado tests/site.feature com o seguinte conteúdo:

Feature: Ver a data e hora atual no site

    Scenario: Navegar até ver o data e hora.
        Given o usuário acessou o site
        When ele clica no botão 'click!'
        Then ele  a data e hora atual.

Como vocês podem ver, este arquivo .feature descreve em linguagem natural os passos que seriam executados por um usuário no site que deseja usar a funcionalidade sendo executada. Vamos parar por aqui e ver em detalhes o fizemos neste arquivo.

Given, When, Then & friends

As linhas com Given, When e Then são os passos que serão executados durante o teste. A diretiva “given” é usada para deixar o sistema em um estado conhecido para que a partir deste estado possamos executar os testes. Nesta diretiva você poderia criar dados, executar alguns passos ou qualquer outra coisa que seja precisa para deixar o seu teste pronto para execução. A diretiva “When” é usada para executar alguma ação e a diretiva “Then” é onde verificamos o resultado do passo executado em “When”.

Além destas diretivas também temos as diretivas “And” e “But” que são diretivas feitas somente para facilitar a escrita dos testes. Quando usarmos uma diretiva “And” or “But”, o behave traduzirá estas diretivas como sendo a última diretiva usada. Então, quando usamos When… And… este And é traduzido para um When pelo behave na hora da execução dos testes.

O ambiente

Em, geral, antes de começarmos os nossos testes precisamos configurar o ambiente com algumas coisas que precisamos que já estejam rodando assim que começarmos os testes. No caso da nossa aplicação de teste precisamos que o servidor já esteja de pé e precisamos de uma instância de um browser selenium para podermos simular o comportamento do usuário. Para configurar o ambiente, o behave usa um arquivo chamado environment.py. O nosso environment.py seria algo assim:

# -*- coding: utf-8 -*-

from selenium import webdriver

from mostra_hora import runserver, killserver


def before_feature(context):
    """Função executada antes cada feature dos testes. O contexto
    passado aqui é compartilhado por todos os steps, então se criarmos
    algo e colocarmos como um elemento do contexto podemos recuperar o
    que criamos dentro de um step a partir do contexto."""

    # primeiro subimos o servidor
    runserver()
    # e depois criamos um browser e deixamos no contexto
    context.browser = webdriver.Chrome()


def after_feature(context):
    """Função executada depois de cada feature dos testes."""

    # agora matamos o servidor
    killserver()
    # fechamos o browser
    context.browser.quit()

Os steps

Agora que já temos nossa funcionalidade descrita no arquivo .feature precisamos escrever um código Python que contém as ações a serem executadas durante os testes. Os passos serão associados ao com base no texto do passo descrito no arquivo .feature. Então vamos criar um arquivo chamado tests/steps/site_steps.py com o seguinte conteúdo:

# -*- coding: utf-8f -*-

from behave import given, when, then


@given(u'o usuário acessou o site')
def step_impl(context):
    """Acessamos a página inicial do site"""
    context.browser.get('http://127.0.0.1:5000')


@when(u"ele clica no botão 'click!'")
def step_impl(context):
    """clicamos no botão"""

    context.browser.find_element_by_tag_name('input').click()


@then(u'ele vê a data e hora atual.')
def step_impl(context):
    """Verificamos que a data e hora é realmente exibida."""
    browser = context.browser
    is_present = u'O horário atual é' in browser.page_source
    assert is_present

Agora já temos tudo o que precisamos para executar os testes. Isso é feito usando o comando behave e apontando para um diretório que contenha os arquivos .feature, assim:

$ behave tests/

Feature: Ver a data e hora atual no site    # tests/site.feature:1
    Scenario: Navegar até ver o data e hora # tests/site.feature:3
        Given o usuário acessou o site      # tests/site.feature:4
        When ele clica no botão 'click!'    # tests/site.feature:5
        Then ele  a data e hora atual.    # tests/site.feature:6

1 features passed, 0 failed, 0 skipped
1 scenarios passed, 0 failed, 0 skipped
3 steps passed, 0 failed, 0 skipped, 0 undefined

E aí temos o relatório da execução dos testes indicando o que passou, o que falhou, onde e etc.

Bom, esta foi só uma pequena introdução ao behave, para saber mais veja a documentação oficial.


Empacotamento e distribuição de projetos Python sem mistério - Adendo

  • publicado em 15 de setembro de 2015

Oi, pessoal. Hoje o post é rapidinho, só um adendo ao post anterior sobre empacotamento de projetos Python.

A coisa é a seguinte: A maneira recomendada para fazer o upload do pacote para o pypi agora é usando o twine. Esse carinha é o recomendado agora porque, diferentemente do pip, ele faz o upload usando https.

Então, quando formos empacotar e distribuir nosso programa, ao invés de usarmos:

$ python setup.py sdist upload

Para criar o pacote e subi-lo, usaremos o comando sdist para criar o pacote e depois usaremos o twine para fazer o upload, assim:

$ python setup.py sdist
$ twine upload dist/my-package-0.1.tar.gz

E é isso!


Empacotamento e distribuição de projetos Python sem mistério

  • publicado em 06 de março de 2015

Oi, pessoal, tudo certo? O assunto hoje é o empacotamento dos nossos projetos Python, ou seja, como a gente faz pra distribuir o nosso código pra outras pessoas, isso de uma maneira fácil pra nós que desenvolvemos e pra quem vai instalar o programa. A ideia aqui é explicar o que precisa fazer pra o nosso programa poder ser instalado via pip, assim facilitando a distribuição.

Começando

O que a gente precisa fazer pra empacotar e distribuir nosso projeto é bem pouca coisa. Antes de começar a gente só precisa das dependências, o setuptools e o pip. Instalando o pip o setuptools vem como dependência. No debian o pacote chama python-pip, e até acho que vem por padrão, nos red hat é algo assim também. Em outros OSs não sei como instalar, mas vai lá, instala rapidão. Aproveita e instala o virtualenvwrapper que a gente vai usar pra testar as coisas.

Já instalou? Beleza, agora vamos lá.

Um exemplo simples

Pra começar vamos usar um exemplo bem simples, o nosso programa vai ser um único módulo, ou seja, ,um único arquivo .py. Vamos criar um diretório vazio pra gente trabalhar:

$ mkdir ~/dist-teste
$ cd ~/dist-teste

É aqui neste diretório que a gente vai criar módulo (um arquivo.py) que queremos distribuir. Vamos criar um módulo chamado meusuperprograma (arquivo meusuperprograma.py). Aqui tem que se tomar cuidado pra não escolher um nome que já seja de algum módulo da biblioteca padrão do Python ou que já seja de alguma outra biblioteca popular (uma busca no pypi.python.org ajuda). Então, aqui está o nosso módulo de exemplo:

#-*- coding: utf-8 -*-
# arquivo meusuperprograma.py

import time


def faz_algo_dahora():
    return time.time()

Tendo isto, um programa que é um único módulo, nossa estrutura de diretórios ficou assim:

dist-teste/

`-- meusuperprograma.py

Já temos um programa, agora a gente precisa ajeitar as coisas pra distribuir. Pra isso precisamos criar um arquivo chamado setup.py no mesmo nível de diretório que o nosso módulo (no diretório ~/dist-teste). Neste arquivo que vão estar as configurações para o empacotamento. Um setup.py básico seria assim:

#-*- coding: utf-8 -*-

from setuptools import setup


setup(name='meusuperprograma',  # aqui o nome do seu programa
      version='0.1',  # a versão.
      author='Eu Mesmo',
      author_email='me@myplace.net',
      # Esta url deveria ser a url para a documentação/código/site oficial do projeto.
      url='http://meusuperprograma.org',
      # Aqui uma lista dos módulos que compõe a sua distribuição.
      # No nosso caso, um módulo só.
      py_modules=['meusuperprograma'],
)

Então, agora com o setup.py temos a seguinte estrutura de diretórios:

dist-teste/

|-- setup.py

`-- meusuperprograma.py

Com isso já podemos distribuir nosso programa. Só precisamos subir nosso código para o CheeseShop e todo mundo vai poder instalar com um simples pip install. Legal, né? Mas peraí… O que é mesmo o CheeseShop, hem?

CheeseShop, o Python Package Index

CheeseShop é o codinome secreto do Python Package Index, aquele carinha que você encontra em https://pypi.python.org/pypi e tenho certeza que você já conhece. Quando a gente instala um programa com pip install… é aí que o pip vai procurar o programa. Além deste pypi, a gente ainda tem um pypi de teste à nossa disposição, esse aqui: https://testpypi.python.org/pypi. Vai lá, se registra (nos dois, são bases separadas) e volta aqui. Rápido.

Pronto? Beleza. Agora a gente vai configurar o pip pra usar as nossas credenciais. No arquivo ~/.pypirc coloque o seguinte:

[distutils]
index-servers =
    pypi
    testpypi

[pypi]
username: ze
password: ninguém

[testpypi]
username: ze
password: ninguém
repository: https://testpypi.python.org/pypi

E é isso. Já temos o nosso super programa pra distribuir, já temos nosso arquivo de configuração da distribuição (o setup.py) e já estamos registrados nos lugares pra onde queremos subir nosso código. Agora é só alegria.

Distribuindo nosso programa

Como essa é a primeira versão do nosso programa, a gente vai precisar registrar nosso projeto. A gente faz isso com o comando register do setuptools. No exemplo abaixo registraremos nosso programa no pypi de teste, por isso usaremos o parâmetro -r testpypi para indicar que usaremos o repositório que está com o nome testpypi no nosso .pypirc. Se não usássemos este parâmetro, iriamos registrar no pypi oficial. Então, pra registrar fica assim:

$ python setup.py register -r testpypi

running register
running egg_info

[ output cortado ]

running check
Registering meusuperprograma to https://testpypi.python.org
Server response (200): OK

Agora que já registramos nosso programa, podemos fazer um release, isto é, fazer o upload de uma versão do nosso código. A gente faz isso com os comandos sdist e upload. O comando sdist cria uma distribuição com os nossos arquivos e o upload envia este arquivo para o servidor escolhido. Novamente usaremos o parâmetro -r testpypi.

$ python setup.py sdist upload -r testpypi

running sdist
running egg_info
[ output cortado ]
warning: sdist: standard file not found: should have one of README, README.rst, README.txt
running check
[ output cortado ]

Creating tar archive
removing 'meusuperprograma-0.1' (and everything under it)
running upload
Submitting dist/meusuperprograma-0.1.tar.gz to https://testpypi.python.org/pypi
Server response (200): OK

E é isso, temos nosso programa prontinho pra distribuir - apesar do warining por causa da falta de README.

Testando nossa distribuição

Pra testar a nossa distribuição, criaremos um virtualenv e também criaremos um diretório vazio para ser nosso diretório de trabalho nos testes. O diretório vazio é para nada “ficar no caminho” e atrapalhar nos testes. Então, vamos criar as coisas primeiro:

$ mkvirtualenv meusuperprogramaenv -p /usr/bin/python3.4
[ output cortado ]
$ mkdir ~/dir-limpo && cd ~/dir-limpo

Agora, vamos instalar nosso programa e testar pra ver se foi tudo instalado. Repare que será usado o parâmetro –index-url para indicar que o pip deve procurar pelo pacote no CheeseShop de teste.

$ pip install meusuperprograma --index-url=https://testpypi.python.org/pypi

[ output cortado ]

Successfully installed meusuperprograma
Cleaning up...

$ python
Python 3.4.2 (default, Oct  8 2014, 10:45:20)
[GCC 4.9.1] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import meusuperprograma
>>> meusuperprograma.faz_algo_dahora()
1417391400.7311318
>>>

É isso, nosso programa foi instalado corretamente pelo pip. Mas ainda tem mais coisas pra gente ver.

Um programa com packages

O nosso primeiro exemplo foi bem simples, um programa com apenas um módulo, mas agora nosso programa cresceu ao invés de um módulo temos dois, e pra organizar tudo isso vamos colocá-los dentro de um package. Um package é simplesmente um diretório que contém módulos python.

A estrutura do nosso programa com package ficou assim:

dist-teste/
|-- COPYING
|-- meusuperprograma/
|   |-- __init__.py
|   |-- modulo_a.py
|   `-- modulo_b.py
|-- README
`-- setup.py

Além de alterarmos a estrutura do nosso programa também incluímos um arquivo README (info sobre o programa, docs etc) e um arquivo COPYING com a lincença do programa. Aqui está o conteúdo dos nossos módulos.

Arquivo meusuperprograma/modulo_a.py:

#-*- coding: utf-8 -*-
# arquivo meusuperprograma/modulo_a.py

import time


def faz_algo_dahora():
    return time.time()

Arquivo meusuperprograma/modulo_b.py

# -*- coding: utf-8 -*-
# arquivo meusuperprograma/modulo_b.py

import datetime


def faz_algo_sensacional(timestamp):
    dt = datetime.datetime.fromtimestamp(timestamp)
    return dt.strftime('%H:%M:%S - %d/%m/%Y')

Arquivo meusuperprograma/__init__.py

# -*- coding: utf-8 -*-
# arquivo meusuperprograma/__init__.py

from meusuperprograma.modulo_a import faz_algo_dahora
from meusuperprograma.modulo_b import faz_algo_sensacional


def faz_algo_sensacionalmente_dahora():
    timestamp = faz_algo_dahora()
    datahora = faz_algo_sensacional(timestamp)
    return {'timestamp': timestamp,
            'datahora': datahora}

E com isto, temos um programa com um package para distribuir. Vamos fazer algumas alterações no nosso setup.py para darem conta da nova versão do nosso programa.

#-*- coding: utf-8 -*-

from setuptools import setup


setup(name='meusuperprograma',  # aqui o nome do seu programa
      version='0.2',  # temos que alterar a versão.
      author='Eu Mesmo',
      author_email='me@myplace.net',
      url='http://meusuperprograma.org',
      # ao invés de usarmos o parâmetro py_modules usamos
      # o parâmetro packages.
      packages=['meusuperprograma'],
      # Vamos colocar também alguns classificadores. Estes classificadores
      # não são obrigatórios, mas deus gosta mais de você quando você
      # classifica seus programas.
      # Você pode ver uma lista com todos os classificadores aqui:
      # https://pypi.python.org/pypi?%3Aaction=list_classifiers
      classifiers=[
          'Development Status :: 3 - Alpha',
          'Intended Audience :: Developers',
          'License :: OSI Approved :: GNU General Public License (GPL)',
          'Natural Language :: Portuguese',
          'Operating System :: OS Independent',
          'Programming Language :: Python :: 3',
          'Programming Language :: Python :: 3.2',
          'Programming Language :: Python :: 3.3',
          'Programming Language :: Python :: 3.4',
          'Topic :: Software Development :: Libraries :: Python Modules',
      ],

)

Assim, já podemos fazer o release desta nova versão do programa.

$ cd ~/dist-teste
$ python setup.py sdist upload -r testpypi

running sdist
running egg_info

  [ output cortado ]

running check

[ output cortado ]

Submitting dist/meusuperprograma-0.2.tar.gz to https://testpypi.python.org/pypi
Server response (200): OK

Agora, vamos testar esta distribuição da nova versão

Testando a distribuição com packages

Vamos atualizar a versão do meusuperprograma que está instalado no nosso virtualenv de teste e vamos ao diretório limpo para testar se foi mesmo instalado corretamente. Note que vamos usar uma opção nova, o parâmetro –upgrade que diz para o pip atualizar a versão caso já haja alguma instalada. Não esqueça de ativar seu virtualenv antes de atualizar a versão.

$ # ative o virtualenv se não estiver ativado
$ workon meusuperprogramaenv
$ cd ~/dir-limpo
$ pip install meusuperprograma --index-url=https://testpypi.python.org/pypi --upgrade

  [ output cortado ]

Successfully installed meusuperprograma
Cleaning up...

$ python
Python 3.4.2 (default, Oct  8 2014, 10:45:20)
[GCC 4.9.1] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import meusuperprograma
>>> meusuperprograma.faz_algo_sensacionalmente_dahora()
{'timestamp': 1417399828.9924762, 'datahora': '00:10:28 - 01/12/2014'}
>>>

E tudo certo, nosso programa com package foi instalado corretamente.

O nosso programa ficou tão legal, tão sensacionalmente dahora que a gente decidiu criar um script para o nosso programa poder ser chamado diretamente da linha de comando, como um programa qualquer que a gente usa.

Um programa com script

Para o nosso programa ter um script que qualquer um pode usar da linha de comando, simplesmente criaremos, no nosso root dir do programa, um diretório chamado scripts e dentro deste diretório colocaremos o script que queremos que os usuários executem, e no nosso caso será um script chamado meusuperprograma (sem o .py mesmo).

A estrutura de diretórios do nosso programa com este novo script ficou assim:

/home/juca/dist-teste
|-- COPYING
|-- meusuperprograma
|   |-- __init__.py
|   |-- modulo_a.py
|   `-- modulo_b.py
|-- README
|-- scripts
|   `-- meusuperprograma
`-- setup.py

E este é o conteúdo do arquivo scripts/meusuperprograma

#!/usr/bin/env python
#-*- coding: utf-8 -*-

# arquivo scripts/meusuperprograma

import sys
from meusuperprograma.modulo_a import faz_algo_dahora
from meusuperprograma.modulo_b import faz_algo_sensacional


if __name__ == '__main__':
    if len(sys.argv) > 1:
        timestamp = float(sys.argv[1])
    else:
        timestamp = faz_algo_dahora()

    datahora = faz_algo_sensacional(timestamp)
    msg = "A data e hora para o timestamp {timestamp} é: {datahora}"
    print(msg.format(timestamp=timestamp, datahora=datahora))

E precisamos alterar também o nosso setup.py, mais uma vez. Aqui a versão alterada do setup.py:

#-*- coding: utf-8 -*-

from setuptools import setup


setup(name='meusuperprograma',  # aqui o nome do seu programa
      version='0.3',  # temos que alterar a versão.
      author='Eu Mesmo',
      author_email='me@myplace.net',
      url='http://meusuperprograma.org',
      # ao invés de usarmos o parâmetro py_modules usamos
      # o parâmetro packages.
      packages=['meusuperprograma'],
      # aqui indicamos onde ficam os scripts que serão instalados
      scripts=['scripts/meusuperprograma'],
      # Vamos colocar também alguns classificadores. Estes classificadores
      # não são obrigatórios, mas deus gosta mais de você quando você
      # classifica seus programas.
      # Você pode ver uma lista com todos os classificadores aqui:
      # https://pypi.python.org/pypi?%3Aaction=list_classifiers
      classifiers=[
          'Development Status :: 3 - Alpha',
          'Intended Audience :: Developers',
          'License :: OSI Approved :: GNU General Public License (GPL)',
          'Natural Language :: Portuguese',
          'Operating System :: OS Independent',
          'Programming Language :: Python :: 3',
          'Programming Language :: Python :: 3.2',
          'Programming Language :: Python :: 3.3',
          'Programming Language :: Python :: 3.4',
          'Topic :: Software Development :: Libraries :: Python Modules',
      ],
)

E vamos fazer o release de novo e depois testar.

$ cd ~/dist-teste
$ python setup.py sdist upload -r testpypi
running sdist
running egg_info

[ output cortado ]

running check

[ output cortado ]

creating dist
Creating tar archive
removing 'meusuperprograma-0.3' (and everything under it)
running upload
Submitting dist/meusuperprograma-0.3.tar.gz to https://testpypi.python.org/pypi
Server response (200): OK

$ workon meusuperprogramaenv
$ cd ~/dir-limpo
$ pip install meusuperprograma --index-url=https://testpypi.python.org/pypi --upgrade
Downloading/unpacking meusuperprograma

[ output cortado ]

Successfully installed meusuperprograma
Cleaning up...

Agora, depois de instalado, só testar nosso programa pela linha de comando

$ meusuperprograma
A data e hora para o timestamp 1417404128.7673662 é: 01:22:08 - 01/12/2014

$ meusuperprograma 0
A data e hora para o timestamp 0.0 é: 21:00:00 - 31/12/1969

$ meusuperprograma -62135585612
A data e hora para o timestamp -62135585612.0 é: 00:00:00 - 01/01/1

É isso aí, tudo certinho.

Um programa com dependências

O nosso programa ficou tão legal que vamos até fazer uma versão web pra ele. E claro que a gente não vai fazer tudo na mão, vamos usar um framework, no caso o flask. Pra instalar é fácil, um simples pip install:

$ pip install flask

Com o flask instalado vamos criar um módulo para a nossa aplicação web e um script para rodar esta aplicação.

Primeiro, o arquivo meusuperprograma/webapp.py com a aplicação flask.

# -*- coding: utf-8 -*-
# arquivo meusuperprograma/webapp.py

from flask import Flask, Response
from meusuperprograma import faz_algo_sensacionalmente_dahora

minhasuperapp = Flask('meusuperprograma.webapp')


@minhasuperapp.route('/')
def index():
    info = faz_algo_sensacionalmente_dahora()
    ret = """
    A data e hora atual é: {datahora}.<br/>
    O timestamp pra isso é: {timestamp}
"""
    return(Response(ret.format(datahora=info['datahora'],
                               timestamp=info['timestamp'])))

Agora o arquivo scripts/meusuperprogramaweb, que é o script para rodar nossa aplicação flask.

#!/usr/bin/env python
#-*- coding: utf-8 -*-

from meusuperprograma.webapp import minhasuperapp


if __name__ == '__main__':
    minhasuperapp.run()

Com estes novos arquivos, a estrutura de diretórios ficou assim:

/home/juca/dist-teste
|-- COPYING
|-- meusuperprograma
|   |-- __init__.py
|   |-- modulo_a.py
|   |-- modulo_b.py
|   `-- webapp.py
|-- README
|-- scripts
|   |-- meusuperprograma
|   `-- meusuperprogramaweb
`-- setup.py

E agora vamos novamente alterar o setup.py:

#-*- coding: utf-8 -*-

from setuptools import setup


setup(name='meusuperprograma',  # aqui o nome do seu programa
      version='0.4',  # temos que alterar a versão.
      author='Eu Mesmo',
      author_email='me@myplace.net',
      url='http://meusuperprograma.org',
      # ao invés de usarmos o parâmetro py_modules usamos
      # o parâmetro packages.
      packages=['meusuperprograma'],
      # aqui indicamos onde ficam os scripts que serão instalados
      scripts=['scripts/meusuperprograma', 'scripts/meusuperprogramaweb'],
      # aqui indicamos quais as dependências de instalação
      install_requires=['flask'],

      # Vamos colocar também alguns classificadores. Estes classificadores
      # não são obrigatórios, mas deus gosta mais de você quando você
      # classifica seus programas.
      # Você pode ver uma lista com todos os classificadores aqui:
      # https://pypi.python.org/pypi?%3Aaction=list_classifiers
      classifiers=[
          'Development Status :: 3 - Alpha',
          'Intended Audience :: Developers',
          'License :: OSI Approved :: GNU General Public License (GPL)',
          'Natural Language :: Portuguese',
          'Operating System :: OS Independent',
          'Programming Language :: Python :: 3',
          'Programming Language :: Python :: 3.2',
          'Programming Language :: Python :: 3.3',
          'Programming Language :: Python :: 3.4',
          'Topic :: Software Development :: Libraries :: Python Modules',
      ],
)

E é isso, tudo pronto pra lançar e testar novamente. Perceba que na hora de instalar a nova versão de meusuperprograma vamos usar o parâmetro –extra-index-url ao invés do parâmetro –index-url, isto porque queremos que primeiro seja buscado no cheese shop live e depois no de teste.

$ python setup.py sdist upload -r testpypi

running sdist
running egg_info

[ output cortado ]

running check

[ output cortado ]

Creating tar archive
removing 'meusuperprograma-0.4' (and everything under it)
running upload
Submitting dist/meusuperprograma-0.4.tar.gz to https://testpypi.python.org/pypi
Server response (200): OK

$ cd ~/dir-limpo
$ pip install meusuperprograma --extra-index-url=https://testpypi.python.org/pypi --upgrade

[ output cortado ]

Successfully installed meusuperprograma
Cleaning up...

Perceba que na hora da instalação foi instalado também, automaticamente, o flask e suas dependências.

Agora, vamos testar nossa aplicação web.

$ meusuperprogramaweb
 * Running on http://127.0.0.1:5000/

E abra seu browser e acesse http://127.0.0.1:5000/ para ver a nossa aplicação web rodando.

Só que a aparência da aplicação web ficou meio xoxa, não? Vamos fazer um template lindão pra melhorar as coisas

Um programa com package data

Os arquivos que não são arquivos python, mas que serão incluídos na distribuição são chamados de package data, isso inclui o template (um arquivo .html) que usaremos para a nossa aplicação web.

Então, primeiro fazemos um template bem bonitão, que vai ficar em meusuperprograma/templates/template.html

<html>
  <head>
    <title>Meu Super Programa Versão Web!</title>
  </head>

  <body>
    <div> A data e hora atual é: <span>{{ datahora }}</span></div>
    <div> O timestamp pra isso é: <span>{{ timestamp }}</span></div>
  </body>
</html>

E depois alteramos a nossa webapp pra que passe a usar o template:

# -*- coding: utf-8 -*-

from flask import Flask, render_template
from meusuperprograma import faz_algo_sensacionalmente_dahora


minhasuperapp = Flask('meusuperprograma.webapp')


@minhasuperapp.route('/')
def index():
    info = faz_algo_sensacionalmente_dahora()
    contexto = {'datahora': info['datahora'],
                'timestamp': info['timestamp']}

    return render_template('template.html', **contexto)

Já alteramos tudo o que precisávamos no nosso código, mas ainda precisamos alterar o setup.py e criar mais um novo arquivo, que se chamará MANIFEST.in e ficará na raiz do nosso projeto. Este arquivo MANIFEST.in é um arquivo onde dizemos quais arquivos de package data devem ser incluídos na distribuição. O nosso ficará assim:

include meusuperprograma/templates/template.html

Simplesmente usamos a diretiva include para dizer qual arquivo deve ser incluído na distribuição.

Com este novo arquivo, nossa estrutura de diretórios ficou assim:

/home/juca/dist-teste
|-- COPYING
|-- MANIFEST.in
|-- meusuperprograma
|   |-- __init__.py
|   |-- modulo_a.py
|   |-- modulo_b.py
|   |-- templates
|   |   `-- template.html
|   `-- webapp.py
|-- README
|-- scripts
|   |-- meusuperprograma
|   `-- meusuperprogramaweb
`-- setup.py

Agora vamos alterar o setup.py. É uma alteração simples. Passaremos a usar o parâmetro include_package_data=True para indicar que os arquivos não-python devem ser incluídos. Com esta mudança nosso setup.py ficou assim:

#-*- coding: utf-8 -*-

from setuptools import setup


setup(name='meusuperprograma',  # aqui o nome do seu programa
      version='0.4',  # temos que alterar a versão.
      author='Eu Mesmo',
      author_email='me@myplace.net',
      url='http://meusuperprograma.org',
      # ao invés de usarmos o parâmetro py_modules usamos
      # o parâmetro packages.
      packages=['meusuperprograma'],
      # aqui indicamos onde ficam os scripts que serão instalados
      scripts=['scripts/meusuperprograma', 'scripts/meusuperprogramaweb'],
      # aqui indicamos quais as dependências de instalação
      install_requires=['flask'],
      # aqui dizemos que é para incluir os arquivos que não são
      # arquivos python
      include_package_data=True,

      # Vamos colocar também alguns classificadores. Estes classificadores
      # não são obrigatórios, mas deus gosta mais de você quando você
      # classifica seus programas.
      # Você pode ver uma lista com todos os classificadores aqui:
      # https://pypi.python.org/pypi?%3Aaction=list_classifiers
      classifiers=[
          'Development Status :: 3 - Alpha',
          'Intended Audience :: Developers',
          'License :: OSI Approved :: GNU General Public License (GPL)',
          'Natural Language :: Portuguese',
          'Operating System :: OS Independent',
          'Programming Language :: Python :: 3',
          'Programming Language :: Python :: 3.2',
          'Programming Language :: Python :: 3.3',
          'Programming Language :: Python :: 3.4',
          'Topic :: Software Development :: Libraries :: Python Modules',
      ],
)

E agora sim temos tudo pronto. Vamos gerar nossa distribuição e testar.

$ python setup.py sdist upload -r testpypi

running sdist
running egg_info

  [output cortado]

running check

  [output cortado]

Creating tar archive
removing 'meusuperprograma-0.5' (and everything under it)
running upload
Submitting dist/meusuperprograma-0.5.tar.gz to https://testpypi.python.org/pypi
Server response (200): OK

$ workon meusuperprogramaenv
$ cd ../dir-limpo
$ pip install meusuperprograma --extra-index-url=https://testpypi.python.org/pypi --upgrade

  [output cortado]

$ meusuperprogramaweb
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

E é isso. Agora só abrir seu navegador em 127.0.0.1:5000 que você vai ser seu super programa versão web agora com um lindo template.

Tá vendo, agora não tem mais mistério em como distribuir seus projetos (puro) python. Molezinha!

Dúvidas? Fiquem à vontade, podem mandar bala!


Sumarização automática de textos na prática: Extração baseada em grafos é o que há!

  • publicado em 10 de novembro de 2014

Boas, pessoal. Mais uma vez voltando dos mortos, agora vou falar um pouco sobre sumarização automática de textos usando um método estatístico.

Começando

Bom, sumarização de textos é isso mesmo que você entendeu. Tem um texto grande e a ideia é criar um texto menor, mantendo o importante da informação. Agora só falta dizer que raios é “extração” e, pior ainda, que maldito grafo é esse.

Tem um montão de jeitos de se resumir (sumarizar) um texto, sendo que os mais usados atualmente são os que baseados em análises estatísticas criam um sumário extraindo as frases principais dos texto-fonte [1]. Dentre estes, segundo [2] e [3], os que se saem melhor são os baseados em aprendizagem de máquina, isto é , que usam uma massa de dados catalogada para treinamento e a avaliação dos textos é feita com base nos resultados deste treinamento, e os baseados em grafos, que criam grafos à partir do texto-fonte e fazem a análise baseada nos nós e arestas [1] [2].

Agora que a gente já sabe o que é extração e sabe que o grafo é o que será construído com base no texto para, com base nele, decidirmos que frase extrair, vamos olhar mais de perto como funciona essa tal de extração de sentenças baseada em grafos [3].

Textos como grafos

A primeira coisa a se fazer quando trabalhando com grafos é identificar quais unidades de texto iremos usar como os nós do grafo e quais as relações entre estas unidades usaremos para criar as arestas. As características dos nós e arestas serão diferentes de aplicação para aplicação, mas independente das características dos nós e arestas, a aplicação de grafos a textos de linguagem natural consiste, de modo geral, nos seguintes passos [5]:

  1. Identificar as unidades de texto (tokens) que melhor se encaixam no trabalho corrente e colocá-las como nós do grafo.

  2. Identificar as relações que conectam estas unidades de texto e criar arestas, baseadas nestas relações, entre os nós do grafo.

  3. Iterar o algorítimo escolhido para se pontuar os nós.

  4. Ordenar os nós de acordo com a pontuação final. Usar esta pontuação para classificação/extração.

Um grafo para extração de sentenças

Como o nosso objetivo aqui é criar um sumarizador por extração, nossa tarefa é determinar quais são as sentenças mais relevantes do texto e depois usá-las para criar o resumo. Sendo assim, usaremos sentenças inteiras como nós e as arestas entre estes nós serão construídas baseadas na semelhança entre as sentenças. Desta maneira será criado um grafo não-orientado relacionando as sentenças entre si. É importante ressaltar também que, pelo menos no contexto da linguagem natural, é interessante além de considerar-se a quantidade de relações, considerar-se também a “força” destas relações, atribuindo algum peso a elas [4].

A semelhança entre duas sentenças pode ser determinada pelo número de tokens que se repetem nestas frases. Para evitar-se dar notas mais altas a sentenças grandes, usa-se um fator de normalização que dividade a o número de repetições de token pelo tamanho das sentenças. Formalmente, dadas duas sentenças Si e Sj com uma sentença sendo representada pelo conjunto de Ni palavras que aparecem na sentença: Si = Wi1, Wi2… Win, a semelhança entre Si e Sj é definida como [4]:

Similaridate entre sentenças

Semelhança entre sentenças

Com estas informações que temos até agora já podemos implementar um código que cria um grafo baseado em um texto, ou seja, os passos 1 e 2, mas ainda nos falta escolher um algorítimo para dar uma pontuação aos nós e, por fim, extrair as frases mais relevantes. Para facilitar a nossa implementação, em princípio vamos usar um algorítimo bem simples para pontuar as sentenças [4].

Neste algorítimo simplificado, a pontuação dos nós será simplesmente a soma do peso das relações que este nó tem com os outros, ou seja, a pontuação dos nós e a soma do peso as arestas relacionadas à ele. Formalmente, sendo Rel(Si) o conjunto nós relacionados à sentença Si e wij sendo o peso da relação entre Si e Sj, a pontuação de de um nó do grafo é definida como:

Pontuação dos nós do grafo

Com isso, agora já temos também estabelecido como faremos o passo 3. O passo 4 é simples e não requer maiores explicações.

Implementando o sumarizador simplificado

Nosso grafo consistirá em nós sendo as sentenças de um texto e as arestas sendo a semelhança entre as sentenças. Para a implementação, usaremos a linguagem de programação Python [6] em conjunto com algumas bibliotecas. São elas: NLTK [7] e NetworkX [8].

Então, antes de começar, vamos relembrar o que devemos fazer: Primeiro, vamos decompor o texto em sentenças e as sentenças em palavras. Depois disso feito, colocaremos as sentenças como os nós do grafo e as arestas serão feitas baseadas na semelhança entre as frases. Com o grafo já criado, daremos uma pontuação para os nós e por fim extrairemos as sentenças de maior pontuação [5].

Agora, deixa de papo e vamos pro que importa: o código!

# -*- coding: utf-8 -*-

import math
import nltk
import networkx as nx


class Texto:

    def __init__(self, raw_text):
        """
        ``raw_text`` é o text puro a ser resumido.
        """

        self.raw_text = raw_text
        self._sentences = None
        self._graph = None


    def resumir(self):
        """
        Aqui a gente extrai as frases com maior pontuação.
        O tamanho do resumo será 20% do número de frases original
        """
        # aqui definindo a quantidade de frases
        qtd = int(len(self.sentences) * 0.2) or 1

        # ordenando as frases de acordo com a pontuação
        # e extraindo a quantidade desejada.
        sentencas = sorted(
            self.sentences, key=lambda s: s.pontuacao, reverse=True)[:qtd]

        # ordenando as sentenças de acordo com a ordem no texto
        # original.
        ordenadas = sorted(sentencas, key=lambda s: self.sentences.index(s))
        return ' '.join([s.raw_text for s in ordenadas])

    @property
    def sentences(self):
        """
        Quebra o texto em sentenças utilizando o sentence tokenizer
        padrão do nltk.
        """

        if self._sentences is not None:
            return self._sentences

        # nltk.sent_tokenize é quem divide o texto em sentenças.
        self._sentences = [Sentenca(self, s)
                           for s in nltk.sent_tokenize(self.raw_text)]

        return self._sentences

    @property
    def graph(self):
        """
        Aqui cria o grafo, colocando as sentenças como nós as arestas
        (com peso) são criadas com base na semelhança entre sentenças.
        """

        if self._graph is not None:
            return self._graph

        graph = nx.Graph()
        # Aqui é o primeiro passo descrito acima. Estamos criando os
        # nós com as unidades de texto relevantes, no nosso caso as
        # sentenças.
        for s in self.sentences:
            graph.add_node(s)

        # Aqui é o segundo passo. Criamos as arestas do grafo
        # baseadas nas relações entre as unidades de texto, no nosso caso
        # é a semelhança entre sentenças.
        for node in graph.nodes():
            for n in graph.nodes():
                if node == n:
                    continue

                semelhanca = self._calculate_similarity(node, n)
                if semelhanca:
                    graph.add_edge(node, n, weight=semelhanca)

        self._graph = graph
        return self._graph

    def _calculate_similarity(self, sentence1, sentence2):
        """
        Implementação da fórmula de semelhança entre duas sentenças.
        """
        w1, w2 = set(sentence1.palavras), set(sentence2.palavras)

        # Aqui a gente vê quantas palavras que estão nas frases se
        # repetem.
        repeticao = len(w1.intersection(w2))
        # Aqui a normalização.
        semelhanca = repeticao / (math.log(len(w1)) + math.log(len(w2)))

        return semelhanca


class Sentenca:

    def __init__(self, texto, raw_text):
        """
        O parâmetro ``texto`` é uma instância de Texto.
        ``raw_text`` é o texto puro da sentença.
        """

        self.texto = texto
        self.raw_text = raw_text
        self._palavras = None
        self._pontuacao = None

    @property
    def palavras(self):
        """
        Quebrando as sentenças em palavras. As palavras
        da sentença serão usadas para calcular a semelhança.
        """

        if self._palavras is not None:
            return self._palavras

        # nltk.word_tokenize é quem divide a sentenças em palavras.
        self._palavras = nltk.word_tokenize(self.raw_text)
        return self._palavras

    @property
    def pontuacao(self):
        """
        Implementação do algorítimo simplificado para pontuação
        dos nós do grafo.
        """
        if self._pontuacao is not None:
            return self._pontuacao

        # aqui a gente simplesmente soma o peso das arestas
        # relacionadas a este nó.
        pontuacao = 0.0

        for n in self.texto.graph.neighbors(self):
            pontuacao += self.texto.graph.get_edge_data(self, n)['weight']

        self._pontuacao = pontuacao
        return self._pontuacao

    def __hash__(self):
        """
        Esse hash aqui é pra funcionar como nó no grafo.
        Os nós do NetworkX tem que ser 'hasheáveis'
        """
        return hash(self.raw_text)

Para testar vamos resumir o seguinte texto, extraído do jornal Folha de São Paulo:

Dezenas de veículos foram incendiados em frente a sede do governo da região de Guerrero, no México, em um protesto pelo desaparecimento e morte de 43 estudantes da escola normal rural de Ayotzinapa.

Mais de 300 jovens, a maioria com o rosto coberto, atacaram a fachada do edifício em Chipancingo, capital de Guerrero.

O protesto ocorreu após o procurador-geral da República do país, Jesús Murillo Karam, informar que três homens suspeitos de ser integrantes do cartel Guerreros Unidos confessaram ter matado os estudantes e queimado seus corpos. O presidente Enrique Peña Nieto prometeu na sexta (7) punir todos os responsáveis pelos «crimes abomináveis».

Os jovens sumiram em 26 de setembro, depois de arrecadar fundos para a escola em Iguala (a 192 km da Cidade do México).

Na saída da cidade, dois ônibus que voltavam à instituição com os alunos foram alvejados por policiais e traficantes do Guerreros Unidos. O ataque deixou seis mortos.

Os confessores, identificados como Particio Reyes, Jonathan Osorio e Agustín García Reyes, dizem que receberam os 43 estudantes no lixão de Cocula, a 22 km de Iguala. Segundo os pistoleiros, 15 deles chegaram ao local mortos com sinais de asfixia.

Segundo a Procuradoria-Geral do México, os detidos não disseram quem levou os estudantes e quem era o mandante da emboscada.

O órgão, porém, acredita que o mandante foi o prefeito de Iguala, Jose Luis Abarca, preso na quarta (5). A intenção seria evitar que os alunos atrapalhassem um evento em que sua mulher, María de los Ángeles Pineda, seria lançada como candidata a sucedê-lo. A mulher de Abarca é irmã de três chefes do Guerreros Unidos.

FAMILIARES

Em entrevista, os parentes disseram não acreditar na versão do procurador-geral e pediram que o material recolhido seja analisado por peritos independentes.

Para eles, o governo quer fazer com que eles acreditem que seus filhos estão mortos. «Sequer mostraram fotos dos nossos filhos. Enquanto não houver provas, nossos filhos estão vivos», disse Felipe de la Cruz, pai de um dos alunos.

Os pais pediram ao governo que prossiga com as buscas e permita a assistência técnica da Comissão Interamericana de Direitos Humanos.

Agora, para usar o código, num shell de python, importe o módulo, crie uma instância da classe Texto e use o método resumir(), assim:

>>> import sumarizacao
>>> t = sumarizacao.Texto(txt)
>>> resumo = t.resumir()

Aqui a representação do grafo. A largura das arestas é baseada na força das relações entre as frases e o tamanho dos nós é baseado na pontuação destes e o número dentro dos nós é índice da sentença no texto.

Grafo com as semelhanças entre as sentenças.

Agora o resumo gerado pelo nosso código:

Dezenas de veículos foram incendiados em frente a sede do governo da região de Guerrero, no México, em um protesto pelo desaparecimento e morte de 43 estudantes da escola normal rural de Ayotzinapa. O protesto ocorreu após o procurador-geral da República do país, Jesús Murillo Karam, informar que três homens suspeitos de ser integrantes do cartel Guerreros Unidos confessaram ter matado os estudantes e queimado seus corpos. Segundo a Procuradoria-Geral do México, os detidos não disseram quem levou os estudantes e quem era o mandante da emboscada.

Finale

Este aqui é só um exemplo de como funciona a sumarização de texto usando grafo. Numa implementação pra valer seria melhor implementar o TextRank ou algum outro bom algorítimo, não este nosso aqui, como algorítimo de pontuação e utilizar algumas técnicas, como remoção de sufixos entre outras, para melhorar o desempenho do algorítimo. Além disso, em textos jornalísticos, temos que ter cuidado com as aspas [6] incluídas no texto, com entrevistas, com listas… Na verdade, numa implementação real há bastantes detalhes a serem levados em consideração. E tenho a impressão de que pra cada implementação, com um foco diferente, os detalhes de implementação serão diferentes também.

Mas, independentemente dos detalhes de implementação, a ideia geral de sumarização extrativa por grafos está aí. Crie um grafo com as unidades de texto que melhor representam o texto para a tarefa em questão, pontue os nós de acordo com o algorítimo escolhido e por fim extraia os nós mais bem pontuados e é isso. Molezinha, não?

Referências

[1] Martins, C.B.; Pardo, T.A.S.; Espina, A.P.; Rino, L.H.M. (2001) - Introdução à Sumarização Automática.

[2] Margarido, P.R.A.; Pardo, T.A.S.; Aluísio, S.M. (2008) - Sumarização Automática para Simplificação de Textos: Experimentos e Lições Aprendidas.

[3] Leite, D.S. & Rino, L.H.M (2006) - Uma comparação entre sistemas de sumarização automática extrativa.

[4] Mihalcea, R. (2004) - Graph-based Ranking Algorithms for Sentence Extraction, Applied to Text Summarization

[5] Mihalcea, R. & Tarau, P. (2004) – TextRank: Bringing Order into Texts

[6] Python Programming Language - https://www.python.org/

[7] Natural Language Toolkit - http://www.nltk.org/

[8] NetworkX - https://networkx.github.io/

Notas de rodapé


Anterior Próximo