Weiterentwicklung der python-Skripte

Handlungsbedarf

Die im Rahmen der Seitenerstellung verwendeten python-Skripte werden konsequenterweise auch durch ChatGTP aufgrund entsprechender Anforderungsprompts erstellt. Die initiale Erstellung erfolgt in der Regel auch problemlos. Schwieriger ist die Anpassung bestehender Skripte aufgrund von gefundene Fehlern oder zu Weiterntwicklung.

ChatGTP sieht sich nicht dazu in der Lage, sicher den bestehenden Code zusammen mit den ermittelten Änderungen zu rekonstruieren, da das interne Sprachmodell der beireitgestellten Quelltextdatei das nicht vorsieht.

Daher wurde ein Patch-Verfahren entwickelt, das es erlaubt, die erforderlichen Änderungen am Code als Patches in den bisherigen Code einzubinden.

Vorgehensweise

Für das Patch-Verfahren werden drei Komponenten verwendet:

  • Die Datei mit dem bisherigen python-Quelltext
  • Eine Datei mit den durchzuführenden Änderungen (Patches)
  • Ein python-Programm, dass die Änderungen am bisherigen Quelltext durchführt (Patchprogramm)

Das Ergebnis, ist die der geänderte python-Quelltext, er wird in die Datei mit dem bisherigen Quelltext zurückgeschrieben. Die Versionssicherung erfolgt mittels git und ist nicht Aufgabe des Patchprogramms.

Format der Patch-Datei

Zunächst liegt es nahe, die für die Patches eine JSON-Struktur zu verwenden. Python und JSON haben allerdings eine Sprach-Inkompatibilität: python verwendet die Zeicehenfolge “‘ als Start und Ende eines Blockkommentars, JSON interpretiert diese Zeichenfolge unbedingt als Zeilenende. Es ist als schwer möglich, ptython-Blockkommentare in JSON-Dateien darzustellen.

Daher wird für die Patchdatei eine XML-Struktur verwendet:

<Patches>
  <Patch id="..." order="..." action="insert_after"> 
    <Anchor occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Anchor> 
    <Content> 
    <![CDATA[ CODEBLOCK ]]> 
    </Content> 
  </Patch> 
  <Patch id="..." order="..." action="replace_between"> 
    <Prefix occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Prefix> 
    <Postfix occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Postfix> 
    <Replacement> 
    <![CDATA[ CODEBLOCK ]]> 
    </Replacement> 
  </Patch> 
</Patches>

Die Parameter und Inhalte der Tags haben folgende Bedeutung:

ParameterBedeutung
idEindeutige Identifikation des Patches in der Datei
orderBearbeitungsreihenfolge der Patches in der Datei
actionArt der Patch-Durchführung:
insert_after:
Der content wird nach der Fundstelle des Ankers eingefügt
replace_between:
Das replacement ersetzt den Text zwischen den Fundstellen des Prefix und des Postfix.
anchornur bei action „insert_after“:
Text der Zeile, nach der der content eingefügt wird.
occurencebestimmt, das wievielte Vorkommen des Anchor-, Prefix- oder Postfix-Textes verwendet werden soll.
Contentnur bei action „insert_after“:
nicht-codierter Text, der nach der Fundstelle des Ankers eingefügt wird.
Prefix, Postfixnur bei action „replace_between“:
Text der Zeilen, zwischen denen das Replacement den bisherigen dort befindlichen Text ersetzen soll
Replacementnur bei action „replace_between“:
nicht-codierter Text, der den Text zwischen den Fundstellen von Prefix und Postfix ersetzen soll.

Der Inhalt der Patch-Datei wird durch einen Prompt bei ChatGTP erzeugt.

Prompt zur Estellung der Patchdatei
Analysiere die hochgeladene Python-Quelldatei und erstelle eine Patchdatei für ein Anchor-basiertes Patch-System. ZIEL: Die Patchdatei soll die angeforderte Änderung vollständig und korrekt umsetzen. Die Ausgabe erfolgt als XML in einem XML-Codefenster. 
--- ENTSCHEIDUNGSLOGIK (VERBINDLICH) ---
Du musst für jede Änderung die passende Action wählen: 
1. Verwende insert_after, wenn: 
- neuer Code hinzugefügt wird 
- Docstrings eingefügt werden 
- zusätzliche Prüfungen oder Logging ergänzt werden 
- bestehender Code unverändert bleiben soll 
2. Verwende replace_between, wenn: 
- bestehender Code inhaltlich geändert werden muss 
- Logik korrigiert wird (Bugfix) 
- ein bestehender Block ersetzt werden muss 
- Operatoren, Bedingungen oder Funktionsaufrufe geändert werden 
3. Verwende NIEMALS replace_between: 
- für reine Ergänzungen 
- wenn dadurch Einrückungsstruktur zerstört würde 
- wenn nur eine einzelne Zeile ergänzt werden soll → dann insert_after 
--- ANCHOR-REGELN (KRITISCH) ---
- Anchor-, Prefix- und Postfix-Inhalte müssen EXAKT aus dem Quellcode übernommen werden 
- Führende Leerzeichen gehören zwingend dazu 
- Anchors müssen eindeutig referenzierbar sein 
- KEINE inhaltlichen Veränderungen an Anchors 
--- OCCURRENCE (VERPFLICHTEND) ---
- Muss IMMER angegeben werden 
- Bezieht sich auf das n-te Auftreten im gesamten Quelltext 
- Bei Mehrdeutigkeit ist das korrekte Vorkommen anhand des Kontexts zu wählen 
--- EINRÜCKUNG (SEHR WICHTIG) --- 
- Die Patchdatei enthält keine absolute Einrückung 
- Einrückung ist relativ zu formulieren 
- KEINE führenden Leerzeichen außerhalb von CDATA-Blöcken 
--- XML-STRUKTUR (VERBINDLICH) --- 
Die Ausgabe muss exakt dieser Struktur entsprechen: 
<Patches> 
  <Patch id="..." order="..." action="insert_after"> 
    <Anchor occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Anchor> 
    <Content> 
    <![CDATA[ CODEBLOCK ]]> 
    </Content> 
  </Patch> 
  <Patch id="..." order="..." action="replace_between"> 
    <Prefix occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Prefix> 
    <Postfix occurrence="..."> 
EXAKTER CODE AUS QUELLE 
    </Postfix> 
    <Replacement> 
<![CDATA[ CODEBLOCK ]]> 
    </Replacement> 
  </Patch> 
</Patches> 
--- WICHTIGE REGELN ZU CDATA ---
- Jeder Codeblock MUSS in <![CDATA[ ... ]]> stehen 
- Innerhalb von CDATA: 
- KEIN Escaping 
- """ müssen unverändert bleiben 
- Einrückung muss korrekt relativ angegeben sein 
- KEINE zusätzlichen Leerzeilen am Anfang oder Ende des CDATA-Blocks 
--- REPLACE-BETWEEN REGELN --- 
- Prefix und Postfix definieren exakt den zu ersetzenden Bereich 
- Prefix bleibt erhalten 
- Postfix bleibt erhalten 
- Nur der Inhalt dazwischen wird ersetzt 
- Der neue Block muss syntaktisch gültiger Python-Code sein 
--- LÖSCHREGELN (VERBINDLICH) --- 
- Löschungen dürfen ausschließlich mit action="replace_between" umgesetzt werden 
- Der zu löschende Bereich wird durch Prefix und Postfix exakt begrenzt 
- Der Replacement-Block MUSS leer sein: <![CDATA[ ]]> 
- Prefix und Postfix müssen direkt an den zu löschenden Bereich angrenzen 
- Es darf kein zusätzlicher Code eingefügt werden 
SPEZIALFALL: 
- Wenn durch die Löschung ein leerer Block entsteht (z. B. if, for, try), muss stattdessen folgender Replacement-Inhalt verwendet werden: 
<![CDATA[ pass ]]> 
- Die syntaktische Gültigkeit des Python-Codes muss immer erhalten bleiben 
--- SORTIERUNG --- 
- Alle Patch-Elemente müssen in der Reihenfolge ihres Auftretens im Code sortiert sein 
--- AUSGABEFORMAT (Zwingend) --- 
- NUR gültiges XML 
- KEIN Markdown 
- KEINE Kommentare außerhalb von CDATA 
- KEINE zusätzliche Erklärung 
- KEINE einleitenden oder abschließenden Texte 
--- QUALITÄTSKRITERIEN --- 
- Ergebnis muss deterministisch anwendbar sein 
- Keine Mehrdeutigkeit in Anchors 
- Keine zerstörte Einrückungsstruktur 
- Keine unnötigen Änderungen 
- Minimal-invasive Patches 
---
AUFGABE: 
<Beschreibung der Aufgabenstellung der Codeanpassungen>.

Bei der Übergabe des Prompts an ChatGTP wird die zu ändernde python-Quelldatei mit hochgeladen.
Der Inhalt der Patch-Datei wird in einem XML-Angezeigt udn per Copy/Paste in eine XML-Datei übertragen.

Patchprogramm

patch_python.py
import json
import argparse
import re
import xml.etree.ElementTree as ET
from pathlib import Path


INDENT_UNIT = "    "  # 4 spaces (konfigurierbar)


# --------------------------------------------------
# Hilfsfunktionen
# --------------------------------------------------

def find_nth(text, pattern, occurrence, start=0):
    """Findet die n-te Vorkommensposition eines Musters im Text.

    Args:
        text (str): Gesamter Text.
        pattern (str): Zu suchendes Muster (zeilenbasiert).
        occurrence (int): Gewünschtes Auftreten (1-basiert).
        start (int, optional): Startposition im Text.

    Returns:
        int: Zeichenposition des gefundenen Musters.

    Raises:
        ValueError: Wenn das Muster nicht gefunden wird.
    """


    import re
    
    def normalize(s):
        return re.sub(r"\s+", " ", s.strip())

    matches = []
    pos = 0

    lines = text.splitlines(keepends=True)

    for line in lines:
        if normalize(line) == normalize(pattern):
            matches.append(pos)
        pos += len(line)
        
    if len(matches) < occurrence:
        raise ValueError(f"Pattern not found: {pattern}")

    # Jetzt Cursor berücksichtigen
    for m in matches:
        if m >= start:
            occurrence -= 1
            if occurrence == 0:
                return m

    raise ValueError(f"Pattern not found after cursor: {pattern}")
    
    
def get_line_at(text, pos):
    """Ermittelt die vollständige Zeile an einer gegebenen Position.

    Args:
        text (str): Gesamter Text.
        pos (int): Zeichenposition.

    Returns:
        str: Zeile, die die Position enthält.
    """


    start = text.rfind("\n", 0, pos) + 1
    end = text.find("\n", pos)
    if end == -1:
        end = len(text)
    return text[start:end]


def get_indent(line):
    """Extrahiert die Einrückung einer Zeile.

    Args:
        line (str): Eingabezeile.

    Returns:
        str: Führende Leerzeichen.
    """


    return line[:len(line) - len(line.lstrip())]


def add_indent_level(indent):
    """Erhöht die Einrückung um eine Stufe.

    Args:
        indent (str): Aktuelle Einrückung.

    Returns:
        str: Erweiterte Einrückung.
    """


    return indent + INDENT_UNIT


def indent_block(block, indent):
    """Rückt einen Block von Zeilen relativ ein.

    Args:
        block (list[str]): Liste von Codezeilen.
        indent (str): Ziel-Einrückung.

    Returns:
        list[str]: Eingerückter Block.
    """


    result = []
    for line in block:
        if line.strip() == "":
            result.append("")
        else:
            result.append(indent + line)
    return result


def detect_base_indent(source, insert_pos):
    """Bestimmt die Basiseinrückung an einer Einfügeposition.

    Args:
        source (str): Gesamter Quelltext.
        insert_pos (int): Einfügeposition.

    Returns:
        str: Erkannte Einrückung.
    """


    rest = source[insert_pos:]

    next_nl = rest.find("\n")
    if next_nl == -1:
        return ""

    next_line = rest[:next_nl]

    if next_line.strip() == "":
        return ""

    return get_indent(next_line)


# --------------------------------------------------
# Patch-Operationen
# --------------------------------------------------

def apply_insert_after(source, patch, cursor):
    """Führt eine insert_after-Operation aus.

    Args:
        source (str): Quelltext.
        patch (dict): Patch-Definition.
        cursor (int): Aktuelle Position.

    Returns:
        tuple[str, int]: Neuer Text und aktualisierter Cursor.
    """


    anchor = patch["anchor"]
    occurrence = patch.get("occurrence", 1)
    content = patch["content"]
 
    pos = find_nth(source, anchor, occurrence, cursor)
   
    line_end = source.find("\n", pos)
    if line_end == -1:
        insert_pos = len(source)
    else:
        insert_pos = line_end

    base_indent = detect_base_indent(source, insert_pos)

    # Heuristik: wenn anchor eine Blocköffnung ist → +1 Level
    if anchor.strip().endswith(":"):
        target_indent = add_indent_level(base_indent)
    else:
        target_indent = base_indent

    indented_content = indent_block(content, target_indent)

    insertion = "\n" + "\n".join(indented_content)

    new_source = (
        source[:insert_pos] +
        insertion +
        source[insert_pos:]
    )

    new_cursor = insert_pos + len(insertion)

    return new_source, new_cursor


def apply_replace_between(source, patch, cursor):
    """Führt eine replace_between-Operation aus.

    Args:
        source (str): Quelltext.
        patch (dict): Patch-Definition.
        cursor (int): Aktuelle Position.

    Returns:
        tuple[str, int]: Neuer Text und aktualisierter Cursor.
    """


    prefix = patch["prefix"]
    prefix_occ = patch.get("prefix_occurrence", 1)

    postfix = patch["postfix"]
    postfix_occ = patch.get("postfix_occurrence", 1)

    content = patch["replacement"]

    prefix_pos = find_nth(source, prefix, prefix_occ, cursor)
    prefix_end = prefix_pos + len(prefix)

    postfix_pos = find_nth(source, postfix, postfix_occ, prefix_end)

    base_line = get_line_at(source, prefix_pos)
    base_indent = get_indent(base_line)

    indented_content = indent_block(content, base_indent)

    new_block = "\n" + "\n".join(indented_content) + "\n"

    new_source = (
        source[:prefix_end] +
        new_block +
        source[postfix_pos:]
    )

    new_cursor = prefix_end + len(new_block)

    return new_source, new_cursor


# --------------------------------------------------
# Validierung
# --------------------------------------------------

def validate_patch(patch):
    """Validiert die Struktur eines Patches.

    Args:
        patch (dict): Patch-Definition.

    Raises:
        ValueError: Bei ungültiger Struktur.
    """


    if "action" not in patch:
        raise ValueError("Patch missing 'action'")

    if patch["action"] == "insert_after":
        if "anchor" not in patch:
            raise ValueError("insert_after requires 'anchor'")
        if "content" not in patch:
            raise ValueError("insert_after requires 'content'")

    elif patch["action"] == "replace_between":
        for key in ["prefix", "postfix", "replacement"]:
            if key not in patch:
                raise ValueError(f"replace_between requires '{key}'")

    else:
        raise ValueError(f"Unknown action: {patch['action']}")


# --------------------------------------------------
# Hauptlogik
# --------------------------------------------------

def apply_patches(source, patches):
    """Wendet eine Liste von Patches sequenziell an.

    Args:
        source (str): Ursprünglicher Quelltext.
        patches (list[dict]): Patch-Liste.

    Returns:
        str: Modifizierter Quelltext.
    """


    cursor = 0

    for p in patches:
        validate_patch(p)

        action = p["action"]

        if action == "insert_after":
            source, cursor = apply_insert_after(source, p, cursor)

        elif action == "replace_between":
            source, cursor = apply_replace_between(source, p, cursor)

    return source


# --------------------------------------------------
# CLI
# --------------------------------------------------

def main():
    """CLI-Einstiegspunkt zur Anwendung von Patch-Dateien.

    Parst Argumente, lädt Dateien, wendet Patches an und schreibt das Ergebnis.
    """


    ap = argparse.ArgumentParser()
    ap.add_argument("--pythonFile", required=True)
    ap.add_argument("--patchFile", required=True)
    ap.add_argument("--dryRun", action="store_true")
    args = ap.parse_args()

    source_path = Path(args.pythonFile)
    patch_path = Path(args.patchFile)

    source = source_path.read_text(encoding="utf-8")

    tree = ET.parse(patch_path)
    root = tree.getroot()

    patches = []

    for p in root.findall("Patch"):
        patch = {
            "id": p.get("id"),
            "order": int(p.get("order", 0)),
            "action": p.get("action")
        }
      
        if patch["action"] == "insert_after":
            anchor_elem = p.find("Anchor")
            content_elem = p.find("Content")

            patch["anchor"] = anchor_elem.text.strip("\n")
            patch["occurrence"] = int(anchor_elem.get("occurrence", 1))

            content_text = content_elem.text or ""
            patch["content"] = [line for line in content_text.strip("\n").split("\n")]

        elif patch["action"] == "replace_between":
            prefix_elem = p.find("Prefix")
            postfix_elem = p.find("Postfix")
            repl_elem = p.find("Replacement")

            patch["prefix"] = prefix_elem.text.strip("\n")
            patch["prefix_occurrence"] = int(prefix_elem.get("occurrence", 1))

            patch["postfix"] = postfix_elem.text.strip("\n")
            patch["postfix_occurrence"] = int(postfix_elem.get("occurrence", 1))

            repl_text = repl_elem.text or ""
            patch["replacement"] = [line for line in repl_text.strip("\n").split("\n")]
          
        patches.append(patch)

    patches = sorted(patches, key=lambda x: x.get("order", 0))

    result = apply_patches(source, patches)

    if args.dryRun:
        print(result)
        return

    source_path.write_text(result, encoding="utf-8")

    print(f"Patched file written to: {source_path}")


if __name__ == "__main__":
    main()

Das Patchprogramm patch_python.py führt am –pythonfile die durch den –patchFile bestimmten Änderungen durch. Die Änderungen liegen nach der Bearbeitung im –pythonFile vor.

Aufuf des Patchprogramms:

python ".\patch_python.py" --pythonFile "<Dateipfad zum zu ändernden Programm>" --patchFile "<Dateipfad zur Patchdatei>"