domingo, 14 de febrero de 2021

Crear un paquete de Python y publicarlo en Azure DevOps con una GitHub action

En este post, me gustaría compartir una forma de crear un paquete de Python, publicarlo en un feed privado con una GitHub action y consumirlo en una aplicación cliente.

He dicho "una forma" porque hay otras muchas que igualmente serían válidas, basta con darse una vuela por https://www.pypa.io/en/latest/ y ver que el estado del arte de publicación de paquetes en Python da para un libro.

Estoy usando PowerShell, así que no te extrañes cuando veas cosas como cd .\directorio\, asumo que son todos comandos con fácil traducción al shell de turno.

Para crear el proyecto, lo único que hay que hacer es crear un entorno virtual dentro de la carpeta del proyecto e instalar las dependencias:

mkdir NameFake
cd .\NameFake\
pipenv shell
pipenv install requests

En este momento los únicos ficheros del directorio serán Pipfile y Pipfile.lock

No es necesario, pero será más fácil seguir la receta si creamos un directorio name_fake y dentro un fichero __init__.py vacío y un fichero generator.py con este código:

import requests

def get_name():
    response = requests.get('https://api.namefake.com/random/random/')
    return response.json()['name']

if __name__ == '__main__':
    name = get_name()
    print(name)

Para el tema del versionado de la librería, lo haremos creando el fichero name_fake\version.py

VERSION = (0, 0, 1)
__version__ = ".".join([str(x) for x in VERSION])

Y actualizaremos __init__.py con:

from .version import __version__

Podemos testear nuestro código con:

python .\name_fake\generator.py

Como queremos crear una librería (un paquete) y publicarlo en un repositorio, crearemos un fichero setup.py con el siguiente contenido (que he dejado al mínimo porque también da para una serie entera de posts)

from setuptools import setup, find_packages
import toml

from name_fake import __version__


def get_install_requires():
    data = toml.load("Pipfile")
    return [package + (version if version != "*" else "")
            for package, version in data["packages"].items()]

packages = find_packages()
install_requires = get_install_requires()

setup(
    name="NameFake-Generator",
    version=__version__,
    packages=packages,
    install_requires=install_requires
)

También necesitaremos instalar la dependencia de toml que estamos usando dentro de setup.py

pipenv install toml --dev

La estructura del proyecto ha quedado así:

tree /F
C:.
│   Pipfile
│   Pipfile.lock
│   setup.py
│
└───name_fake
        generator.py
        version.py
        __init__.py

Ahora ya podemos crear nuestro paquete con el siguiente comando y encontraremos en la carpeta dist el fichero NameFake_Generator-0.0.1-py3-none-any.whl

python setup.py clean --all bdist_wheel

Antes de subir a nuestro feed privado, podemos probar la librería creando un nuevo proyecto (y un nuevo entorno virtual) e instalando el paquete localmente:

cd ..
mkdir NameFakeClient
cd .\NameFakeClient\
pipenv shell
pipenv install ..\NameFake\dist\NameFake_Generator-0.0.1-py3-none-any.whl

Creamos un fichero main.py con este código:

from name_fake import generator

print(generator.get_name())

Y lo ejecutamos con:

python .\main.py

Ya tenemos un paquete wheel y hemos probado que funciona correctamente, ahora sí podemos publicarlo en nuestro feed privado, del que, por cierto, voy a asumir que ya está creado en Azure DevOps.

Para publicar el paquete necesitaremos un token de Azure DevOps con permisos de read/write en Packages.

Una vez lo tengamos podremos crear nuestro GitHub action con el siguiente contenido:

name: Upload Python Package

on: [workflow_dispatch]

jobs: 
 
  deploy:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.7.9'
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install setuptools wheel pipenv twine
        pipenv install --dev
    - name: Build
      run: |
        pipenv run python setup.py bdist_wheel
    - name: Upload package
      run: |
        twine upload dist/* --repository-url https://pkgs.dev.azure.com/<YOUR_ORGANIZATION>/<YOUR_TEAM_PROJECT>/_packaging/<YOUR_FEED>/pypi/upload/ --username ${{ secrets.PRIVATEFEED_USERNAME }} --password ${{ secrets.PRIVATEFEED_PASSWORD }} --skip-existing

A propósito de este workflow, cabe mencionar que asume se han creado secretos para el nombre de usuario y contraseña (nombre del token y token) y que el campo --repository-url requiere que cada uno ponga el suyo, claro. Además --skip-existing es útil para que el workflow no falle si intentamos publicar una versión que ya existe en el feed. Por último, en el paso de establecer la versión de Python, siéntete libre de poner la que creas oportuna.

Lógicamente, tanto el proyecto de la librería en local (probablemente con pre-commit) como el workflow deberían incluir pasos extrás como flake8, quizás mypy, pruebas automatizadas, etc.

En cualquier caso y teniendo ya nuestro paquete publicado en un feed de Azure DevOps, el último paso es configurar nuestra aplicación cliente para que pueda descargar paquetes no sólo de PyPI.org, sino también de nuestro feed privado.

Para ello, tendremos que crear un nuevo token con permisos read en Packaging y crear el fichero $env:USERPROFILE\pip\pip.ini con este contenido

[global]
extra-index-url=https://<YOUR_TOKEN_NAME>:<YOUR_TOKEN>@pkgs.dev.azure.com/<YOUR_ORGANIZATION>/<YOUR_TEAM_PROJECT>/_packaging/<YOUR_FEED>/pypi/simple/

Además, en el fichero Pipfile debemos agregar

[[source]]
url = "https://pkgs.dev.azure.com/<YOUR_ORGANIZATION>/<YOUR_TEAM_PROJECT>/_packaging/<YOUR_FEED>/pypi/simple/"
verify_ssl = true
name = "<YOUR_FEED>"

Fíjate que en el fichero pip.ini (y aunque es cierto que este fichero no está dentro del repositorio de código fuente) la autenticación se guarda en plano. Por otro lado, en Pipfile (que sí es parte del repositorio) no tenemos autenticación en la url (sería un problema mayúsculo subir secretos al repositorio). Si no usamos pipenv, podemos confiar en artifacts-keyring para que nos pida usuario y contraseña de forma interactiva al usar pip install, pero con pipenv no la pide, por eso guardamos el token en pip.ini.

Y con esto ya habríamos publicado una librearía de Python en un feed interno y la estaríamos consumiendo en nuestra apliciación cliente.

Un saludo!

viernes, 8 de enero de 2021

Exportar Wikis de Azure DevOps a Hugo

Evaluando Hugo como plataforma de blogging para un futuro, me topé con el tema Geekdoc y entonces me surgió la duda de si era una opción razonable usar Hugo como herramienta para la documentación interna.

Si el proyecto fuera Open-Source, la verdad es que optaría por usar GitHub Pages, pero siendo un repositorio privado toca pasar por caja y además y con independencia de si el repositorio es público o privado, las GitHub Pages son siempre públicas, luego opción descartada.

Por ahora (y lleva así mucho tiempo y no tengo ninguna pega), estamos usando las Wikis de Azure DevOps, las Wikis "aprovisionadas", que son distintas a públicar la wiki como código (ficheros .md directamente desde una carpeta del repositorio, al estilo de GitHub Pages), más información aquí.

Hacer una prueba de concepto con Hugo ha sido fácil, uno de los ganchos para usarlo de hecho, pero yo necesitaba verlo poblado de entradas, con documentación real en vez de lorem ipsum, así que migrar wikis "reales" de Azure DevOps a Hugo era la mejor opción para ver como lucía el tema de Geekdoc y si era usable.

Usando la API de Azure DevOps, ha sido relativamente fácil iterar dentro de una organización por los proyectos, wikis, páginas y descargar sus ficheros adjuntos. Por otro lado, aunque el Getting Started de Hugo va como la seda, donde encontré más problemas fue a la hora de entender como manejar los recursos asociados a una página, a este respecto, el siguiente enlace me sirvió y me pregunto porque no está incluido en la documentación oficial de Hugo.

El código (en Python, necesito practicar) está publicado aquí y he publicado un paquete en PyPI para que usarlo sea sencillo e indoloro.

pip install ado2hugo
set ORGANIZATION=YOUR_ORGANIZATION
set PATH=YOUR_PATH
ado2hugo YOUR_SIRE_DIRECTORY

Un saludo!

lunes, 7 de diciembre de 2020

Programación mínima, C# vs Python

Por puro divertimento, me ha dado por pensar cuál sería el programa mínimo que podría hacer para un compañer@ que me pide descargar una página web a un fichero.

Para esta prueba voy a comparar a C# con Python.

De Python se dice que es un lenguaje expresivo, es decir, que con una única línea de código Python se hace normalmente más que con otro lenguaje de programación (ni lo confirmo ni lo desmiento, yo creo que esta afirmación estás basada más en list comprehension o en como hacer swap de 2 variables).

Por ejemplo, si comparamos un swap de variables en Python y en C#, a priori ganaba Python, pero C# se ha puesto las pilas y empatan en número de líneas:

my_var = "my_var"
your_var = "your_var"
my_var, your_var = your_var, my_var  # this is expressive
print(my_var, your_var)  # your_var my_var
using System;

namespace ConsoleApp1
{
    class Program
    {
        static void Main(string[] args)
        {
            var myVar = "myVar";
            var yourVar = "yourVar";
            (myVar, yourVar) = (yourVar, myVar);  // This is expressive too
            Console.WriteLine($"{myVar} {yourVar}");  // yourVar myVar
        }
    }
}

Como el rival a batir es Python, veamos como haríamos la descarga de la página a un fichero:

from urllib import request
request.urlretrieve("https://www.google.es/", "google.html")

2 líneas de código es bien, menos código menos oportunidad de meter bugs.

Ahora en C#:

using System.IO;
using System.Net.Http;
using System.Threading.Tasks;

namespace ConsoleApp1
{
    class Program
    {
        static async Task Main(string[] args)
        {
            using (var httpClient = new HttpClient())
            {
                File.WriteAllText("google.html", await httpClient.GetStringAsync("https://www.google.es/"));
            }
        }
    }
}

Necesitamos hacer algo, esos malvados pythonistas nos están echando en cara que ellos en 2 líneas han hecho lo mismo que nosotros en 17. Es hora de sacar la artillería pesada, top level statements de C# 9 y .NET 5:

System.IO.File.WriteAllText("google.html", 
    await new System.Net.Http.HttpClient().GetStringAsync
    ("https://www.google.es/"));

Ahora el problema lo tiene Python, con C# lo hemos hecho en 1 línea (si no eres muy de C#, simplemente he partido la línea en varias para mejorar su legibilidad). En cualquier caso ¡menos líneas no se podían!

Habiendo escrito el mínimo código viable para llevar a cabo la tan magna tarea encomendada, todavía nos queda distribuir nuestra aplicación. Para Python vamos a usar pyinstaller y para C# directamente la CLI de dotnet.

Con Python ha sido necesario los siguientes pasos (dejando a un lado el tema de los entornos virtuales) y se ha generado un fichero .exe con un tamaño de 7,23 MB

pip install pyinstaller
pyinstaller --onefile main.py

Con C# y aprovechándonos del trimming, tenemos un fichero .exe con un tamaño de 4,33 MB (más pequeño que en Python)

dotnet publish --self-contained -c Release -r win-x64 -p:PublishTrimmed=True -p:TrimMode=Link -p:PublishSingleFile=true

Bueno, pues me quedo más tranquilo, C# está dando pasos de gigante, hemos sido capaces de escribir el mismo código de Python en menos líneas y con un menor tamaño del distribuible, queda claro que C# es un señor lenguaje y plataforma... y además también expresivo... y Python me gusta mucho, ¡ojo!, es sólo que necesitaba una cabeza de turco.

Un saludo!

domingo, 22 de noviembre de 2020

Series temporales en SQL

A propósito del post que ha escrito Jose A Bautista, Series temporales en SQL, este post es complementario (o casi idéntico y entonces se podría considerar un plagio) para terminar de entender cómo calcular las series temporales, o expresado en un lenguaje más llano, crear intervalos.

El problema al que nos enfrentamos (y resolvió, quiero insistir, Jose A Bautista de forma efectiva) era el siguiente:

Saber que productos han tenido stock pero no ventas, en un periodo determinado.

El problema del problema (un metaproblema por así decirlo) no es averiguar lo anterior, para eso no es necesario crear series temporales, sino trabajar con un gran volumen de datos y que la consulta siga ejecutándose en un tiempo razonable, por eso crear intervalos ofrece una solución porque, presumiblemente (y depende mucho de la dispersión de los datos), reducirá el conjunto de filas sobre el que se ejecuta la consulta (asumiendo también que crear esta tabla intermedia tiene un coste, tanto en tiempo como en almacenamiento).

El set de datos inicial es el siguiente:

CREATE TABLE [dbo].[Sales](
	[ProductId] [int] NOT NULL,
	[PointOfSaleId] [int] NOT NULL,
	[Date] [date] NOT NULL,
	[Quantity] [int] NOT NULL
) ON [PRIMARY]
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-01' AS Date), 1)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-02' AS Date), 2)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-03' AS Date), 3)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-10' AS Date), 10)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-11' AS Date), 11)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (1, 1, CAST(N'2020-01-20' AS Date), 20)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-01' AS Date), 1)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-02' AS Date), 2)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 1, CAST(N'2020-01-05' AS Date), 5)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-15' AS Date), 15)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-16' AS Date), 16)
GO
INSERT [dbo].[Sales] ([ProductId], [PointOfSaleId], [Date], [Quantity]) VALUES (2, 2, CAST(N'2020-01-25' AS Date), 25)

Finalmente tenemos los siguientes datos:

ProductId PointOfSaleId Date Quantity
1 1 01-01-2020 1
1 1 01-02-2020 2
1 1 01-03-2020 3
1 1 01-10-2020 10
1 1 01-11-2020 11
1 1 01-20-2020 20
2 1 01-01-2020 1
2 1 01-02-2020 2
2 1 01-05-2020 5
2 2 01-15-2020 15
2 2 01-16-2020 16
2 2 01-25-2020 25

El siguiente paso es entender la función ROW_NUMBER

-- Sin PARTITION, simplemente tenemos un contador
SELECT ROW_NUMBER() OVER (ORDER BY ProductId) AS rn, * FROM Sales
rn ProductId PointOfSaleId Date Quantity
1 1 1 01-01-2020 1
2 1 1 01-02-2020 2
3 1 1 01-03-2020 3
4 1 1 01-10-2020 10
5 1 1 01-11-2020 11
6 1 1 01-20-2020 20
7 2 1 01-01-2020 1
8 2 1 01-02-2020 2
9 2 1 01-05-2020 5
10 2 2 01-15-2020 15
11 2 2 01-16-2020 16
12 2 2 01-25-2020 25
-- PARTITION BY cambia, así que se reinicia el contador
    SELECT ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY ProductId) AS rn, * FROM Sales    
rn ProductId PointOfSaleId Date Quantity
1 1 1 01-01-2020 1
2 1 1 01-02-2020 2
3 1 1 01-03-2020 3
4 1 1 01-10-2020 10
5 1 1 01-11-2020 11
6 1 1 01-20-2020 20
1 2 1 01-01-2020 1
2 2 1 01-02-2020 2
3 2 1 01-05-2020 5
1 2 2 01-15-2020 15
2 2 2 01-16-2020 16
3 2 2 01-25-2020 25

Ahora que ya sabemos como funciona ROW_NUMBER, vamos a centranos en lo que queremos conseguir, el resultado final, los intervalos:

ProductId PointOfSaleId StartDate EndDate Quantity
1 1 01-01-2020 01-03-2020 6
1 1 01-10-2020 01-11-2020 21
1 1 01-20-2020 01-20-2020 20
2 1 01-01-2020 01-02-2020 3
2 1 01-05-2020 01-05-2020 5
2 2 01-15-2020 01-16-2020 31
2 2 01-25-2020 01-25-2020 25

Para llegar del conjunto inicial de datos al resultado final, la SQL necesaria es:

WITH sales_cte AS
    (SELECT ProductId,
            PointOfSaleId, 
            Date,
            SUM(Quantity) AS Quantity
     FROM Sales
     GROUP BY ProductId,
              PointOfSaleId,
              Date),
       groups_cte AS
    (SELECT ROW_NUMBER() OVER (ORDER BY date) AS row_number,
      DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date), Date) AS [group],
          ProductId,
          PointOfSaleId , 
          Date,
          Quantity
     FROM sales_cte)   
  SELECT ProductId,
         PointOfSaleId,
         MIN(Date) AS StartDate,
         MAX(Date) AS EndDate,
         SUM(Quantity) AS Quantity
  FROM groups_cte
  GROUP BY ProductId,
           PointOfSaleId,
           [group]  

La clave de la SQL está en -ROW_NUMBER(), vamos a ver el resultado intermedio y entender así que los días consecutivos están creando un grupo que podremos usar posteriormente:

SELECT 
	ROW_NUMBER() OVER (ORDER BY date) AS row_number,
	ProductId,
    PointOfSaleId,
	Date,
	Quantity,
	-ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date) AS negative_row_number,
    DATEADD(DAY, -ROW_NUMBER() OVER (PARTITION BY ProductId, PointOfSaleId ORDER BY Date), Date) AS [group]
INTO #groups
FROM (SELECT ProductId,
          PointOfSaleId, 
		  Date,
		  SUM(Quantity) AS Quantity
   FROM Sales   
   GROUP BY ProductId,
            PointOfSaleId,
            Sales.Date) as T;
SELECT * FROM #groups;
row_number ProductId PointOfSaleId Date Quantity negative_row_number group
1 1 1 01-01-2020 1 -1 12-31-2019
3 1 1 01-02-2020 2 -2 12-31-2019
5 1 1 01-03-2020 3 -3 12-31-2019
7 1 1 01-10-2020 10 -4 01-06-2020
8 1 1 01-11-2020 11 -5 01-06-2020
11 1 1 01-20-2020 20 -6 01-14-2020
2 2 1 01-01-2020 1 -1 12-31-2019
4 2 1 01-02-2020 2 -2 12-31-2019
6 2 1 01-05-2020 5 -3 01-02-2020
9 2 2 01-15-2020 15 -1 01-14-2020
10 2 2 01-16-2020 16 -2 01-14-2020
12 2 2 01-25-2020 25 -3 01-22-2020

Después de esto ya podemos agrupar por el grupo creado (la columna [group]) y usar funciones de agregado para sacar el resto de los datos:

SELECT ProductId,
       PointOfSaleId,
	   [group],
       MIN(Date) AS StartDate,
       MAX(Date) AS EndDate,
	   SUM(Quantity) AS Quantity
FROM #groups
GROUP BY ProductId,
         PointOfSaleId,
         [group]
ProductId PointOfSaleId group StartDate EndDate Quantity
1 1 12-31-2019 01-01-2020 01-03-2020 6
1 1 01-06-2020 01-10-2020 01-11-2020 21
1 1 01-14-2020 01-20-2020 01-20-2020 20
2 1 12-31-2019 01-01-2020 01-02-2020 3
2 1 01-02-2020 01-05-2020 01-05-2020 5
2 2 01-14-2020 01-15-2020 01-16-2020 31
2 2 01-22-2020 01-25-2020 01-25-2020 25

Por acabar con algún dato del mundo real, para el cliente en el que estamos haciendo algunas pruebas la reducción de filas ha sido la siguiente:

Original rows Intervals rows
Stock 560.089.291 12.236.634
Sales 22.981.825 16.794.972

Un saludo!

lunes, 30 de diciembre de 2019

Sitio estático multi-lenguaje con webpack

Recientemente, he tenido que configurar una plantilla de sitio web estático (digo plantilla porque supuestamente servirá como scaffolding a futuros proyectos, pero seguro quedará obsoleta en 2 días y habrá que modificarla, veremos a posteriori si sirvió a más de un proyecto). El sitio web no es una SPA, es una MPA (Multiple-Page Application) y, además, era un requisito indispensable la traducción a múltiples lenguajes y que fuera en tiempo de compilación, por SEO. Siendo así, y después de no encontrar nada en google ya hecho y que me gustara, pensé que sería fácil y rápido hacerlo con webpack, pero craso error, me ha llevado más tiempo de lo que suponía y he acabado (no sé si con razón o sin razón), buceando en exceso en la documentación, y probando con el método ensayo-error el resultado de la compilación. La conclusión que saco de todo esto es que en el front me siento como un conductor novel que está configurando un vehículo con un excesivo y apabullante número de extras disponibles. De hecho, y por casualidad, hace unas semanas contesté a esta encuesta https://stateofjs.com/ y viendo ahora los resultados más del 50% contestamos que sí, que "La creación de aplicaciones JavaScript es demasiado compleja en este momento" https://2019.stateofjs.com/opinions/#building_js_apps_overly_complex, aunque claro, más del 50% eramos full-stack https://2019.stateofjs.com/demographics/#jobTitle así que lo mismo el problema no es javascript, si no gente que le da a todo y así no se puede (yo me incluyo por si no ha quedado claro).

En este post, me quiero centrar en las decisiones que he tomado en relación con la MPA y a la traducción. Para el resto es más fácil y seguro leer la documentación de webpack.

El repositorio con la plantilla está en https://github.com/panicoenlaxbox/webpack-static-site-template

Si tenemos una MPA, tendremos varios entries y por cada uno de ellos podremos decidir que bundles queremos incluir, la idea está sacada de https://webpack.js.org/guides/entry-advanced/

entry: {
index: [
    "./src/index.js",
    "./src/styles/index.scss",
    "selectric/public/selectric.css"
],
about: ["./src/about.js", "./src/styles/about.scss"]
},
plugins: [
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "index.html"),
    template: "src/index.html",
    chunks: ["index", "vendor"]
}),
new HtmlWebpackPlugin({
    filename: path.join(translation.dist, "about.html"),
    template: "src/about.html",
    chunks: ["about", "vendor"]
}),

Fijarse que cada entry especifica los estilos, .scss por lo que no usa require como dependencia en el .js. Además, cada nueva página debería ir acompañada de una nueva entry y una nueva instancia de HtmlWebpackPluginhttps://github.com/jantimon/html-webpack-plugin#generating-multiple-html-files.

Por otro lado, ya ha aparecido el objeto translation. Aunque se usa i18n-webpack-plugin para traducir las claves de los ficheros .js, también hay claves de traducción en ficheros .html y ahí el reemplazo lo he resuelto con este otro plugin html-string-replace-webpack-plugin-webpack-4.

new HtmlStringReplace({
    patterns: [
    {
        match: /__(.+?)__/g,
        replacement: (match, $1) => translation.translation[$1]
    }
    ]
}),

Como que el sitio es estático, quería obtener en la raíz de dist/ la versión del lenguaje neutro y luego una carpeta / por cada lenguaje soportado. Para ello, he usado una compilación mútiple (la idea está sacada de https://survivejs.com/webpack/techniques/i18n/) y luego unas tareas extras para ajustarlo todo.

Que webpack devuelve una función en vez de un objeto, se puede ver en https://webpack.js.org/configuration/configuration-types/. Esto da mucho juego y abre distintas posibilidades.

module.exports = (env, argv) => {
  const isProduction = argv.mode === "production";
  return translations.map(translation => {
    return {
      entry: {

translations es un objeto que lee los mismos ficheros que usa … y agrega algunas propiedaes útil para la compilación.

[ { language: 'en',
    translation:
     { language: 'en',
       title: 'My static site template',
       message: 'My message' },
    default: true,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist' },
  { language: 'es',
    translation:
     { language: 'es',
       title: 'Mi plantilla de sitio estático',
       message: 'Mi mensaje' },
    default: false,
    dist: 'C:\\Temp\\webpack-static-site-template\\dist\\es' } ]

Ahora cada vez que webpack emite un bundle lo hace teniendo en cuenta el lenguaje:

output: {
  path: translation.dist,
  filename: `[name].${translation.language}${
      isProduction ? ".[contenthash]" : ""
  }.js`
}

Como estamos compilando lo mismo varias veces (con la única diferencia del lenguaje), acabaremos por tener en dist/ algo válido pero un poco feo y repetitivo, se puede mejorar haciendo algunos reemplazos (que por otro lado no me gusta hacer, es como hackear el sistema, de largo es lo que más oscuro me parece).

new HtmlStringReplace({
    patterns: [
        {
            match: /(<img src=")(?!(\/\/|https?:\/\/|data:image))/gi,
            replacement: (match, $1) => `${$1}/`
        }
    ]
}),
new HtmlStringReplace({
    enable: !translation.default,
    patterns: [
        {
            match: /(<link href=")(?!(\/\/|https?:\/\/))/gi,
            replacement: (match, $1) => `${$1}../`
        },
        {
            match: /(<script type="text\/javascript" src=".*?)(?=vendor\.)/gi,
            replacement: (match, $1) => {
            return $1.substring(0, $1.lastIndexOf('"') + 1) + "/";
            }
        }
    ]
}),

Y borrando por último, lo que no queremos en un hook del ciclo de compilación:

new EventHooksPlugin({
    done: () => {
        if (!translation.default) {
            exec(`rimraf \"dist/${translation.language}/!(*.html|*.js)\"`);
            exec(`rimraf \"dist/${translation.language}/vendor*.js"`);
        }
    }
})

Después de esto, acabaremos con una estructura como la siguiente:

│   about.css
│   about.en.js
│   about.html
│   index.css
│   index.en.js
│   index.html
│   vendor.css
│   vendor.js
└───es
        about.es.js
        about.html
        index.es.js
        index.html

Un saludo!

viernes, 6 de diciembre de 2019

High-order Observables (Angular) y ASP.NET Core

ReactiveX es una API para la programación asíncrona con Observables. Su implementación en Javascript es RxJS y en Angular (no sé en otros frameworks), se usa y se usa mucho, no puedes pasarlo por alto. Este post (al que luego volveré a hacer mención) empieza con un párrafo bastante lapidario "Like it or not, rxjs is a critical component of modern Angular development. Although it is perfectly possible to use Angular 2+ without using observables, you lose out on an enormous amount of functionality. The reactive pattern is extremely powerful, and once you get over the, admittedly rather high, learning curve the grass is definitely greener on the other side.". Pues así me siento yo, en mi opinión, Angular es un framework con una curva de aprendizaje medio-alta, si a eso le sumamos Redux, en su sabor Angular vía NgRx, la cosa se complica un poco/bastante más, pero todo tiene un denominador común... RxJS. De hecho, el lema de NgRx es "Reactive State for Angular", y Reactive es sinónimo de RxJS. Queda claro que RxJS debe ser importante.

En este sentido, hay un concepto de RxJS que me parece especialmente importante y son los high-order Observables. Normalmente, trabajamos con Observables que emiten valores de tipos básicos (strings, numbers, tipos de usuario, etc.), a estos se les llama first-order Observables, pero si un Observable emite, a su vez, Observables, estamos hablando de high-order Observables (es igual que cuando tenemos una función que recibe o devuelve otra función y hablamos de high-order functions, de ahí habrán sacado el nombre, digo yo).

Llegar a encontrarse en el código high-order Observables no es excepcional, bastaría un ejemplo sencillo como el siguiente, donde en la suscripción recibimos un Observable y no un number ¿Qué hago yo ahora? Yo quería recibir un number.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) => console.log(n));

// Observable { _isScalar: true, _subscribe: [Function], value: 1 }
// Observable { _isScalar: true, _subscribe: [Function], value: 4 }
// Observable { _isScalar: true, _subscribe: [Function], value: 9 }

Podemos resolverlo creando una suscripción anidada, pero es un anti-pattern. Lo es porque perdemos el control de cuando cancelar la suscripción anidada y además recuerda (sospechosamente) al famoso call-back hell. Si te encuentras dos susbcribe anidados, tienes un problema. Y no lo digo yo, lo dice Deborah Kurata, que sabe bastante más que yo.

import { Observable, of } from "rxjs";
import { map } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n))
  )
  .subscribe((n: Observable<number>) =>
    n.subscribe((i: number) => {
      console.log(i);
    })
  );

// 1
// 4
// 9

Y ¡ojo!, no vale hace trampas, aunque no tengamos dos subscribe juntos, hay otras formas más enrevesadas de tener nested suscriptions.

import { Observable, of } from "rxjs";
import { map, tap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    tap<Observable<number>>((o: Observable<number>) => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe((n: Observable<number>) => console.log(`outer ${n}`));

// inner 1
// outer [object Object]
// inner 4
// outer [object Object]
// inner 9
// outer [object Object]

He aprovechado este último ejemplo para introducir dos nuevas palabras que es importante tener en cuenta, source u outer Observable e inner Observable. El Observable al que nos suscribimos se llama outer Observable y, a los Observables que emite (por eso un high-order Observable) se les llama inner Observable.

También quiero aprovechar a hacer el disclaimer, de que en este post seré muy verbose con la firma de los métodos porque es una manía que tengo cuando estoy aprendiendo, es decir, lo anterior es equivalente a este otro código:

of(1, 2, 3)
  .pipe(
    map(n => of(n * n)),
    tap(o => {
      o.subscribe((i: number) => {
        console.log(`inner ${i}`);
      });
    })
  )
  .subscribe(n => console.log(`outer ${n}`));

Volviendo al problema original, ¿cómo trabajar entonces con high-order Observables? Pues hay que convertir un high-order Observable en un first-order Observable. Eso se hace con flattenig (aplastamiento, su traducción más o menos acertada al español). Estos operadores permitirán que consumamos los inner Observable como tipos básicos y, además, y esto es muy importante, gestionarán de forma automática la suscripción y cancelación al inner Observable.

Por ejemplo, con concatAll podemos resolver el problema inicial.

import { Observable, of } from "rxjs";
import { map, concatAll } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    map<number, Observable<number>>((n: number) => of(n * n)),
    concatAll<number>(),
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Y podemos hacerlo un poco mejor, si usamos el operador concatMap, que es la suma de map y concatAll (al igual que sucede por ejemplo en vanilla Javascript con map y flat, que se combinan en flatMap)

import { Observable, of } from "rxjs";
import { map, concatAll, concatMap } from "rxjs/operators";

of(1, 2, 3)
  .pipe(
    concatMap<number, number>((n: number) => of<number>(n * n))
  )
  .subscribe((n: number) => console.log(n));

// 1
// 4
// 9

Amigo de concatMap, tenemos también a switchMap (un clásico), mergeMap y exhaustMap.

Lo más importante de entender es que todos estos operadores responden a la misma pregunta ¿Qué hacer si el outer Observable vuelve a emitir y el inner Observable todavía está trabajando? Es decir, ¿Qué hacer si se solapan emisiones del outer Observable con el inner Observable? Si pasa el suficiente tiempo entre emisiones del outer Observable como para que la suscripción y cancelación del inner Observable ya haya acabado, podemos poner lo que queramos, da igual, se va a comportar de la misma forma. Sin embargo, si el inner Observable todavía esta trabajando ¿qué hacer con el trabajo actual del inner? Aquí es donde el post que mencionaba al principio http://alanpryorjr.com/2019-05-15-rxjs-flattening-operators/ me parece genial porque (sin código, importante y felicito por ello al autor) explica con una analogía de jefe-empleado como se comportará para operador de los mencionados en el hipotético caso de que un jefe (outer Observable) manda tareas (emite) a un empleado (flatennig operator) que todavía no ha acabado la anterior (inner Observable).

Si lo llevamos a un escenario más concreto, como llamar a una API si el usuario hace click en un botón (y asumiendo no hemos tenido a bien, deshabilitar el botón después de un click, que sería lo suyo), tenemos lo siguiente:

  • switchMap. Cancela petición en curso y vuelve a llamar a la API.
  • concatMap. Cuando acabe la petición en curso, hará otra llamada. Es una cola. Ademas, se respetará el orden y no llamada hasta no acabar con la anterior (esto es importante porque queremos garantizar que en el back se procesen en orden, que se hagan las peticiones desde cliente en orden no garantiza que en el back se procesen en el mismo orden, por eso se espera a que termine una petición para lanzar la siguiente).
  • mergeMap. A la vez que la petición en curso, se lanzará una nueva llamada, en paralelo.
  • exhaustMap. No hará nada si hay una petición en curso.

En este ejemplo https://stackblitz.com/edit/angular-nkgfyr está recogido lo anterior, y usando https://www.mocky.io/ podemos simular un delay para forzar a que el inner Observable esté trabajando cuando volvamos a hacer click en el botón. Es muy importante tener abierta la pestaña network de las developer tools para ver como se cancelan las peticiones en curso en función del operador elegido. En el ejemplo, se usan switchMapTo, concatMapTo y mergeMapTo, que son iguales a sus versiones sin "To", sólo que no necesitan un parámetro de entrada.

Llegando al final y, puesto que hemos cancelado peticiones, ¿Qué podemos hacer en el back para aprovecharnos de este comportamiento? Pues usar CancellationToken y propagarlo en todos los métodos asíncronos que lo permitan (por ejemplo, EF, Dapper, MediatR, etc.). Porque es muy bonito que el cliente cancele una petición (le honra), pero si en back seguimos procesando las peticiones, sólo uno estará haciendo lo correcto, el otro seguirá sin percatarse de que está trabajando para nada y que ya a nadie le importa el resultado (triste pero cierto).

En ASP.NET Core (y haciendo una API identica a la que hemos consumido anteriormente), simplemente añadiendo el parámetro CancellationToken al método de acción y pasándoselo a Task.Delay hará la magia de lanzar una excepción del tipo System.Threading.Tasks.TaskCanceledException si el cliente cancela la petición.

[HttpGet]
public async Task<ActionResult<IEnumerable<User>>> Get(CancellationToken cancellationToken, int delay = 0)
{
    await Task.Delay(delay, cancellationToken);
    return new[]
    {
            new User() {Id = 1, Name = "Sergio", Email = "panicoenlaxbox@gmail.com"},
            new User() {Id = 2, Name = "Carmen", Email = "panicoenel20@gmail.com"}
        };
}

Un saludo!

martes, 14 de mayo de 2019

Tooling en SQL

No es ningún secreto que la programación con SQL no dispone de un tooling a su altura. Si bien es cierto que tenemos una excelente herramienta como SSMS, por el contrario, si estás acostumbrado al desarrollo en C#, javascript o cualquier otro lenguaje, tarde o temprano querrás imitar el escenario al que estás acostumbrado, esto es, linting, guías de estilo de código, integración continua, etc.

Lógicamente, si usamos SQL sólo de forma esporádica o siempre embebido en C#, el asunto sería distinto, pero si te toca lidiar con un montón de procedimientos almacenados, funciones y demás objetos de SQL Server, en mi opinión se le empiezan a ver las costuras al flujo de desarrollo.

Con sinceridad, tampoco es que la solución propuesta aquí sea la panacea, pero es un intento de poner algo de orden y evitar que lo inevitable suceda. Además, personalmente descarto (por ahora) el uso de herramientas profesionales como redgate, ApexSQL o devart, pero por otro lado, son una incuestionable fuente de inspiración para intentar copiar o imitar (que suena mejor) algunas funcionalidades que pueden ser muy útiles.

El propósito del post será llegar a tener un linter, un formateador y un git hook, todo ello en el contexto de código T-SQL.

Para el linter (y habiendo descartado SonarQube, porque el analizador de T-SQL es de pago) nos quedan pocas opciones. Lo mejor que hemos encontrado es una extensión de VSCode llamada tsqlint. Dentro de VSCode funciona muy bien, aunque la única pega que le pongo es que el fichero de configuración donde activamos o desactivamos reglas (.tsqllintrc) tiene que estar en %USERPROFILE%, eso hace un poco más difícil el “clonar el repo y listo”, pero bueno, no habiendo más oferta, doy las gracias por esta herramienta.

Aunque tengamos el linter en VSCode, parece mejor opción instalar globalmente el paquete vía npm con npm install tsqllint -g. Ahora podemos crear el fichero .tsqllintrc con tsqllint --init. Y lo más importante, ahora podemos usar el linter desde línea de comandos (lo que abre la puerta a integración continua).

Para los ejemplos voy a usar pubs (que aunque me dicen es antigua, es sencilla y hasta un niño la entiende).

Probemos con una instrucción sencilla:

SELECT j.job_id,
    j.job_desc,
    e.emp_id,
    e.fname,
    e.lname,
    e.job_id,
    e.hire_date
FROM jobs j
INNER JOIN employee e ON j.job_id = e.job_id
ORDER BY j.job_desc,
    e.fname,
    e.lname

Y al pasar el linter

C:\Temp\test>tsqllint example.sql
example.sql(8,7): error schema-qualify : Object name not schema qualified.
example.sql(9,13): error schema-qualify : Object name not schema qualified.
example.sql(12,16): warning semicolon-termination : Statement not terminated with semicolon.

Linted 1 files in 0,2461205 seconds

2 Errors.
1 Warnings

tssqlint permite crear plugins de una forma sencilla y además usa C#, así que no hay excusa.

Nuestro plugin lo que hará es buscar la palabra clave UNION y luego ya darlo como warning, error o no reportarlo, según queramos, eso ya es configuración de cada uno.

El código del plugin (lo relevante) es este:

    public class MyTSqlLintUnionPlugin : IPlugin
    {
        public void PerformAction(IPluginContext context, IReporter reporter)
        {
            string line;
            var lineNumber = 0;

            var reader = new StreamReader(File.OpenRead(context.FilePath));

            while ((line = reader.ReadLine()) != null)
            {
                lineNumber++;
                var regex = new Regex(@"\s*UNION\s*", RegexOptions.IgnoreCase);
                var match = regex.Match(line);
                if (match.Success)
                {
                    var column = match.Index;
                    reporter.ReportViolation(new RuleViolation(
                        context.FilePath,
                        "union",
                        "UNION is forbidden",
                        lineNumber,
                        column,
                        RuleViolationSeverity.Warning));
                }
            }
        }
    }

Si ahora pasamos el linter a este SQL, ¡tenemos un UNION no permitido!

SELECT 1
UNION
SELECT 2;
C:\Temp\test>tsqllint sergio.sql
Loaded plugin: 'MyTSqlLintPlugin.MyTSqlLintUnionPlugin', Version: '1.0.0.0'
sergio.sql(2,0): warning union : UNION is forbidden.

Linted 1 files in 0,2557418 seconds

0 Errors.
1 Warnings

Visto esto, el único pero que le saco a la extensibilidad (o a mi implementación, mejor dicho) es el uso de expresiones regulares, lo mismo hoy son una solución, pero ya se sabe que mañana…

En cualquier caso, aceptamos barco, y además integrar tsqllint en SSMS también es posible como una herramienta externa.

Llegados aquí, ¡tenemos linter!, pero si queremos ir un paso más allá y en vez de trabajar con expresiones regulares, usar el parser que usa el propio SQL Server (para por ejemplo, dar un warning en un DELETE sin WHERE, que es una de las reglas de SonarQube, por cierto) podemos hacerlo con Microsoft.SqlServer.DacFx.x64.

var parser = new TSql140Parser(true);
            using (var reader = new StringReader(@"
SELECT * FROM authors;
DELETE jobs --WHERE min_lvl > 10
;"))
            {
                var result = parser.Parse(reader, out var errors) as TSqlScript;
                foreach (TSqlBatch batch in result.Batches)
                {
                    foreach (var statement in batch.Statements.OfType<DeleteStatement>())
                    {
                        if (statement.DeleteSpecification.WhereClause == null)
                        {
                            Console.WriteLine("¡DELETE sin WHERE, insensato!");
                        }
                        break;
                    }
                }
            }

Si probamos a habilitar o deshabilitar el comentario con la condición del DELETE vemos que funciona. Lógicamente, habría que hacer una herramienta de línea de comandos con un código de salida para poder usarla en la build, pero por ahora con saber que se puede hacer (y como un señor con un parser potente) parece suficiente y posibilita un futuro lleno de oportunidades.

En cuanto al formateo de SQL, es decir, una guía de estilo, tampoco hay mucho donde elegir. Si ves lo que tiene por ejemplo devart, https://sql-format.com/, es flipante, sin más, por eso viven de ello. Pero alternativas open-source no hay muchas, o al menos no hay muchas que admitan cierto grado de configuración. En nuestro caso, no hemos decantado por http://poorsql.com/, tiene muy buena pinta, pero la verdad es que el proyecto parece un poco abandonado. No obstante, es lo mejor que hemos encontrado y no parece que formatear SQL sea algo que esté cambiando todos los días.

Cabe mencionar que hemos descartado el formateo que hace el plugin oficial de SQL Server en VSCode, por eso, porque sólo lo hace en VSCode. Tiene competencia con este otro, pero ninguno es invocable por línea de comandos (o al menos yo no sé).

Con el tema del formateo tengo la sensación de que vamos a tener que contentarnos con lo que haya y no pedir peras al olmo.

Usando poorsql pasaríamos de esto (escrito por una persona con muy mala baba, la verdad sea dicha).

IF (1 =1    ) BEGIN
 SELECT j.job_id, j.job_desc,
  e.emp_id,  e.fname,
  e.lname,e.job_id,
  e.hire_date
 FROM jobs j  INNER 
 JOIN employee e ON 
 j.job_id = e.job_id
 ORDER BY j.job_desc,
    e.fname, e.lname END

a esto otro

IF (1 = 1)
BEGIN
 SELECT j.job_id,
  j.job_desc,
  e.emp_id,
  e.fname,
  e.lname,
  e.job_id,
  e.hire_date
 FROM jobs j
 INNER JOIN employee e ON j.job_id = e.job_id
 ORDER BY j.job_desc,
  e.fname,
  e.lname
END

Aunque podríamos integrarlo como comando en VSCode y en SSMS como herramienta externa, para finalmente poner todo en orden, usaremos node y los scripts de npm (que además sería equivalente a lo que sucedería en el servidor de integración continua).

Para el git hook de pre-commit vamos a usar husky.

"husky": {
    "hooks": {
      "pre-commit": "node index.js ./**/*.sql"
    }
  }

Lo que pasará ahora es que cada vez que hagamos un commit, se va a ejecutar el fichero index.js con un glob pattern para todos los ficheros .sql.

index.js es el encargado de ver en que ficheros .sql ha habido cambios y están en zona de staging, para entonces pasar el linter y el formateador a cada uno de ellos y si algo falla, abortar el commit.

const { execSync } = require("child_process");
const glob = require("glob");

// 0: C:\Program Files\nodejs\node.exe
// 1: C:\Temp\test\index.js
var args = process.argv.slice(2);
args.forEach(function(arg) {
  const files = getFiles(arg);
  files.forEach(function(file) {
    if (!formatFile(file)) {
      process.exit(1);
    }
    if (!lintFile(file)) {
      process.exit(1);
    }
    stageFile(file);
  });
});

function getFiles(pattern) {
  var files = glob.sync(pattern);
  var stagedFiles = getStagedFiles();
  return files.filter(file => stagedFiles.includes(file));
}

function formatFile(file) {
  console.log(`formatting ${file}`);
  return executeCommand(`npm run sqlformat -- -f ${file} -g ${file}`);
}

function lintFile(file) {
  console.log(`linting ${file}`);
  return executeCommand(`npm run tsqllint -- ${file}`);
}

function stageFile(file) {
  console.log(`adding ${file} to index`);
  return executeCommand(`git add ${file}`);
}

function executeCommand(command) {
  try {
    execSync(command);
    return true;
  } catch (error) {
    console.log(`stderr ${error.stdout.toString()}`);
    return false;
  }
}

function getStagedFiles() {
  var output = execSync(
    `git diff --cached --name-only --diff-filter=ADMR`
  ).toString();
  return output;
}

La verdad es que explicado por escrito parecen muchas operaciones a seguir, pero bien montado puede suponer una pequeña mejora en el flujo de trabajo con SQL (aquí tienes el código subido en github).En cualquier caso, me frustra que algo tan transversal como SQL no tenga (al menos yo no he encontrado nada) otros mecanismos para poder mirar de igual a igual a otras tecnologías en cuanto a tooling se refiere.