TCode Helper - sperimentando con Python e TKinter

La prima cosa che si impara quando si lavora con SAP è che tutte le funzioni sono accessibili, oltre che navigando in un menu con tipo cinquemila voci su dodici livelli, digitandone il codice nella casella in alto a sinistra nel client. Si tratta dei Transaction Code, tcode per gli amici, poichè le singole applicazioni in SAP sono chiamate, come richiede la tradizione informatica degli anni 80, "transazioni".

Il problema è che questi tcode sono tanti e hanno nomi assolutamente spassosi come me22n, se38, we05, se22, /opt/vim_wp eccetera. So bene che ricordarsi a memoria 800 codici è un punto di orgoglio importante per qualunque analista SAP, ma non sarebbe bello per quelli come me che non si ricordano cosa hanno mangiato a pranzo due ore prima avere un qualche programmino immediato da richiamare con il quale sia possibile cercare "idoc log" per ricordarsi della we05, o "manutenzione servizi" quando proprio "/iwfnd/maint_service" ce l'ho sulla punta della lingua ma non mi viene?

Del tipo:

Non mi ricordo come mostrare i log degli IDOC. Premo shift+F12 e mi appare la finestra di ricerca. Digito "idoc log" e nella lista mi appare in bella vista il codice WE05. Esclamo qualcosa del tipo "maccertoecchecca**olousoseicentovoltealgiorno"  facendo impensierire il mio vicino di scrivania (che pure ne ha viste di tutti i colori con me vicino), poi premo ESC e torno a lavorare.

Approfittando del fatto che avevo proprio voglia di fare qualche esperimento con TKinter, la libreria grafica nativa di Python, ho provato a realizzare qualcosa del genere.


Nel post che segue cerco di spiegare i singoli passaggi per la realizzazione dell'applicazione, cercando di seguire una specie di filo logico. Per ulteriori dettagli conviene sfogliare direttamente il codice disponibile qui: https://github.com/PicciMario/TCodeHelper.

Negli spezzoni di codice qui sotto, per facilitare la leggibilità, non mostro dettagli come la gestione degli errori o funzionalità meno interessanti. I vari "self" sono perchè nel codice completo tutto è dentro una classe, se riesco cerco di toglierli per rendere il codice più leggibile, ma può essermene scappato qualcuno. Non usate questo codice per cercare di farci qualcosa di utile, piuttosto guardate nel repository la versione completa.


Per prima cosa si deve creare una finestra Tkinter. Si importano un pò di librerie si chiama il costruttore Tk(). Già che ci siamo impostiamo una icona e il titolo.

window = tk.Tk()
window.title("TCode Helper - " + version)
window.geometry("400x400")

logo = Image.open(logo_filename)
app_icon = ImageTk.PhotoImage(logo)
window.wm_iconphoto(False, app_icon)

La parte più divertente in tkinter è come sempre la creazione dei componenti dell'interfaccia (e questa sotto è solo una piccola parte):

# Creazione componenti UI
small_info_image=logo.resize((12, 12))
small_info_image_tk = ImageTk.PhotoImage(small_info_image)
info_img = ttk.Button(window, image=small_info_image_tk, command=popup_about)
info_img.grid(row=0, column=1, sticky="nsew")

search_field = ttk.Entry(window, width=50)
[...]
search_field.bind("<KeyRelease>", _text_callback)
search_field.bind("<Tab>", focus_tree)

columns = ('code', 'descr')
tree = ttk.Treeview(window, columns=columns, show='headings')
[...]

tree.column("code", minwidth=0, width=100, stretch=tk.NO) 
[...]
tree.bind('<<TreeviewSelect>>', _selected_tcode)
tree.bind("<Tab>", focus_search)

descr_text = RichText(window, height=5, borderwidth=5, relief=tk.FLAT, wrap=tk.WORD)
[...]

E alla fine si mostra la finestra:

window.mainloop()

Come si vede ci sono diversi binding legati agli oggetti della UI. Per esempio, il campo di testo e la treeview hanno la pressione del tasto TAB collegati a metodi che effettuano l'uno il focus sull'altro; in questo modo è possibile passare rapidamente dal campo di ricerca alla lista con la pressione di un tasto. Nel caso di spostamento verso la lista si forza anche la selezione del primo elemento mostrato.

def focus_tree(self, event):
	if (tree.get_children()):
		child_id = tree.get_children()[0]
		tree.focus_set()
		tree.focus(child_id)
		tree.selection_set(child_id)
	return("break")

def focus_search(self, event):
	search_field.focus_set()
	search_field.selection_range(0, tk.END)
	return("break")

Infine, sulla lista si implementa un binding a una funzione che mostra nella casella in fondo il codice della transazione selezionata e la relativa descrizione (la casella è predisposta allo scopo di mostrare una descrizione estesa, ma non ho ancora avuto voglia di mettermi a scrivere un poema per ciascuna delle 80 transazioni nella lista che ho accumulato finora).

def _selected_tcode(self, event):

if (len(tree.selection()) > 0):

	selected_id = tree.selection()[0]
	selected_item = tree.item(selected_id)
		selected_tcode = next((x for x in self.tcodes if x['code'] == selected_item['values'][0]), "")

	descr_text.delete('1.0', tk.END)
	descr_text.insert("end", f"{selected_tcode['code']}\n", "bold")
	descr_text.insert("end", f"{selected_tcode['descr']}", "base")
	descr_text.insert("end", f"\n\nKeywords ({selected_tcode['score']}): {selected_tcode['keywords']}", "italic")

return("break")

Il binding più interessante è sul key release della casella di ricerca, che rigenera la lista dei codici trovati in base a quanto scritto e verrà descritto più avanti.

Prima di proseguire, abbiamo anche creato una manciata di binding legati direttamente alla finestra o globali.

Abbiamo creato un binding sulla chiusura della finestra. Quando la finestra viene chiusa (o quando premiamo ESC) non vogliamo che il programma termini, vogliamo solo che venga ridotto a icona nella tray bar di windows, e resti pronto per l'uso successivo. Per questa funzionalità usiamo la libreria "pystray". Nel menu contestuale dell'icona mettiamo due voci, una di default per mostrare la finestra e una per terminare l'applicazione.

window.protocol('WM_DELETE_WINDOW', withdraw_window)
window.bind('<Escape>', withdraw_window)

self.menu = (
	pystray.MenuItem('Mostra finestra', show_window, default=True),
	pystray.MenuItem('Termina applicazione', quit_window)
)
    
def withdraw_window():
	window.withdraw()
	icon = pystray.Icon("TCode Helper", logo, "TCode Helper", menu)

Il binding globale (quindi sempre attivo, anche mentre il programma non è visibile sullo schermo) serve a richiamare il programma in qualunque momento premento Shift+F12 (combinazione strategicamente scelta perchè, a quanto pare, non è catturata dai programmi che uso normalmente). Il metodo collegato elimina l'icona nella traybar e mostra la finestra, forzandola in primo piano e con il focus sulla casella di ricerca per poter iniziare a digitare immediatamente.

keyboard.add_hotkey('shift+F12', show_window)

def show_window(self):

	if (hasattr(self, 'icon')):
		self.icon.stop()

	window.withdraw()
	window.deiconify()
	window.lift()

	search_field.selection_range(0, tk.END)

	# Mette la finestra in primo piano, ma senza
	# lasciarla bloccata lì.
	window.attributes("-topmost", True)
	window.attributes("-topmost", False)

	window.focus_set()
	search_field.focus_set()	

Entrando nel vivo del programma, vediamo come all'avvio abbiamo caricato da un file JSON la lista dei codici.

with open(tcodes_filename, "r") as file:
	tcodes = json.load(file)

Per ogni TCODE è presente il tcode vero e proprio, la descrizione breve mostrata nell'interfaccia e una stringa di parole chiave. La ricerca viene effettuata su questa lista, che risulta più accurata perchè evita i match da parole presenti in ogni descrizione ("il", "la", "un", "modifica", "crea" eccetera) e consente di aggiungere più testo per ciascun codice senza obbligare a mostrarlo nella descrizione (per una transazione legata agli ordini di acquisto posso aggiungere parole chiave come "ordine", "acquisto", "oda", "purchase", "order" eccetera). La lista è fatta così:

[
	...
    {
        "code": "ME22N",
        "descr": "Modifica ordini di acquisto (OdA).",
        "keywords": "ordini acquisto OdA purchase order"
    },
    ...
]

(e al momento è un pò grezza, ci devo lavorare su e accetto volentieri pull request al riguardo...)

La funzione di ricerca, collegata al callback sulla scrittura nella casella di testo, segue questa logica: per ciascuna parola trovata nella ricerca, analizza la stringa delle parole chiave in ogni transazione e assegna un punteggio alla transazione stessa. Se trovo un match perfetto (quindi l'esatta parola cercata) assegno due punti alla transazione. Se trovo un match parziale un solo punto. Il match perfetto è individuato con una regexp, il match parziale con un semplice string find.

# Identifica parole chiave ricerca
keywords = [x for x in search.upper().split(' ') if len(x) > 1]

# Inizializza lista per punteggi ai tcodes
tcodes_points = [[x, 0] for x in self.tcodes]

# Calcola punteggi 
for keyword in keywords:
	for tcode in tcodes_points:

		pattern = keyword
		text = tcode[0]['keywords'].upper()

		if (re.search("(^| )" + pattern + "( |$)", text)):
			tcode[1] += 2
			print(pattern, tcode)
		elif tcode[0]['keywords'].upper().find(keyword) != -1:
			tcode[1] += 1

Alla fine mostro nell'albero i tcodes con almeno un punto, ordinati per punteggio decrescente.

# Ordina e ricostruisce lista tcodes con punteggio non nullo
tcodes_points.sort(key=lambda x: x[1], reverse=True)
for x in tcodes_points:
	x[0]['score'] = x[1] 
filtered_list = [x[0] for x in tcodes_points if x[1] > 0]

# Pulisce albero
for i in self.tree.get_children():
	self.tree.delete(i)

# Aggiunge record in albero
for item in filtered_list:
	self.tree.insert('', tk.END, values=(item['code'], item['descr']))

Il metodo è orribilmente poco efficiente, e richiede un mostruoso numero di check per ciascuna digitazione nella casella di ricerca, ma trattandosi di una lista di decine (al più centinaia) di codici al momento non vale la pena studiare ottimizzazioni esotiche, poichè il risultato è comunque immediato.


Bonus per chi è arrivato fino in fondo. Ho imparato a usare pyinstaller, una simpatica libreria python che consente di creare un programma eseguibile per windows (anche altre piattaforme, ma io ho solo windows) a partire da uno script python. Basta richiamarlo così:

pyinstaller -F --noconsole tcode_helper.py

Ho provveduto a rendere disponibile su github una release eseguibile del programma (a destra nella schermata di github, sotto "releases") se qualcuno ci vuole giocare senza doversi installare l'ambiente di sviluppo Python.

Alla prossima!