Skip to content

python

filtersql v1.2.6: il bug banale che ha rivelato un abisso architetturale

Quando ho pubblicato la versione 1.2.6 di filtersql ero convinto di aver raggiunto un buon livello di sicurezza. Poi, puntuale come un ordigno svizzero, è saltato fuori un nuovo bug.

Era la classica svista minuscola, la riga di codice che guardi e pensi di risolvere in pochi minuti.

Ma quando ho messo le mani nella correzione mi sono reso conto che la patch banale risolveva in effetti il bug, ma non il problema che si è rivelato essere più grande.

Il bug non era nella logica: era architetturale.


Premessa 1: La giungla dei nomi di colonna nei database reali

Vero che dipende dal DBMS, dalla versione e dalla configurazione, ma in linea generale i database consentono di definire i nomi delle colonne con grande ed eroica anarchia. Una colonna può contenere spazi, accenti, caratteri speciali e simboli Unicode. Si può addirittura quotare il carattere di quoting.

Ed in produzione in effetti non c'è limite all'ineleganza: da colonne come "qtà totale" o "n° ordine" che pur non essendo una idea grandiosa sono tutto sommato legittime, fino a casi limite ma inaspettatamente validi come "totale " (con due spazi in coda, o magari con tab, newlines, ecc.).

Per garantire una quotazione avanzata e sicura senza impazzire, ho scelto di applicare una semplificazione: rimuovere eventuali spazi bianchi accidentali agli estremi (trimming). La colonna "totale " veniva ripulita in "totale". Questo avrebbe ridotto la copertura dei casi più assurdi, ma i casi più dignitosi sarebbero rimasti salvi.


Premessa 2: L'incubo dei conflitti di scope (Multi-Tenancy)

filtersql gestisce il concetto di scope: una mappa di condizioni fisse (come tenant_id = 42) applicata lato server a tutte le operazioni (select, insert, update, delete). Non sostituisce davvero i permessi del database, ma offre comunque una sua utilità nel blindare le applicazioni web multi-tenant.

Ma cosa succede se un client invia un payload che prova a modificare proprio la colonna riservata allo scope?

A parte la rottura di un equilibrio estetico, nelle letture e nelle cancellazioni non si pone davvero un problema, dato che le condizioni finiscono tutte in AND e al massimo non esce e non si cancella nulla, ma in scrittura la faccenda si fa interessante:

Caso insert:

insert into users (tenant_id, tenant_id, name) values (400, 500, 'Mario');
A seconda del DBMS, questa istruzione può assegnare il primo valore (400), prendere l'ultimo (500), comportarsi in modo imprevedibile o fallire miseramente.

Caso update (molto peggio):

update users set tenant_id = 500 where tenant_id = 400;
Questa singola riga di codice trasferisce uno o più record dal proprio tenant a un altro. Ed a parte perdere i propri dati che spariscono dal controllo, un utente malevolo potrebbe sfruttare questo comportamento per spostare un account admin dal proprio tenant al tenant di un nemico per prenderne il controllo.

La soluzione logica: il problema sembrava risolvibile con banale controllo di collisione che ho implementato facilmente.

Se una colonna appartiene allo scope, deve essere vietata nei campi di insert o update.

Facile come bere una bottiglia d'acqua.


Il Bug della v1.2.6

Rileggi le due premesse, inserisci un minuscolo errore logico, ed ecco che l'ingranaggio salta.

Se lo scope protegge la colonna "tenant_id" e un client invia in scrittura la chiave "tenant_id " (con uno spazio in fondo), cosa succede?

  1. purtroppo il controllo dei conflitti confrontava immediatamente le due stringhe grezze: "tenant_id" contro "tenant_id ";
  2. le stringhe risultavano diverse ed il controllo dava il via libera;
  3. solo successivamente, la funzione di quoting sanificava l'identificatore applicando il trim().
  4. risultato: la query veniva compilata ed eseguita modificando proprio tenant_id!

Risolvere questo specifico passaggio non era poi complicato, bastava anticipare il trim() al momento giusto, prima del controllo di collisione. Il bug sarebbe stato risolto senza tanta fatica. Ma è stato lì che mi si è aperto l'abisso.


Il problema architetturale: Uguaglianza Stringa vs Equivalenza Semantica

Due stringhe identiche sono per forza uguali, il che non è particolarmente sorprendente.

Ma due stringhe diverse possono avere lo stesso identico significato logico per il database.

Cosa succede se il server imposta scope: {"tenant_id": 400} e il client invia un payload con values: {"users.tenant_id": 500}?

Un controllo basato su stringhe non rileva alcun conflitto ("tenant_id" != "users.tenant_id"). Il sistema genera allegramente questo codice SQL:

update users
set
  users.tenant_id = 500
where
  tenant_id = 400;

Mentre diversi motori, quali PostgreSQL o SQLite, trovavano questa sintassi indigesta e la rifiutavano giustamente con un errore, MySQL la accettava con grande entusiasmo, eseguendo lo spostamento temuto.

Ma non solo!

Cosa succede se le colonne sono case-insensitive? Cosa succede se a livello di database è definito qualche alias? Cosa sarebbe successo con le decomposizioni Unicode (NFC vs NFD), dove la lettera à può essere rappresentata da un singolo codice o da due codici separati (a + accento)?

Le stringhe in byte erano diverse, ma per il database puntavano alla stessa identica colonna.

Riconoscere tutte le possibili varianti semantiche che un utente o un database potevano inventarsi per identificare la stessa colonna era una corsa alle armi persa in partenza. Chi sa davvero se due nomi sono equivalenti è il motore del database; ma filtersql genera solo stringhe e non è, per filosofia, collegato al database.


La Svolta: L'Asimmetria Consapevole

Per uscirne vittorioso ho capito che non dovevo rendere più "intelligente" il confronto tra stringhe, ma dovevo cambiare il contratto all'ingresso.

Ho introdotto un'architettura asimmetrica:

  1. in lettura (select, where): Il sistema rimane flessibile. Può accettare identificatori qualificati (users.tenant_id) o percorsi JSONB (data->>key), perché quotati e sanificati separatamente.
  2. in scrittura (insert, update, delete): nuova regola: tutte le chiavi in values o id devono superare la validazione _validate_bare_identifier.

Un identificatore di scrittura valido dev'essere un bare identifier puro:

  • nessuno spazio iniziale o finale (il trim() non pulisce più il dato: se c'è uno spazio, la query viene rifiutata con InvalidIdentifierError).
  • nessun punto (vietata la qualificazione di tabella come users.tenant_id).
  • nessuna sintassi JSONB o carattere di controllo.
  • forma canonica Unicode NFC obbligatoria.

Normalizzando sia lo scope che i campi di scrittura in NFC e applicando casefold(), l'area di confronto per le collisioni si è ridotta a uno spazio unidimensionale in cui nessuna ambiguità semantica può più nascondersi.

Nota: nella 1.2.8 ho poi esteso la stessa regola anche a _quote(), così che l'incoerenza non possa ripresentarsi in nessun altro punto del codice.


E la Allowed_Columns?

Ci pensavo da tempo, ma avrei dovuto implementarla prima.

Il collision check risolve il caso in cui il client prova a scrivere su una colonna di scope. Ma non risolve il caso in cui il client prova a scrivere o leggere su colonne pericolose. Per quello serve una whitelist.

In un'applicazione reale (e a maggior ragione in una pipeline con gli LLM), lasciare che un client o un'IA interroghino o filtrino su qualsiasi colonna esista a DB senza filtro è un rischio enorme: rischi di esporre password_hash, credit_card_token o filtrare su stipendio_netto.

Il prossimo passo sarà quindi introdurre la possibilità di limitare le colonne coinvolte a quanto previsto nella allowed_columns.

In questo modo i ruoli rimangono ben distinti e puliti:

  • il developer definisce a mano la whitelist applicativa dei campi esposti per quello specifico endpoint o utente.

  • filtersql fa il lavoro sporco da compilatore: sanifica l'input, quota gli identificatori, applica lo scope multi-tenant senza possibilità di bypass e difende il database dai DoS.

Tutto questo sarà pubblicato in una delle prossime versioni.

Tanti saluti ed al prossimo bug!

filtersql.org — github.com/fthiella/filtersql

Appunti su Pandoc: stili personalizzati per Word

Brevi appunti pratici sull'integrazione tra Markdown, Pandoc e Microsoft Word tramite file di stile personalizzati.

Documenti Word

Generare un documento con gli stili di riferimento:

pandoc -o custom-reference.docx --print-default-data-file reference.docx

Si possono quindi modificare gli stili all'interno del file "custom-reference" con le normali modalità di Microsoft Word.

Introdurre nuovi stili:

<div custom-style="Super big">My super big text</div>
Normal text. <span custom-style="Highlighted text">This is highlighted</span>

(devono essere esistenti nel file custom-reference.docx)

Si può quindi compilare il documento con:

pandoc documento.md -o documento.docx --reference-doc=custom-reference.docx

Per quanto riguarda le tabelle non è purtroppo possibile modificare lo stile standard. Si può però effettuare un passaggio con python:

import docx
document = docx.Document('/tmp/xxx.docx')
for table in document.tables:
    table.style = document.styles['custom_style']
document.save("target.docx")

Daily coding problem #15: Random element from the stream

Problema:

Given a stream of elements too large to store in memory, pick a random element from the stream with uniform probability.

Scegliere un elemento casuale con probabilità uniforme da un array di dimensione n è semplice:

from random import randrange

foo = ['A', 'B', 'C', 'D']
n = len(foo)

print(foo[randrange(n)])

in realtà vi sarebbe anche il metodo random.choice() o lo stesso metodo della libreria secrets che garantisce sicurezza a livello crittografico.

Se lo stream però è troppo grande per stare in memoria, e non ne conosciamo la dimensione in anticipo, non possiamo applicare il metodo precedente ma dobbiamo utilizzare una diversa strategia. Necessariamente dovremo selezionare un elemento mentre stiamo leggendo lo stream, avendo come unica informazione il numero di elementi ricevuti fino a quel momento, ma non sapendo quanti ne mancano alla fine.

L'elemento corrente avrà probabilità $\(\frac{1}{n}\)$ di essere estratto, dove $\({n}\)$ è il numero di elementi letti dallo stream fino a quel momento. Potremmo selezionarlo in questo modo:

if random.randint(1,n) == n:
    chosen = current_element_from_stream

che cosa succede ad eventuali elementi letti in precedenza? Dobbiamo dimostrare come tutti gli elementi abbiano a loro volta la stessa probabilità $\(\frac{1}{n}\)$ di essere stati scelti.

L'elemento precedente lo abbiamo scelto con probabilità $\(\frac{1}{(n - 1)}\)$ il quale ha probabilità $\(\frac{(n - 1)}{n}\)$ di rimanere, da cui si ricava la sua probabilità che è $\(\frac{1}{(n-1)} \cdot \frac{(n-1)}{n} = \frac{1}{n}\)$.

Il codice completo può essere il seguente (dove lo stream è "simulato" da un array):

import random

def get_random_element(stream):
    chosen = None
    n = 0

    for i in range(len(stream)):
        n=n+1
        if random.randint(1,n) == n:
            chosen = stream[i]

    return chosen

stream = ['A', 'B', 'C', 'D']
print(get_random_element(stream))

Daily coding problem #28: Write an algorithm to justify text

Problem:

Write an algorithm to justify text. Given a sequence of words and an integer line length k, return a list of strings which represents each line, fully justified.

More specifically, you should have as many words as possible in each line. There should be at least one space between each word. Pad extra spaces when necessary so that each line has exactly length k. Spaces should be distributed as equally as possible, with the extra spaces, if any, distributed starting from the left.

If you can only fit one word on a line, then you should pad the right-hand side with spaces.

Each word is guaranteed not to be longer than k.

For example, given the list of words ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"] and k = 16, you should return the following:

["the quick brown", # 1 extra space on the left

"fox jumps over", # 2 extra spaces distributed evenly

"the lazy dog"] # 4 extra spaces distributed evenly

This looks like a problem that can be quickly solved in a few lines of codes, but it would actually require a little more lines than expected. While the case of a word longer than the given integer k is not specifically covered in the problem description, I assume than such word has to be split into k sized chunks.

The logic is going to be like this:

  • I keep a buffer that holds all words that can fit into a single line. This buffer is empty at first.
  • From the given array I read each word, one by one. I eventually split each word into k-sized chunks.
  • Is the current word / word chunk going to fit into the current line? If there's still space for it I am going to add it to the current line buffer.
  • If the current word / chunk won't fit, I am going to output the current line (all words in the buffer, with proper space justification). The buffer will then be set to contain only the current word.
  • When I am finished reading all words, I am going to output the current line (unless the buffer is empty, which will happen only if the array of words is empty as well)

This is how I am going to call the function:

if __name__ == '__main__':
    print("\n".join(justify(["the", "quick", "brown", "fox",
        "jumps", "over", "the", "lazy", "dog"], 16)))

And this is how my justify function looks like:

def justify(words, k):
    res = []
    current_length = 0
    current_words = []

    for word in words:
        # read each word
        for i in range(0, len(word), k):
            # split each row into k-sized chuncks
            chunk = word[i:i+k]

            # try to add a new word to current_words / current_length
            if len(chunk) + current_length + len(current_words) <= k:
                # can fit
                current_words.append(chunk)
                current_length += len(chunk)
            else:
                # won't fit
                res.append(justify_line(current_words, current_length, k))
                current_words = [chunk]
                current_length = len(chunk)

    if current_words:
        res.append(justify_line(current_words, current_length, k))

    return res

The final part is handled by the justify_line function, that accept as input the array w of words that fits in the current line, the length l of the current words (not considering spaces), and the width of the like k.

(l is not strictly necessary, I have all elements to calculate it inside the function, but since it's alreay available I'll just pass it to the function)

def justify_line(w, l, k):
    if len(w)==1:
        return w[0].ljust(k)
    else:
        narrow_spaces, wider_words = divmod(k - l, len(w)-1)
        ns = " " * narrow_spaces
        ws = " " * (narrow_spaces + 1)

        return ns.join([ws.join(w[0:wider_words+1]), *w[wider_words+1:]])
  • If the current line contains only one word then I'll just return this word left-justified with spaces at the right (as from the problem specs).

  • If instead the line is composed by more words, we have to calculate:

  • the total number of spaces needed (k - l)
  • the minimum number of spaces between each word (total numer of spaces DIV number of words minus one)
  • the number of words that require an additional space (total number of spaces MOD number of words minus one)

For example if I call justify_line(['the', 'quick', 'brown'], 13, 16): - narrow_spaces will be set to 1 (one space between each word) - wider_words will be set to 1 (the first word needs one more extra space after it) - ws.join(w[0:wide_words+1]) will join the first and second words with wider spaces - ns.join() will then join the wider-spaced-words with all remaining words

To make things easier during developement, and to make sure that a little fix won't mess up everything, I wrote this testing unit:

import unittest
import re
from justify_text import justify

class TestStringMethods(unittest.TestCase):

    def test_given(self):
        self.assertEqual(
            justify(
                ["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], 16),
                ["the  quick brown",
                 "fox  jumps  over",
                 "the   lazy   dog"])

    def test_lengths(self):
        for i in range(1,80):
            j = justify(["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], i)
            lengths = list(map(lambda x: len(x), j))
            for n in range(0, len(lengths)):
                self.assertEqual(lengths[n], i)

    def test_decrease(self):
        for i in range(1,80):
            j = justify(["the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"], i)

            spaces_split = list(map(lambda x: re.split('[^ ]+', x), j))
            for n in range(0, len(spaces_split)):
                self.assertEqual(spaces_split[n][0], '')
                for t in range(1, len(spaces_split[n])-2):
                    self.assertTrue(len(spaces_split[n][t])>=len(spaces_split[n][t+1]))

if __name__ == '__main__':
    unittest.main()
  • test_given: the result of the function has to match the result from the problem specification
  • test_lengths: all strings have to be of length k (k from 1 to 80)
  • test_decrease: all strings have to start with a word, not a space, and the number of spaces from left to right has not to increase

Process Killer

Quando un programma va in crash la colpa viene attribuita sempre al programmatore. Mentre quando un programma si blocca, o è troppo lento, è di certo per qualche misfatto dell'amministratore di sistema. Anche se magari il programma è scritto con i piedi ed è propenso all'inchiappettamento di tutti i server su cui gira.

Per ristabilire un po' di giustizia divina, ho pensato di killare senza pietà tutti i processi che utilizzano ferocemente la CPU per più di un determinato tempo. Metodo barbaro, ma molto in voga negli Unix dei vecchi tempi... e non solo.

Naturalmente nel mio caso si tratta di processi che dovrebbero essere molto leggeri ma per qualche strana ragione si incastrano e finiscono per utilizzare il processore a pieno regime, infastidendo anche tutti gli utenti di programmi buoni e gentili.

Come punto di partenza, direi di dare un'occhiata all'output del comando pslist , che lista tutti i processi in esecuzione su di una macchina (locale o remota, a patto di avere i privilegi necessari):

pslist v1.29 - Sysinternals PsList
Copyright (C) 2000-2009 Mark Russinovich
Sysinternals

Process information for ER-MEJO:

Name                Pid Pri Thd  Hnd   Priv        CPU Time    Elapsed Time 
Idle                  0   0   2    0      0    23:05:50.623     0:00:00.000
System                4   8 114 4338     52     0:11:32.114    28:42:03.487
smss                252  11   3   30    340     0:00:00.171    28:42:03.487
csrss               348  13   9 1033   2744     0:00:06.645    28:41:51.132
wininit             416  13   3   79    992     0:00:00.093    28:41:50.414
explorer           4448   8  36 1252  69768     0:07:39.797    28:41:04.882
iexplore           3772   8  13  382   7172     0:00:04.539     3:14:05.766
iexplore           5788   8  22  704  97632     0:05:51.345     3:14:02.648
PsList             4136  13   1  151   1980     0:00:00.187     0:00:00.172

le cui colonne interessanti sono le prime due (nome del process e PID) e le ultime due:

Elapsed Time: tempo totale di esecuzione del processo
CPU Time: tempo effettivamente utilizzato dal processo

ad esempio explorer è in esecuzione da 28 ore e 41 minuti, e durante questo periodo ha utilizzato la cpu per 7 minuti e 39 secondi. Proviamo a rilanciare il psexec e a vedere che cosa succede:

Name                Pid Pri Thd  Hnd   Priv        CPU Time    Elapsed Time 
Idle                  0   0   2    0      0    23:06:42.540     0:00:00.000
System                4   8 114 4342     52     0:11:32.426    28:42:33.493
smss                252  11   3   30    340     0:00:00.171    28:42:33.493
csrss               348  13   9 1050   2744     0:00:06.645    28:42:21.138
wininit             416  13   3   79    992     0:00:00.093    28:42:20.420
explorer           4448   8  42 1295  71484     0:07:40.234    28:41:34.888
iexplore           3772   8  13  389   7600     0:00:04.617     3:14:35.772
iexplore           5788   8  23  716  95520     0:05:53.513     3:14:32.654
PsList             1792  13   1  151   1988     0:00:00.171     0:00:00.156

il comando è stato lanciato circa 30 secondi dopo il primo, ed infatti tutto gli Elapsed Time sono aumentati circa di 30 secondi, processo PsList escluso ma se osserviamo bene si tratta di un nuovo processo con un nuovo PID.

Anche la colonna CPU Time è variata per alcuni processi, e se dividiamo il delta del CPU Time per il delta dell'Elapsed Time possiamo ottenere... la percentuale di utilizzo del processore di questo processo relativamente la periodo di tempo intercorso tra i due lanci del comando psexec.

Da notare che se la macchina dispone di più processori nel Task Manager di Windows vedremo la percentuale riferita a tutte le CPU: un processo che gira su di un singolo processore non potrà quindi superare il 25% di CPU se la macchina dispone di 4 processori.

Con il comando psexec invece non ci dobbiamo preoccupare del numero di processori che ha la macchina: il tempo di CPU utilizzato diviso il tempo di CPU avuto è disposizione può variare da zero a uno ed è l'utilizzo effettivo del signolo processore.

Ho scritto questo codice in Python che controlla ad intervalli regolari un elenco di server, e che giustizia tutti i processi che durante questo periodo di tempo hanno consumato più del 90% della propria CPU:

from __future__ import division
import subprocess
import re
import time

word = dict()
pausa = 30
servers = ('citrix01', 'citrix02', 'citrix03', 'server', 'server-2')

def milliSeconds(time):
  tm = re.match("(\d*)\:(\d*)\:(\d*)\.(\d*)", time)
  if tm:
    return long(tm.group(1)) * 60*60*1000 + long(tm.group(2)) * 60*1000 + long(tm.group(3)) * 1000 + long(tm.group(4))
  else:
    return 0

def getProcess(server, nome):
  pslist = subprocess.Popen('"c:\program files\utils\pslist.exe" \\\\' + server + ' ' + nome, shell=False, stdout=subprocess.PIPE)

  for line in pslist.stdout:
    proc = re.match("^(\w+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+([\w\:\.]+)\s+([\w\:\.]+)", line)
    if proc:
      serverPid = server + '.' + proc.group(2)
      if serverPid in word:
        if (milliSeconds(proc.group(7))-milliSeconds(word[serverPid][1]))/(milliSeconds(proc.group(8))-milliSeconds(word[serverPid][2]))>0.90:
          subprocess.Popen('"c:\program files\utils\pskill.exe" \\\\' + server + ' ' + word[serverPid][4])
      else:
        if proc.group(1) <> "Idle" and proc.group(1) <> "System":
          word[serverPid] = [proc.group(1), proc.group(7), proc.group(8), server, proc.group(2)]

def main():
  while True:
    for server in servers:
      getProcess(server, 'winword')
      time.sleep(pausa)

if __name__ == "__main__":
    main()

da sistemare per bene... e da usare con opportuna cautela!

Inviare Check Passivi a Nagios

Nagios è un programma di monitoraggio di computer o risorse di rete che permette di inviare degli avvisi quando un nodo od un servizio non risulta attivo, oppure al ripristino del suo funzionamento.

Per il monitoraggio degli apparati è possibile utilizzare uno dei numerosi comandi predefiniti (es. ping, snmp, etc) oppure è possibile scrivere un comando personalizzato - uno script bash, perl, python o altro va benissimo, l'importante è che tale script restituisca un valore che indica se il servizio che stiamo monitorando è attivo oppure se presenta qualche problema.

Il sistema si occupa in automatico di schedulare i test, di raccogliere i risultati, e di inviare eventuali avvisi.

Oltre a questa modalità di monitoraggio in cui il Nagios effettua dei test ed attende la risposta dagli host remoti, che è detta attiva , è possibile utilizzare una seconda modalità, detta passiva , in cui sono gli host o i servizi remoti che inviano il loro stato al Nagios.

L'utilizzo più tipico consiste nel monitoraggio delle operazioni pianificate, ad esempio backup, allineamento tabelle, etc.
Un task viene eseguito, svolge il backup, l'allineamento, o quant'altro, ed al termine invia al Nagios una notifica con l'esito dell'operazione. Se il Nagios non riceve tale esito entro un certo periodo di tempo (perché il task si blocca prima di inviare l'avviso, o perché il task dura troppo a lungo) può segnalare comunque di non aver ricevuto alcuna informazione e attivare un allarme.

Il servizio può essere definito in questo modo (i parametri possono essere personalizzati a piacere):

define service{
  use                    service-template
  host_name              myhost
  service_description    logs
  freshness_threshold    93600
  notification_period    24x7
  check_command          check_dummy!3 "Dati non ricevuti"
  active_checks_enabled  0
  passive_checks_enabled 1
  notification_interval  0
  check_freshness        1
  check_period           24x7
  max_check_attempts     1
  contact_groups         contatti
  notification_options   c,u,w,r
  notification_interval  0
  notification_period    24x7
}

In cui il comando check_dummy è definito nel seguente modo:

define command {
  command_name    check_dummy
  command_line    $USER1$/check_dummy $ARG1$
}

in questo esempio, Nagios attende il ricevimento di una notifica passiva, e se non riceve una notifica da più di 93600 secondi, allora esegue il comando check_dummy , che imposta lo stato del servizio ad UNKNOWN ed inserisce l'avviso che non riceve i log. Un amministratore di sistema si dovrà dunque preoccupare di approfondire il problema.

Come inviare i dati al server Nagios centrale? Esistono molti sistemi, ma il più semplice ed il mio preferito consiste nell'inviare l'esito dell'operazione direttamente al cmd.cgi che lo mette in coda agli altri eventi e lo fa processare.

Basta inviare un semplice POST HTTP al cgi che si trova in cgi-bin/cmd.cgi e siamo a posto! Come al solito il codice seguente è decisamente semplificato, e non tiene conto di ogni possibile errore o circostanza. In alternativa, è sempre possibile lanciare un buon wget --post="parametri" http://nagios/nagios/cgi-bin/cmd.cgi.

import httplib
import urllib
import sys

argc = len(sys.argv)

if (argc < 6):
  print "Nagios Passive Check Submit"
  print "Esempio:"
  print "nagiospcs Server \"Trasferimento Dati\" 3 \"Errore Import\" \"\""
else:
  data = urllib.urlencode(
    {"cmd_typ" : "30",
     "cmd_mod" : "2",
     "host" : sys.argv[1],
     "service" : sys.argv[2],
     "plugin_state" : sys.argv[3],
     "plugin_output" : sys.argv[4],
     "performance_data" : sys.argv[5]})
  f = urllib.urlopen(
    "http://user:password@servernagios/nagios/cgi-bin/cmd.cgi",
    data)
  s = f.read()
  f.close()

Ave.

Last.fm API e Python

Il mio primo programma Python non è certamente un esempio da inserire in un manuale di stile, ma non era nemmeno quella l'intenzione; ci tenevo a scrivere qualcosa che fosse utile e che avesse risultati pratici fin da subito.
Con piccole variazioni al codice ho potuto individuare i brani doppi, ho potuto correggere alcuni titoli errati, ho potuto individuare gli artisti che comparivano "sdoppiati" a causa del tag artistsortorder mancante.
Ed ora, che cosa resta?
Ho pensato che sarebbe stato interessante sistemare anche il genere musicale...

Last.fm

Last.fm è un social network di utenti accomunati dalla passione per la musica. Una caratteristica che lo rende interessante è la funzione di scrobbling: di ogni utente viene creato un profilo dettagliato che comprende tutti i brani ascoltati tramite l'apposita radio oppure tramite un plugin installato nel proprio player.
Gli utenti inoltre possono aggiungere dei "tag", ovvero delle etichette, agli artisti, agli album, alle canzoni che ascoltano. Da questi tag è possibile ottenere molte informazioni, tra cui il genere musicale di un artista. Ma come si possono estrarre queste informazioni?

Last.fm API

Fortunatamente Last.fm mette a disposizione dei Web Services attraverso i quali è possibile estrarre tutte le informazioni presenti sul sito. Dato per scontato che non è sempre precisa (il "tag" è un'etichetta generica, che non sempre coincide col genere musicale), la funzione artist.getTopOfTags sembra proprio avvicinarsi il più possibile a quanto stavo cercando.

Il codice

import sys
import win32com.client
import httplib
import urllib
import xml.dom.minidom

dic = dict()

def artistGetTopOfTags(artista):
  artist_url = u"http://ws.audioscrobbler.com/2.0/?method=artist.gettoptags&artist=%s&api_key=YOUR_KEY"
  url = artist_url % urllib.quote(artista.encode('utf8'))
  dom=xml.dom.minidom.parse(urllib.urlopen(url))
  genre=""
  for tag in dom.getElementsByTagName('tag'):
    name = ""
    count = ""
    for child in tag.childNodes:
      if child.nodeName == "name":
        name = child.firstChild.nodeValue
      if child.nodeName == "count":
     count = child.firstChild.nodeValue
    if count=="100":
      genre = name
  return genre.title()

def genere(artista):
  if not artista in dic:
    dic[artista]=artistGetTopOfTags(artista)
    print artista
  return dic[artista]

def main(*args):
  itunes = win32com.client.Dispatch("iTunes.Application")
  mainLibrary = itunes.LibraryPlaylist
  tracks = mainLibrary.Tracks
  numTracks = tracks.Count
  n = 1;

  while n <= numTracks:
    currTrack = tracks.Item(n)
    if genere(currTrack.Artist) != "":
      try:
        currTrack.Genre = genere(currTrack.Artist)
      except:
        print "eccezione!"
    n+=1

if __name__ == '__main__':
  sys.exit(main(*sys.argv))

va bene, le eccezioni dovranno essere gestite meglio, e va bene, il genere musicale non è sempre corretto. Però i portali Web che mettono a disposizione delle funzioni tramite Web Services sono sempre più numerosi (es. Google, Yahoo Weather, etc...) e mi pareva interessante porre le basi per iniziare ad utilizzarli. Sembra che il metodo più pythoniano di parserizzare un file XML consiste nell'utilizzare la libreria ElemenTree, e non la libreria DOM standard... bene vedremo di approfondire anche questo!

Aggiornare la libreria di iTunes con Python

Visto che la mia libreria di iTunes non ne voleva sapere di aggiornarsi correttamente, nemmeno utilizzando il buon iTunes Library Updater, ho pensato di approfittare di questo piccolo ma fastidioso inconveniente per iniziare a conoscere - finalmente - il linguaggio di programmazione Python.

Prerequisito 1 - Python!

Quale distribuzione di Python utilizzare? Poiché l'ambiente ActiveState Perl mi è molto familiare, mi sembrava naturale utilizzare ActiveState Python, ma ho notato che gli sviluppatori di Google, nel canale Google Code, preferiscono utilizzare la versione ufficiale tratta dal sito www.python.org e allora... non so ancora perché, non so se e quando lo scoprirò, ma ho deciso di iniziare anch'io con questa versione: migliaia di sviluppatori Google non possono sbagliare!

Prerequisito 2 - Apple SDK

iTunes per Windows è controllabile tramite interfaccia COM: sul sito della Apple è gentilmente messo a disposizione il software developer kit con la documentazione per l'interfaccia COM di iTunes.

Prerequisito 3 - libreria tagging MP3

Dopo anni di programmazione in Perl, sono giunto alla conclusione che le mie prime impressioni erano fin troppo vere: si tratta di un linguaggio brutto, sporco e cattivo. Ciò che lo rende comodo, ma non solo comodo, oserei dire quasi meraviglioso, è la sterminata libreria di moduli, generalmente di ottima qualità, progettati da progettisti degni di tale nome, che risolvono praticamente tutti i problemi della terra. E per Python, esiste qualcosa di analogo? Non lo so ancora, e quindi vai con google: il modulo Mutagen sembra proprio quello che stavo cercando, e se lo utilizzano con successo gli amici della comunità MusicBrainz, allora lo posso utilizzare con successo anch'io. Forse code.google.com è (o diventerà) il repository di riferimento per i moduli Python? Forse l'intenzione è proprio quella: vedremo!

Il codice!

Ho ancora molto dubbi, ma copiando un po' di qua un po' di là, ecco finalmente il mio primo programma Python bello e funzionante:

import win32com.client

from mutagen.mp3 import MP3
from mutagen.easyid3 import EasyID3
import mutagen.id3

itunes = win32com.client.Dispatch("iTunes.Application")
mainLibrary = itunes.LibraryPlaylist
tracks = mainLibrary.Tracks
numTracks = tracks.Count
n = 1
v = 0

while n <= numTracks:
  currTrack = tracks.Item(n)
  location = win32com.client.CastTo(currTrack,'IITFileOrCDTrack').Location

  if ((currTrack.Artist == "") or (currTrack.Name == "")):
    try:
      m = MP3(location, ID3=EasyID3)
      if m.has_key('artist'):
        currTrack.Artist      = m['artist'][0]
      if m.has_key('album'):
        currTrack.Album       = m['album'][0]
      if m.has_key('title'):
        currTrack.Name        = m['title'][0]
      if m.has_key('tracknumber'):
        currTrack.TrackNumber = m['tracknumber'][0]
      if m.has_key('date'):
        currTrack.Year        = m['date'][0]
      if m.has_key('genre'):
        currTrack.Genre       = m['genre'][0]
      v += 1
    except mutagen.id3.error:
      print "Error"
      continue
  n+=1

print "Brani aggiornati: " + str(v)

Questo programma scorre tutta la libreria iTunes, ed aggiorna il nome dell'artista, il titolo dell'album e del brano, il numero di traccia, la data (uhm forse bisogna estrarre solo l'anno), ed il genere musicale, leggendoli direttamente dai tag del file MP3, per tutti i brani in cui l'artista oppure il brano sono vuoti. Brutto, sporco, probabilmente buggato, con un cast misterioso e ancora molti perché... ma funzionale: per adesso è un buon inizio!