Tutorial básico para publicar un módulo en Pypi

Normalmente el conocimiento base de todo desarrollador llega hasta la solución puntual de un problema, pero cuando este problema resulta siendo bastante común en proyectos propios, del equipo, empresa o ecosistema, entonces es un buen momento para crear una librería. En esta entrada vamos a dar un ejemplo de cómo crear un proyecto con ciertos criterios de calidad, pero al mismo tiempo básico.

En este tutorial vamos a crear una clase Snake en un módulo llamado animals, la cual va a estar documentada con Sphinx y con tests unitarios usando unittest, para ser publicada en pypi.

La clase Snake para este ejemplo va a ser

# snake.py

class Snake:
    def eat(self, animal):
        return 'Delicious!!!' if animal is 'mouse' else 'No thanks'

Estructura de archivos

A continuación vamos a mostrar la estructura propuesta, vamos a ir explicando la finalidad de los demás archivos.

 .
├ animals
|  ├ __init__.py
|  └ snake.py
├ setup.py
├ requirements.txt
├ README.md
└ .gitignore

En el módulo de animals está la clase en el archivo snake.py y un archivo para configurar el módulo, en donde se importan los archivos y clases a usar, como se muestra en el ejemplo

# __init__.py

from .snake import Snake

__name__ = 'animals'
__version__ = '0.0.0'
__all__ = ['Snake']

En el archivo requirements.txt se agregan las dependencias del proyecto, especificando versiones si es necesario, como por ejemplo

# requirements.txt

pandas==0.25.0
sklearn

Este archivo es tomado por el archivo de configuración del módulo, el cual es setup.py, para la configuración de dependencias. Este archivo puede ser de la siguiente forma

# setup.py

import setuptools
import animals

with open('README.md', 'r') as fh:
    long_description = fh.read()

setuptools.setup(
    name='animals',
    version=animals.__version__,
    author='Santa Claus',
    author_email='santa@claus.com',
    description='Please describe this',
    long_description=long_description,
    long_description_content_type='text/markdown',
    url='https://github.com/resuelve/animals',
    packages=setuptools.find_packages(exclude=['sphinx_docs', 'docs', 'tests']),
    python_requires='~=3.5',
    install_requires=[
        i.replace('\n', '')
        for i in open('requirements.txt', 'r').readlines()
    ],
    extras_require={
        'dev': ['setuptools', 'wheel', 'twine', 'Sphinx'],
    },
    classifiers=[
        'Development Status :: 3 - Alpha',
        'Intended Audience :: Developers',
        'Programming Language :: Python :: 3',
        'Programming Language :: Python :: 3.6',
        'Programming Language :: Python :: 3.7',
        'Topic :: Software Development',
        'License :: OSI Approved :: MIT License',
        'Operating System :: OS Independent',
    ],
)

Para terminar, el archivo .gitignore puede ser el mismo generado por github, solo recomendamos que al menos estén estas reglas

# .gitignore

*.egg-info/
build/
dist/
__pycache__/

Pruebas unitarias

Se agregan unas pruebas unitarias usando unittesting. La idea es que sean lo más concisas posibles. Este archivo para este caso va a estar dentro de la carpeta tests.

# test_snake.py

import unittest
from animals.snake import Snake

class TestSnake(unittest.TestCase):

    def test_eat(self):
        snake = Snake()
        self.assertEqual(snake.eat('mouse'), 'Delicious!!!')
        self.assertEqual(snake.eat('elephant'), 'No thanks')


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

La forma de revisar que estén pasando todos los tests es corriendo en consola, el cual solo funciona si los tests están ubicados dentro de la carpeta de tests y comienzan con la palabra test_ , como por ejemplo, test_snake.py

python3 -m unittest tests/test_*

Documentación

Para generar una página web con los comentarios en python nosotros usamos Sphinx. Un ejemplo de la forma de documentar usando la estructura de sphinx se puede ver en la siguiente forma

# snake.py

class Snake:
    ''' Esta es la clase snake, de tener unos parámetros de constructor, acá irían'''
    
    def eat(self, animal):
        ''' Toda serpiente necesita comer
        :param animal: Hoy que animal va a comer
        :type animal: str
        :return: Si la serpiente está dispuesta a comer o no
        :rtype: str
        '''
        return 'Delicious!!!' if animal is 'mouse' else 'No thanks'

Para esto se agrega una carpeta donde se guarde la estructura de los archivos a generar. En este punto la estructura de carpetas debería estar de la siguiente manera

.
├ animals
|  ├ __init__.py
|  └ snake.py
├ tests
|  └ test_snake.py
├ sphinx_docs
|  ├ conf.py
|  ├ index.rst
|  └ snake.rst
├ setup.py
├ requirements.txt
├ MANIFEST.in
├ README.md
└ .gitignore

En conf.py está la configuración y demás formas de revisar el código para generar el HTML, en index.rst está la entrada inicial del proyecto y cómo se va a ver representada y en snake.rst están las directivas de cómo leer el archivo de snake.py

# conf.py

# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# http://www.sphinx-doc.org/en/master/config

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))


# -- Project information -----------------------------------------------------

project = 'animals'
copyright = '2019, Resuelve'
author = 'Santa Claus'

# The full version, including alpha/beta/rc tags
release = '0.0.0'


# -- General configuration ---------------------------------------------------

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    'sphinx.ext.autodoc',
    'sphinx.ext.viewcode',
    'sphinx.ext.intersphinx',
    'sphinx.ext.autosummary',
]

# Include Python objects as they appear in source files
# Default: alphabetically ('alphabetical')
autodoc_member_order = 'bysource'
# Default flags used by autodoc directives
autodoc_default_flags = ['members', 'show-inheritance']
# This value contains a list of modules to be mocked up
autodoc_mock_imports = [
    # all the dependencies that you want to ignore
]
# Generate autodoc stubs with summaries from code
autosummary_generate = True

# Add any paths that contain templates here, relative to this directory.
# templates_path = ['_templates']

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []


# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# alabater theme opitons
html_theme_options = {
    'github_button': True,
    'github_type': 'star&v=2',
    'github_user': 'resuelve',
    'github_repo': 'silk-ml',
}

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named 'default.css' will overwrite the builtin 'default.css'.
html_static_path = ['_static']

# Sidebars configuration for alabaster theme

html_sidebars = {
    '**': [
        'about.html',
        'navigation.html',
        'searchbox.html',
    ]
}

# I don't like links to page reST sources
html_show_sourcelink = True

# Add Python version number to the default address to correctly reference
# the Python standard library
intersphinx_mapping = {'https://docs.python.org/3.7': None}
.. index.rst

*******
animals
*******

.. image:: https://img.shields.io/pypi/v/animals.svg
   :target: https://pypi.python.org/pypi/animals
   :alt: PyPI Version
.. image:: https://img.shields.io/pypi/pyversions/animals.svg
   :target: https://pypi.python.org/pypi/animals
   :alt: PyPI python Version

Simple Intelligent Learning Kit (SILK) for Machine learning

Welcome to silk_ml's documentation!
===================================

:Source code: `github.com project <https://github.com/resuelve/animals>`_
:Bug tracker: `github.com issues <https://github.com/resuelve/animals/issues>`_
:Generated: |today|
:License: MIT
:Version: |release|

Project Modules
===============

List of project's modules

.. toctree::
   :maxdepth: 2
   
   _autosummary/snake.rst

Indices and tables
==================

* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
.. snake.rst

snake
========

.. automodule:: animals.snake
   :members:

Generación de binarios

Primero se necesita instalar las herramientas necesarias para poder realizar la compilación del código

python3 -m pip install --upgrade setuptools wheel twine

Ya teniendo esto instalado, primero se compilan y crean los binarios usando el mismo archivo de configuración setup.py, y se suben a pypi usando twine. Vale destacar que primero es necesario haber creado una cuenta en pypi.

python3 setup.py sdist bdist_wheel
python3 -m twine upload dist/*


Descargar archivos csv

¿Qué es un archivo csv?

Un csv es un archivo de valores separados por comas. Se pueden usar con excel o con cualquier otro programa de hoja de cálculo. Se diferencian de otros tipos de archivo de hoja de cálculo en que no puede guardar ningún formato ni puede guardar las fórmulas.

Los archivos con extension csv tienen un comportamiento similar al que tienen los archivos png, jpg, pdf, el navegador los toma como archivos pequeños que puede mostrar en otra pestaña y no los descarga.

¿Cuál es el problema?

Los clientes cuando tratan de descargar un archivo en formato csv el navegador lo abre en una ventana nueva y no lo descarga.

Bien, un issue sencillo, nada complicado, ¿qué tan difícil es descargar un archivo csv?, y si, no es difícil descargar un csv, sin embargo, tuve que hacer una pequeña investigación antes de resolver este issue, y la quiero compartir con ustedes.

En vista de que el navegador no va a descargar el archivo, pero si muestra su contenido, lo primero hay que hacer es descargar el contenido del archivo que en nuestro caso es esta en AWS. Creamos una función que descargue el contenido usando fetch, a continuación con ayuda de la librería Downloadjs descargamos el archivo.

Downloadjs es una librería que permite descargar archivos en diferentes tipos de formatos. Recibe tres parámetros :

  • El archivo que se va a descargar.
  • El nombre del archivo y su extensión(archivo.csv)
  • El MIME type (Es una forma estandarizada de indicar la naturaleza y el formato de un documento) MDN

Este es el método rápido, sencillo y mágico para descargar un archivo csv.

Y así de fácil tienes tu archivo csv.

También puedes descargar un cvs de otra forma un poco mas compleja pero igual de efectiva.

De igual manera que antes descargamos el contenido del archivo con fetch,con new blobconcatenamos la información, le decimos el tipo de archivo que será, creamos una nueva url de la cual vamos a descargar el archivo csv. Creamos una etiqueta (<a></a>)y descargamos el archivo. Posteriormente eliminamos la url creada ya que el navegador no debe guardar la referencia al archivo.

Aqui puedes probar que funciona.

Para finalizar

Si usas el primer método te vas ahorrar código y también vas a poder descargar archivos jpg, png, pdf, txt, gif. De igual manera que con los .csv los puedes descargar muy fácil y rápido. Ten en cuanta que estas agregando una librería mas a tu código con lo que ello implica, aunque al final es una librería pequeña que hace algo puntual.

Si usas el segundo método es un poco mas de código, pero te estas evitando una librería mas en el código. Ahora si sabes que lo único que necesitas es descargar archivos .csv y no necesitas los otros formatos que mencioné anteriormente, pues para que agregar una librería con este método es suficiente.

¿Cuál es la mejor?

Depende de como estén trabajando, del equipo y de que librerías y/o frameworks estén utilizando.

Correcciones, mejoras, comentarios, siempre bien recibidos.

Saludos.

Tipos en JavaScript sin TypeScript/Flow

JavaScript no es un lenguaje que sea estrictamente tipado y muchos proyectos han escalado bastante bien sin usar tipado, pero la realidad es que a medida que crece un proyecto su complejidad aumenta y simplemente existen muchos detalles que ya no podemos tener en mente.

Los tipos nos ayudan a reducir esta complejidad de varias formas, algunas de estas son:

  • Evitar errores comunes, ya que al conocer los input/outputs o interfaces de los módulos que usamos nos ayuda a usarlos como se debería.
  • Documentación, Poder tener claro los tipos de datos que acepta o retorna un módulo sin tener que ir a buscar su definición es bastante útil, más cuando es un proyecto grande.
  • Soporte IDE, la integración y sugerencias de los IDE/Editores es bastante útil cuando programas.
  • Refactorizar, poder modificar partes de tu código sin tener regresiones o infiriendo donde se está usando lo que cambias resulta una de las mejores ventajas que dan los tipos. parámetros Estas son algunas de las razones por las cuales son una gran idea integrar tipos en tus proyectos, te ayudará en general al developer experience y poder prevenir errores antes de que el producto llegue a tus usuarios.

Si no usas una forma de definir tipos está bien, muchos proyectos funcionan sin esto y si no sientes tener el problema no los integres, ya que esto solo va a añadir otra capa de complejidad a tus desarrolladores.

JSDOC + TSC

Las dos alternativas más comunes al integrar tipos en JavaScript es usar Flow o TypeScript, ambas tienen props y contras, pero ambas van a añadir una capa de transpilación a tu código y es posible que integrarlo a tu flujo de desarrollo no resulte tan sencillo.

Toma tiempo y experiencia siendo eficiente con los tipos si tienes background en lenguajes tipados

Una alternativa es usar JSDoc que es una forma estándar de añadir documentación al código en JS, usandolo junto con TSC o TS (solo para chequeo de tipos) podemos tener las mismas ventajas de usar tipado.

Una de las razones por las cuales usar este enfoque es que no requires o require tener un paso más de transpilación, el código que escribes sigue siendo JS y no necesitas migrar o cambiar las herramientas que usas en desarrollo.

También si usas VSCode, este soporta JSDoc para poder usar intelliSense, permitiendo mejorar el autocompletado, información de parámetros, etc.

Configuración

Para activar esto en VSCode puedes hacerlo de dos formas:

La primera es activarlo por defecto en todos los archivos JS, en settings, agrega

"javascript.implicitProjectConfig.checkJs": true

La segunda es agregar en la raíz del proyecto un archivo jsconfig.json, con la siguiente configuración

{
"compilerOptions": {
"target": "es2017",
"allowSyntheticDefaultImports": true,
"jsx": "react",
"noEmit": true,
"strict": true,
"noImplicitThis": true,
}
},
"exclude": ["node_modules", "build"],
}

Puedes leer más en detalle todas las opciones de configuración aquí.

También podrías estar interesado en tener un paso de CI que te permita checar los tipos, para esto basta con añadir un script en el package.json con

"type-lint": "tsc --pretty",

Uno de los proyectos grandes que usan esta forma de revisión de tipos es webpack.

Tipos de librerías de terceros

Una vez inicies a utilizar tipos de esta forma o con TypeScript, vas a encontrar que tus dependencias necesitan ayuda para conocer sus tipos ya que no todas son publicadas con estos, para esto la comunidad de TS, tiene una gran herramienta

qué te ayuda a encontrar los tipos de tus dependencias.

¿Qué tipos puedo usar?

Los tipos básicos son

  • null
  • undefined
  • boolean
  • number
  • string
  • Array or []
  • Object or {}

Para definir el tipo de una variable puedes usar @type

/**
* @type {number}
*/
const age = 1

/**
* @type {string}
*/
const name = "yeison"

Es este caso sería innecesario ya que al asignar un numero a la variable, se infiere el tipo

Por ejemplo en arrays podemos definir el tipo de los elementos que contiene.

/**
* @type {Array<number>}
*/
const randomNumbers = []

Una alternativa es usar la sintaxis @type {number[]}

Con los objetos puedes definir que tipo va a tener cada propiedad.

/**
* @type {{age: number, name: string}}
*/
const person = {age: 1, name: 'yeison'}

Otra alternativa es definir cada propiedad en una línea independiente

/**
* @property {number} age
* @property {string} name
*/
const person = {age: 1, name: 'yeison'}

person.name = 1 // Te va a mostrar un error

Si una propiedad es opcional podemos declararlo usando [] al rededor del nombre de la propiedad.

/** 
* @typedef {Object} Options The Options to use in the function createUser.
* @property {string} firstName The user's first name.
* @property {string} lastName The user's last name.
* @property {number} [age] The user's age.
*/
/**
* @type {Options} opts
*/
const opts = { firstName: 'Joe', lastName: 'Bodoni' } // no va a mostrar error

Definir tipos personalizados

Podemos crear tipos personalizados, esto es una forma para crear tipos personalizados y podemos reutilizarlos.

Para declarar un tipo personalizado usamos @typedef

/**
* @typedef {{age: number, name: string}} Person
*/

/**
* @type {Person}
*/
const person = {age: 1, name: 'yeison'}

/**
*
* @param {Person} person
* @returns {string}
*/
const getUpperName = (person) => person.name.toUpperCase()

Métodos y funciones

Cuando declaramos funciones podemos definir que valores va a recibir y retornar una función, tenemos varias sintaxis que podríamos usar.

Sintaxis estándar de JSDoc

/**
*
* @param {number} a
* @param {number} b
* @returns {boolean}
*/
const gte = (a, b) => a > b

Sintaxis más parecida a TS

/**
* @type {function(number, number): boolean}
*/
const gte = (a, b) => a > b

Sintaxis parecida a Clojure

/**
* @type {(a: number, b: number) => boolean}
*/
const gte = (a, b) => a > b

Generic

Para poder usar valores genéricos podemos usar @template

/**
* @template T
* @param {T} i
* @return {T}
*/
function identity(i) {
return i
}

En este caso identity va a recibir cualquier tipo, pero el tipo que reciba es el que debe retornar.

Importar tipos

También podemos importar tipos entre archivos, de forma estandar JSDoc no permite hacer esto, pero VSCode permite utilizar import para poder lograr importar definiciones de tipos.

/**
* @typedef {import('moment').Moment} initialDate
*/

También puedes importarlos de archivos (no necesitas declarar ninguna clausula de export)

/**
* @typedef {import('../utils').File} File
*/

Intersecciones y uniones

Algo que podríamos querer hacer es extender una definición que ya tenemos, para esto podemos utilizar las intersecciones (&) que nos permite unir varios tipos en uno solo

/**
* @typedef {{name: string, cc: number, tel: number}} Person
* @type {Person & {addres: string}}
*/
const person = { name: 'yeison', cc: 1, tel: 12, addres: 'asd' }

O también podríamos necesitar que un tipo sea uno u otro, para esto podemos utilizar las uniones (|)

/**
* @type {{isValidCitizen: true, cc: number} | {isValidCitizen: false, ce: number}}
*/
const person = { isValidCitizen: true, cc: 1 }

React con JSDoc

Una vez ya sabemos utilizar las partes básicas de JSDOC, podemos aplicar estas en proyectos donde usamos React, en este caso para definir los tipos de los props y el estado de cada componente.

Si un componente que vamos a utilizar tiene definido sus tipos, cuando lo utilizamos el editor nos va a dar información de los props que espera y sus tipos.

import React from 'react'

/**
* @param {{name: string}} Props
*/
const Hello = ({ name }) => (
<h1>Hello {name}</h1>
)

<Hello name={1}> {/* muestra error */}

En los componentes que declaramos con clases, debemos definir sus props y el estado, para esto la clase esta extendiendo de Component, para escribir esto en JSDoc, debemos utilizar @extends

import React, { Component } from 'react'

/**
* @typedef {{ user: string, password: string }} State
* @typedef {{ login: (data: State) => Promise }} Props
* @extends {Component<Props, State>}
*/
class Login extends Component {
state = {
user: '',
password: ''
}

/**
* @param {React.ChangeEvent<HTMLInputElement>} ev
*/
handleChange = (ev) => {
const { value, id } = ev.target

this.setState({[id]: value})
}

/**
* @param {React.FormEvent} ev
*/
handleSubmit = (ev) => {
ev.preventDefault()

this.props.login(this.state)
}

render () {
return (
<form>
<fieldset>
<label htmlFor='user'>
Password
<input type='text' id='user' />
</label>
<label htmlFor='password'>
Password
<input type='password' id='password' />
</label>
</fieldset>
<input type='submit' value='Enviar'/>
</form>
)
}
}

Bonus

En muchos proyectos se usan alias, para poder realizar imports de modulos sin tener que escribir grandes rutas, el problema de esto es que tu editor no va a reconocer estas rutas, para solucionarlo podemos agregar la configuración de los alias en el jsconfig.json

{
"compilerOptions": {
"module": "es2015",
"esModuleInterop": true,
"moduleResolution": "node",
"baseUrl": "./src",
"paths": {
"utils": ["utils/"],
"api": ["api/"],
"actions/*": ["actions/*"],
"stores/*": ["stores/*"],
}
}
}

Palabras finales

Creo personalmente que usar JSDoc en un proyecto es una forma bastante buena y con menos fricción para poder mejorar el developer experiencie y tener las ventajas que ofrece usar tipos sin tener que migrar de una a usar TS/Flow.

Yeison Daza 🍉

Cómo limpiar logs en docker

Después de trabajar un rato con contenedores de docker para desarrollo, me he encontrado con el momento en que los logs, al iniciar el contenedor, cargan y cargan y cargan y parece que no tienen fin. Eso, sumado a que tienes una gran cantidad de logs por request, solo se vuelve más y más largo.

Preguntando a mis compañeros de trabajo, me encontré que la solución más común es simplemente borrar la máquina y volverla a crear. Borrar los logs manualmente había probado ser tedioso y en algunos eventos, hasta ha corrompido el contenedor.

No conforme con esto, (y que tenía el internet bastante limitado), no quería volver a descargar la imagen y volverla a instalar localmente. Así que estuve buscando alguna alternativa. Hasta que encontré esto:

Solo es una pequeña configuración que se pone en el docker-compose.yml justo dentro de la definición del servicio que estás usando para trabajar.

Esta definición va a realizar dos cosas con tus logs la próxima vez que levantes el servicio:

  1. Va a borrar los logs actuales
  2. Los nuevos logs van a estar limitados a 50mb

Efectivamente limpiando los logs todo tu contenedor y teniendo un poco más de paz mental.

Si eres como yo y no confías en lo que haga esta configuración en el servidor de producción (no sé, digamos que usas K8s y subes el docker). Te recomiendo que hagas lo siguiente:

  • Inserta en tu docker-compose.yml la definición
  • Levanta el contenedor y ve cómo ya no hay logs
  • Detén el contenedor
  • Regresa el docker-compose.yml a la normalidad
  • Levanta de nuevo el contenedor

Listo, ahora tienes un contenedor limpio de logs para trabajar y no afectarás los logs en ningún otro lado.

Espero les sirva este pequeño hack. Yo sé que a mí me sirvió xD

Zero out!

Facebook
Twitter
YouTube