Das Journal macht beim Start einen winzigen Server auf 127.0.0.1 auf und traegt
Port und PID in die gemeinsame SQLite ein (Tabelle "laeuft"). Das Rechnungstool
sieht daran, dass jemand zuhoert, und klopft nach jeder Rechnung an: "schau in
die sqlite". Dann wird sofort nachgelesen, statt bis zum naechsten Durchgang zu
warten. Faellt die API aus, findet die Wache die Meldung trotzdem - ein os.stat
auf die eine Datei, alle zwei Sekunden und sofort beim Zurueckklicken.
Die Proforma-Rechnung wird NICHT gebucht: keine_buchung() kennt jetzt
Berichtigung und Proforma. Der Scan fragt wieder keine_buchung() - seit dem
Berichtigungs-Umbau tat er das nicht mehr, und die Proforma waere als Buchung
im Journal gelandet (der Pruefstand hat es gefunden).
Pruefstand pruef_wache.py: 31 Pruefungen ueber BEIDE Programme - Anmeldung mit
PID, ping ("hallo, bin noch da"), genau eine Datei im gemeinsamen Ordner, Push
nach Rechnung und nach Storno, Einlesen samt Anzeige, Rueckfallebene ohne API,
tote PID wird weggeraeumt, kein Melden an ein geschlossenes Journal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYeAfLtccFrbU3MTj1MMTx
194 lines
6.4 KiB
Python
194 lines
6.4 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Mini-API zwischen Rechnungstool und Steuerjournal.
|
|
|
|
WARUM
|
|
-----
|
|
Beide Programme stehen nebeneinander offen. Schreibt das Rechnungstool eine
|
|
Rechnung, soll sie im Journal SOFORT auftauchen - nicht erst beim naechsten
|
|
Einlesen und nicht nach einer Wartezeit.
|
|
|
|
Seine Ansage dazu: *"machst ein kleinen py server der hin und her pingt mit
|
|
einem push vom rechnungstool: eh schau mal in die sqlite"* und *"die muessen ja
|
|
sehen das die beiden auf sind"*.
|
|
|
|
WIE
|
|
---
|
|
Jedes Programm macht beim Start einen winzigen Server auf **127.0.0.1** mit
|
|
einem Port, den das Betriebssystem aussucht (Port 0). Wo es lauscht, traegt es
|
|
in die gemeinsame SQLite ein - die ist das Telefonbuch. Wer jemanden erreichen
|
|
will, schlaegt dort Port und PID nach, prueft die PID und ruft an.
|
|
|
|
Zwei Nachrichten reichen:
|
|
|
|
{"was": "ping"} -> {"antwort": "hallo, bin noch da"}
|
|
{"was": "schau_nach"} -> {"antwort": "ok"} (und das Fenster liest nach)
|
|
|
|
Der Push traegt keine Daten. Er sagt nur "schau in die SQLite" - dort steht die
|
|
Meldung ohnehin. Damit gibt es genau EINE Wahrheit, und wenn der Anruf nicht
|
|
durchgeht (Programm zu, Port belegt), findet das andere die Meldung beim
|
|
naechsten Nachsehen trotzdem.
|
|
|
|
SICHERHEIT
|
|
----------
|
|
Gebunden wird ausschliesslich auf 127.0.0.1 - von aussen ist da nichts zu
|
|
erreichen, und Windows fragt bei Loopback nicht nach der Firewall. Angenommen
|
|
werden nur die zwei Woerter oben; alles andere wird verworfen. Die API fuehrt
|
|
keine Befehle aus und uebertraegt keine Daten.
|
|
|
|
Diese Datei liegt in BEIDEN Programmen gleich. Wer sie aendert, kopiert sie
|
|
hinueber.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import socket
|
|
import socketserver
|
|
import sys
|
|
import threading
|
|
|
|
log = logging.getLogger("api")
|
|
|
|
ANTWORT_HALLO = "hallo, bin noch da"
|
|
|
|
|
|
class _Handler(socketserver.StreamRequestHandler):
|
|
timeout = 3
|
|
|
|
def handle(self):
|
|
# Nur der eigene Rechner darf reden - gebunden ist ohnehin nur Loopback.
|
|
if self.client_address[0] not in ("127.0.0.1", "::1"):
|
|
return
|
|
try:
|
|
zeile = self.rfile.readline(4096).decode("utf-8", "replace").strip()
|
|
except OSError:
|
|
return
|
|
if not zeile:
|
|
return
|
|
try:
|
|
was = str(json.loads(zeile).get("was", ""))
|
|
except (ValueError, AttributeError):
|
|
was = ""
|
|
if was == "ping":
|
|
antwort = ANTWORT_HALLO
|
|
elif was == "schau_nach":
|
|
antwort = "ok"
|
|
self.server.melden() # Fenster benachrichtigen (threadsicher)
|
|
else:
|
|
antwort = "haeh?"
|
|
try:
|
|
self.wfile.write((json.dumps({"antwort": antwort}) + "\n").encode("utf-8"))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
class _Server(socketserver.ThreadingTCPServer):
|
|
allow_reuse_address = True
|
|
daemon_threads = True
|
|
|
|
def __init__(self, adresse, bei_push):
|
|
self.melden = bei_push
|
|
super().__init__(adresse, _Handler)
|
|
|
|
|
|
class MiniApi:
|
|
"""Der kleine Server eines Programms.
|
|
|
|
`bei_push` wird aus einem FREMDEN Faden gerufen - Tkinter darf man von dort
|
|
nicht anfassen. Der Aufrufer legt die Meldung deshalb in eine Warteschlange
|
|
und holt sie im Fenster mit after() ab.
|
|
"""
|
|
|
|
def __init__(self, programm, bei_push):
|
|
self.programm = programm
|
|
self.bei_push = bei_push
|
|
self.server = None
|
|
self.faden = None
|
|
self.port = 0
|
|
|
|
def starten(self, buch):
|
|
"""Server aufmachen und im Telefonbuch (SQLite) eintragen."""
|
|
if self.server is not None:
|
|
return self.port
|
|
try:
|
|
self.server = _Server(("127.0.0.1", 0), self.bei_push)
|
|
except OSError as e:
|
|
log.warning("API konnte nicht starten: %s", e)
|
|
self.server = None
|
|
return 0
|
|
self.port = self.server.server_address[1]
|
|
self.faden = threading.Thread(target=self.server.serve_forever,
|
|
name=f"api-{self.programm}", daemon=True)
|
|
self.faden.start()
|
|
log.info("API von %s laeuft auf 127.0.0.1:%s", self.programm, self.port)
|
|
self.eintragen(buch)
|
|
return self.port
|
|
|
|
def eintragen(self, buch):
|
|
"""Port und PID ins Telefonbuch. Ohne Buch gibt es keine API-Auskunft."""
|
|
if buch is None or not self.port:
|
|
return
|
|
try:
|
|
buch.anmelden(self.programm, os.getpid(),
|
|
os.path.basename(sys.executable), self.port)
|
|
except Exception as e: # noqa: BLE001
|
|
log.warning("Eintrag ins Telefonbuch fehlgeschlagen: %s", e)
|
|
|
|
def stoppen(self, buch=None):
|
|
if buch is not None:
|
|
try:
|
|
buch.abmelden(self.programm)
|
|
except Exception: # noqa: BLE001 - beim Schliessen nichts erzwingen
|
|
pass
|
|
if self.server is not None:
|
|
try:
|
|
self.server.shutdown()
|
|
self.server.server_close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
self.server = None
|
|
self.port = 0
|
|
|
|
|
|
def _reden(port, was, zeitgrenze=1.5):
|
|
"""Eine Zeile hinschicken und die Antwort lesen. None = niemand da."""
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", int(port)), timeout=zeitgrenze) as s:
|
|
s.settimeout(zeitgrenze)
|
|
s.sendall((json.dumps({"was": was}) + "\n").encode("utf-8"))
|
|
roh = s.makefile("r", encoding="utf-8").readline()
|
|
return json.loads(roh).get("antwort", "")
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def erreichbar(buch, programm):
|
|
"""Meldet sich das andere Programm? ("hallo, bin noch da")
|
|
|
|
Zuerst die PID - die beantwortet Windows und kostet nichts. Erst wenn die
|
|
stimmt, wird ueberhaupt angerufen.
|
|
"""
|
|
if buch is None:
|
|
return False
|
|
try:
|
|
eintrag = buch.wer_laeuft(programm)
|
|
except Exception as e: # noqa: BLE001
|
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
|
return False
|
|
if not eintrag or not eintrag.get("port"):
|
|
return False
|
|
return _reden(eintrag["port"], "ping") == ANTWORT_HALLO
|
|
|
|
|
|
def anklopfen(buch, programm):
|
|
"""Push: "schau mal in die sqlite". True, wenn es angekommen ist."""
|
|
if buch is None:
|
|
return False
|
|
try:
|
|
eintrag = buch.wer_laeuft(programm)
|
|
except Exception as e: # noqa: BLE001
|
|
log.debug("Telefonbuch nicht lesbar: %s", e)
|
|
return False
|
|
if not eintrag or not eintrag.get("port"):
|
|
return False
|
|
return _reden(eintrag["port"], "schau_nach") == "ok"
|