TL;DR: {{ }} wird nur in f-Strings zu { } expandiert. In normalen Python-Strings bleiben {{ }} als literale zwei Klammern stehen. Wenn du Code für andere Sprachen generierst und Strings mischst, entsteht ungültiger Code — ohne jede Fehlermeldung.


Das Problem

Ein Script auf srv-r2d2.deathstar.lan verarbeitet Tickets aus einem Helpdesk-System. Dafür baut es Ruby-Code zusammen, der via docker exec ... rails runner ausgeführt wird.

Der Code holt neue Tickets aus der Datenbank:

ruby = (
    "new_ids = Ticket::State.where(name: 'new').pluck(:id);"
    "tickets = Ticket.where(state_id: new_ids)"
    ".where('processed IS NULL OR processed = FALSE')"
    f".order(created_at: :asc).limit({MAX_TICKETS});"
    "puts tickets.map {{ |t| {{id: t.id, title: t.title}} }}.to_json"
)

Der Rails-Runner wird aufgerufen. Kein Fehler in Python. Das Script gibt aus: „Keine neuen Tickets." — obwohl definitiv welche in der Datenbank sind.


Die Diagnose

Schritt 1: Die Datenbank direkt befragen — Rails-Runner manuell ausführen. Findet 2 Tickets.

Schritt 2: Die Python-Parsing-Logik isoliert testen — gibt korrekte Ergebnisse zurück.

Schritt 3: Den generierten Ruby-Code ausgeben, der tatsächlich an den Runner übergeben wird:

print(repr(ruby))

Ausgabe:

"...puts tickets.map {{ |t| {{id: t.id, title: t.title}} }}.to_json"

Moment. {{ |t|? Das sind zwei öffnende geschweifte Klammern. Ungültiges Ruby.


Die Ursache

In Python-f-Strings ist {{ }} der Escape für eine literale geschweifte Klammer:

f"{{ }}"   # → "{ }"   (geschweifte Klammer, kein Ausdruck)
f"{name}"  # → Wert von name

Aber: In normalen Strings (ohne f-Prefix) ist {{ }} einfach zwei Klammern — kein Escaping, keine Expansion:

"{{ }}"    # → "{{ }}"  (zwei Klammern!)

Im fehlerhaften Code ist nur eine Zeile ein f-String — nämlich die mit {MAX_TICKETS}:

f".order(created_at: :asc).limit({MAX_TICKETS});"

Die anderen Zeilen sind normale Strings. Die {{ }} in der letzten Zeile werden nicht expandiert und der Ruby-Code enthält literal {{ |t| {{...}} }} — was Ruby nicht versteht.

Rails runner gibt einen SyntaxError zurück, der Python-Code fängt ihn ab, gibt None zurück, und das Script sagt höflich „Keine neuen Tickets."


Die Lösung

Option 1: Den String als f-String markieren (auch wenn keine Python-Ausdrücke enthalten sind):

f"puts tickets.map {{ |t| {{id: t.id, title: t.title}} }}.to_json"

Jetzt sind {{ }} Escapes in einem f-String und werden zu { }.

Option 2: Direkt { } schreiben, da kein Python-Ausdruck nötig ist:

"puts tickets.map { |t| {id: t.id, title: t.title} }.to_json"

Simpler und eindeutiger.

Option 3: Den ganzen Ruby-Code in eine separate Datei auslagern statt ihn als String zu bauen. Dann entfällt das Problem komplett — aber das ist ein größerer Refactor.


Warum ist das so tückisch?

Normalerweise wirft Python sofort einen Fehler wenn etwas nicht stimmt. Hier passiert das nicht, weil:

  1. Python generiert den Code korrekt aus seiner Perspektive — {{ }} in einem normalen String ist vollkommen gültig
  2. Der Fehler liegt im generierten Code, nicht in Python selbst
  3. Der Ruby-SyntaxError kommt als Return-Code 1 von subprocess.run zurück — aber der Code prüft den Return-Code nicht, er schaut nur auf den stdout-Output
  4. Bei leerem stdout wird stillschweigend None zurückgegeben

Das Symptom — „findet keine Tickets" — ist beliebig weit von der Ursache entfernt.


Lessons Learned

1. Beim Code-Generieren den Output immer prüfen

print("Generated code:", repr(generated_code))

Das sollte in der Entwicklungsphase immer aktiv sein. Für Debugging:

python3 -c "from myscript import build_ruby_query; print(build_ruby_query())"

2. Return-Code von Subprocessen prüfen

result = subprocess.run([...], capture_output=True, text=True)
if result.returncode != 0:
    log.error(f"Rails runner failed: {result.stderr}")
    return None

Das hätte sofort auf den SyntaxError hingewiesen.

3. Gemischte f-String / non-f-String Strings sind fehleranfällig

Wenn ein Code-Template über mehrere verkettete Strings gebaut wird und nur ein Teil ein f-String ist, ist das eine Falle. Entweder:

  • Alle Teile als f-Strings kennzeichnen
  • Oder str.format() / Template-Strings verwenden
  • Oder direkte { } ohne Escaping schreiben

4. Die einfachste Lösung ist oft die beste

Im konkreten Fall war die Zeile "puts tickets.map { |t| {id: t.id} }.to_json" mit einfachen { } die klarste Lösung — weil hier gar keine Python-Variable interpoliert werden musste.


Checkliste: Python-Code-Generierung

  • Enthält der generierte Code {{ }} oder }}? → Prüfen ob der String ein f-String ist
  • subprocess.run → Return-Code auswerten
  • stderr mitloggen wenn returncode != 0
  • Generierten Code im Debug-Modus ausgeben
  • Wenn möglich: Code in Datei auslagern statt als String bauen