From 1881af93f8d1fde77e4a527df04f2642a1b86ecc Mon Sep 17 00:00:00 2001 From: daniel Date: Tue, 2 Dec 2025 20:55:51 +0100 Subject: [PATCH 01/11] =?UTF-8?q?PruefManager=20und=20Daten=20aus=20P41=20?= =?UTF-8?q?=C3=BCbertragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Dateipruefer_flowchart.svg | 102 ++++ assets/Linkpruefer_flowchart.svg | 3 + assets/Objektstruktur.txt | 122 +++++ assets/Pluginkonzept.md | 22 + assets/Stilpruefer_flowchart.svg | 1 + assets/UML_Struktur.png | Bin 0 -> 141866 bytes modules/Dateipruefer.py | 97 ++++ modules/Pruefmanager.py | 51 ++ modules/__init__py | 0 modules/linkpruefer.py | 94 ++++ modules/pruef_ergebnis | 11 + modules/pruef_ergebnis.py | 11 + modules/stilpruefer.py | 45 ++ styles/GIS_63000F_Objekt_Denkmalschutz.qml | 609 +++++++++++++++++++++ styles/GIS_Biotope_F.qml | 225 ++++++++ styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml | 349 ++++++++++++ styles/GIS_LfULG_LSG.qml | 371 +++++++++++++ styles/verfahrensgebiet.qml | 414 ++++---------- test/__init__.py | 1 + test/run_tests.py | 29 + test/start_osgeo4w_qgis.bat | 9 + test/test_dateipruefer.py | 88 +++ test/test_linkpruefer.py | 125 +++++ test/test_pruefmanager.py | 36 ++ test/test_stilpruefer.py | 47 ++ 25 files changed, 2567 insertions(+), 295 deletions(-) create mode 100644 assets/Dateipruefer_flowchart.svg create mode 100644 assets/Linkpruefer_flowchart.svg create mode 100644 assets/Objektstruktur.txt create mode 100644 assets/Pluginkonzept.md create mode 100644 assets/Stilpruefer_flowchart.svg create mode 100644 assets/UML_Struktur.png create mode 100644 modules/Dateipruefer.py create mode 100644 modules/Pruefmanager.py create mode 100644 modules/__init__py create mode 100644 modules/linkpruefer.py create mode 100644 modules/pruef_ergebnis create mode 100644 modules/pruef_ergebnis.py create mode 100644 modules/stilpruefer.py create mode 100644 styles/GIS_63000F_Objekt_Denkmalschutz.qml create mode 100644 styles/GIS_Biotope_F.qml create mode 100644 styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml create mode 100644 styles/GIS_LfULG_LSG.qml create mode 100644 test/__init__.py create mode 100644 test/run_tests.py create mode 100644 test/start_osgeo4w_qgis.bat create mode 100644 test/test_dateipruefer.py create mode 100644 test/test_linkpruefer.py create mode 100644 test/test_pruefmanager.py create mode 100644 test/test_stilpruefer.py diff --git a/assets/Dateipruefer_flowchart.svg b/assets/Dateipruefer_flowchart.svg new file mode 100644 index 0000000..b9abfe8 --- /dev/null +++ b/assets/Dateipruefer_flowchart.svg @@ -0,0 +1,102 @@ +

Ja

VERBOTEN

NUTZE_STANDARD
ohne plugin_pfad/standard

NUTZE_STANDARD
mit plugin_pfad+standard

TEMPORAER_ERLAUBT

Nein

Ja

Nein

Ja=ABBRECHEN

Ja=ERSETZEN/ANHAENGEN

Nein

Start: Eingabe prüfen

Pfad leer?

leer_modus

Return: erfolgreich=False
Fehler: 'Kein Pfad angegeben'

Return: erfolgreich=False
Fehler: 'Standardpfad/-name fehlen'

Setze Pfad=plugin_pfad+standard

Return: erfolgreich=True
temporär=True

Datei existiert?

vorhandene_datei_entscheidung gesetzt?

Return: erfolgreich=True
entscheidung=None
Fehler: 'Datei existiert bereits – Entscheidung ausstehend'

Return: erfolgreich=False
Fehler: 'Benutzer hat abgebrochen'

Return: erfolgreich=True
entscheidung=...

Return: erfolgreich=True
pfad=...

\ No newline at end of file diff --git a/assets/Linkpruefer_flowchart.svg b/assets/Linkpruefer_flowchart.svg new file mode 100644 index 0000000..7d791de --- /dev/null +++ b/assets/Linkpruefer_flowchart.svg @@ -0,0 +1,3 @@ + + +

Nein

Ja

Ja

Nein

Ja

Ja

Nein

Nein

Ja

Nein

Start Linkprüfung

Ist Link vorhanden?

Fehler: Link fehlt

Prüfergebnis: Fehler zurückgeben

Ist Link Remote http/https?

HEAD-Anfrage mit QgsNetworkAccessManager

Antwort erhalten?

Fehler: Verbindungsfehler

Prüfergebnis: Fehler zurückgeben

HTTP-Statuscode < 200 oder ≥ 400?

Fehler: Link nicht erreichbar

Anbieter klassifizieren

Prüfergebnis zurückgeben

Plausibilitätscheck für lokalen Link

Link sieht ungewöhnlich aus?

Warnung ausgeben

\ No newline at end of file diff --git a/assets/Objektstruktur.txt b/assets/Objektstruktur.txt new file mode 100644 index 0000000..df3e392 --- /dev/null +++ b/assets/Objektstruktur.txt @@ -0,0 +1,122 @@ ++ PluginController + └─ GUIManager + └─ PrüfManager (koordiniert alle Prüfer) + ├─ Dateiprüfer + ├─ Linklistenprüfer + │ └─ Zeilenprüfer[n] + │ ├─ Linkprüfer + │ └─ Stilprüfer + └─ LayerLoader + └─ Logger + +Plan41_plugin/ +│ +├── plugin/ # Plugin-Code +│ ├── main_plugin.py # PluginController +│ ├── dock_widget.py # GUIManager +│ ├── pruefer/ +│ │ ├── dateipruefer.py +│ │ ├── linklistenpruefer.py +│ │ ├── zeilenpruefer.py +│ │ ├── linkpruefer.py +│ │ └── stilpruefer.py +│ ├── loader.py +│ └── logger.py +│ +├── tests/ # Unit-Tests +│ ├── __init__.py +│ ├── test_dateipruefer.py +│ ├── test_linklistenpruefer.py +│ ├── test_zeilenpruefer.py +│ ├── test_linkpruefer.py +│ ├── test_stilpruefer.py +│ ├── test_logger.py +│ └── run_tests.py # zentraler Test-Runner +│ +├── requirements.txt +└── README.md + + ++------------------------------------+ +| PluginController | ++------------------------------------+ +| - Dock_widget: GUIManager | +| - pruef_manager: PruefManager | +| - loader: LayerLoader | +| - logger: Logger | ++------------------------------------+ +| + start(): void | ++------------------------------------+ + ++------------------------------------+ +| GUIManager | ++------------------------------------+ +| - dialog: QWidget | ++------------------------------------+ +| + getParameter(): dict | ++------------------------------------+ + ++------------------------------------+ +| PruefManager | ++------------------------------------+ +| - dateipruefer: Dateipruefer | +| - linklistenpruefer: Linklisten... | ++------------------------------------+ +| + pruefe_alle(parameter): list | ++------------------------------------+ + ++------------------------------------+ +| Dateipruefer | ++------------------------------------+ +| + pruefe(pfad: str): PruefErgebnis | ++------------------------------------+ + ++------------------------------------+ +| Linklistenpruefer | ++------------------------------------+ +| + pruefe(pfad: str): list[Zeile] | ++------------------------------------+ + ++------------------------------------+ +| Zeilenpruefer | ++------------------------------------+ +| - linkpruefer: Linkpruefer | +| - stilpruefer: Stilpruefer | ++------------------------------------+ +| + pruefe(zeile: str): LayerAuftrag | ++------------------------------------+ + ++------------------------------------+ +| Linkpruefer | ++------------------------------------+ +| + pruefe(link: str): PruefErgebnis | ++------------------------------------+ + ++------------------------------------+ +| Stilpruefer | ++------------------------------------+ +| + pruefe(stilname: str): Pruef... | ++------------------------------------+ + ++------------------------------------+ +| LayerLoader | ++------------------------------------+ +| + lade(layer_auftrag): void | ++------------------------------------+ + ++------------------------------------+ +| Logger | ++------------------------------------+ +| + schreibe(msg: str): void | +| + exportiere(): file | ++------------------------------------+ + ++------------------------------------+ +| PruefErgebnis | ++------------------------------------+ +| - erfolgreich: bool | +| - daten: dict | +| - fehler: list[str] | +| - warnungen: list[str] | ++------------------------------------+ + diff --git a/assets/Pluginkonzept.md b/assets/Pluginkonzept.md new file mode 100644 index 0000000..fd3ff8a --- /dev/null +++ b/assets/Pluginkonzept.md @@ -0,0 +1,22 @@ +**Pluginkonzept** +Das Plugin ist grundsätzlich als modulares System gedacht. Komponenten sollen sowohl im Plugin selbst, aber auch in anderen Anwendungen verbaut werden können. +Die Module sind als Python-Objekte angelegt. +Alle Fallunterscheidungen, Exception-Management und Fehlerbehandlung sind in die "Prüfer" ausgelagert. +Der "Prüfmanager" übernimmt dabei die Interaktion mit dem Anwender, um Abfragen oder Fallunterscheidungen durchzuführen, die nicht anhand des Codes erfolgen können. +Alle Prüfer geben ein Objekt "Prüfergebnis" zurück, das das Ergebnis der Fallunterscheidung, Exceptions und Fehlermeldungen enthält. Die Prüfer haben selbst keine UI-Elemente. + +| Modul | Aufgabe | Beschreibung | +|-------------------|---------------------------------------|--------------| +|PruefManager | Nutzerabfragen, Ergebnisanpassung | Der Pruefmanager wertet das Ergebnis vom Typ "PruefErgebnis" aus. Sind Entscheidungen erforderlich, fragt er den Anwender und passt das PruefErgebnis entsprechend an, bzw gibt Fehler aus| +|Dateipruefer | Auswertung der Eingaben in Dateiauswahlfeldern | Der Dateipruefer prüft die Eingaben in Dateifeldern. Dabei kann bei jeder Prüfung vorgegeben werden, ob leere Eingabefelder zulässig sind, und ob sie, wenn sie leer sind, eine Standarddatei aufrufen oder temporäre Layer erzeugen. In jedem Fall wird der Nutzer zur Entscheidung aufgefordert, ob das leere Feld beabsichtigt ist, oder ein Bedienfehler| +|Linklistenpruefer | Spezialprüfer für die Linkliste aus dem Plan41-Plugin | Damit die beiden Objekte Stilpruefer und Linkpruefer auch unabhängig voneinander verwendet werden können, fasst der Linklistenpruefer die Ergebnisse zusammen und ergänzt eine Prüfung zur Kartenreihenfolge/Layerreihenfolge| +|Linkpruefer | prüft die Quelle eines angegebenen Links technisch und entscheidet die technischen Parameter nach Typ und Quellort | Enthält eine Fallunterscheidung für lokale und remote-Quellen, sowie für unterschiedliche Datenanbieter. Der Linkpruefer gibt Fehler und Exceptions zurück, wenn die Quelle fehlerhaft oder nicht erreichbar ist.| +|Stilpruefer | Prüft verschiedene Stilquellen | Der Stilpruefer prüft .qml und eingebettete Stile und gibt Warnungen zurück, bzw. Exceptions, um Nutzerentscheidungen auszulösen| + +Jedes Modul hat seinen eigenen Unittest. Die Tests werden im Unterordner "Test" zusammengefasst und können gesammelt über die "run_tests.py" aufgerufen werden. + +Jedes Modul wird durch ein Mermaid-ClassDiagram beschrieben. Die Entscheidungen und Exceptions, sowie die behandelten Fehler werden visuell aufbereitet. + +Zur Verarbeitung werden alle Nutzerinteraktionen und Angaben zunächst in den zuständigen Prüfer übergeben. Wenn vorhanden, mit den erforderlichen Parametern. Das Ergebnis wird zur Auswertung an den Pruefmanager übergeben. Dieser bereitet das Ergebnis auf, behandelt alle Exceptions und Anwenderentscheidungen und gibt die Daten mit den richtigen Parametern zur Weiterverarbeitung an die eigentliche Funktion. + + diff --git a/assets/Stilpruefer_flowchart.svg b/assets/Stilpruefer_flowchart.svg new file mode 100644 index 0000000..dafc0d0 --- /dev/null +++ b/assets/Stilpruefer_flowchart.svg @@ -0,0 +1 @@ +
Nein
Ja
Nein
Ja
Nein
Ja
Input Stilpfad
Stilpfad vorhanden?
Ergebnis: Erfolg, Stil=None, Warnung 'Kein Stil angegeben'
Datei existiert?
Ergebnis: Fehler 'Stildatei nicht gefunden'
Endet mit .qml?
Ergebnis: Fehler 'Ungültige Dateiendung'
Ergebnis: Erfolg, Stil=pfad
\ No newline at end of file diff --git a/assets/UML_Struktur.png b/assets/UML_Struktur.png new file mode 100644 index 0000000000000000000000000000000000000000..70fea303a3720dbd07bc07f635fb618d7432752d GIT binary patch literal 141866 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%0Alf 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�(rw7tyS&q?bX$z zxUtN7c?wn3#*V&lLKfz`D*SEp4aqOog%PKb2o0=A`?LW0Zj{!knbiS_C)LpzO8fDu zG)DmS>qeNm2INNQo|K+BT)!{q3Cal5z=^0L+AQz+!YSrthR4STS-N-lUCqll3(`2L zgM~^ut!2Q>*3#19tEZ4=0I{;OZIy;IYd;fLb;6&Q-~loJYZtwrbR=^p%2Qj~lKV7Y z)qwGGWC5+@I9&+i9V}BdWgNrOlHWy9m-+c)Ljy9#IB=o6J|sijS<$uf#t}`ULmD(Y z{BU!-dbXcqx%FUfYn7hMPP-gjfzn{w+O|7qAzom3o^QsLG2DN~X#q~SiP{|cdEvr^ z-tpTb>5Hgr(vJ_K!_TR?7eLu6vyVKc!JiGXi^a|7hEh5+A7vz3^}-zlxNq2`Jx^-e zFF)Rp`S$r4S62X%5@_MgPd}Td7qfdlxl02SB%D&x>TXNCt*}uT0RbBnYaSN37A0+x zb{J)nci`bP7O{k;mQ~NEI+Vs7Nr5{^1*xA>)}+#kQg)cWecKe;!67?#=J1Z02QoE( zQz?AsrxMWUpD!9rQ>$n``Qb48LWWX$XOme4X@=2QpzFPH=744hQBLL`QuT+FqE%yN zRCYLSp=?TSb_y}K8}UoCPb?Std@PUE4cYg=J`vYBF2MK9?iVuL?TeOdcZdhS@o<(hgz5>Vi&8-3xLO2b}<~0DCJI9R8&OW2)c9U zPGj{5phb|l)7g*L3)m#sIXIm1jw{BHl_sGvlEvx+=eB;>ONi#Nq(?lm5zaS%l{tk* zr$=}j8@sFR%CNn>zOL^CSt(ThX3z4Ty`FRrc~vNwrKPbSx#a2#e4^G g0Osghe~ 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 literal 0 HcmV?d00001 diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py new file mode 100644 index 0000000..166eccc --- /dev/null +++ b/modules/Dateipruefer.py @@ -0,0 +1,97 @@ +import os +from enum import Enum, auto + + +# ------------------------------- +# ENUMS +# ------------------------------- +class LeererPfadModus(Enum):#legt die modi fest, die für Dateipfade möglich sind + VERBOTEN = auto() #ein leeres Eingabefeld stellt einen Fehler dar + NUTZE_STANDARD = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: nutze Standard oder brich ab + TEMPORAER_ERLAUBT = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: arbeite temporär oder brich ab. + + +class DateiEntscheidung(Enum):#legt die Modi fest, wie mit bestehenden Dateien umgegangen werden soll (hat das das QGSFile-Objekt schon selbst?) + ERSETZEN = auto()#Ergebnis der Nutzerentscheidung: bestehende Datei ersetzen + ANHAENGEN = auto()#Ergebnis der Nutzerentscheidung: an bestehende Datei anhängen + ABBRECHEN = auto()#bricht den Vorgang ab. (muss das eine definierte Option sein? oder geht das auch mit einem normalen Abbruch-Button) + + +# ------------------------------- +# RÜCKGABEOBJEKT +# ------------------------------- +#Das Dateiprüfergebnis wird an den Prüfmanager übergeben. Alle GUI-Abfragen werden im Prüfmanager behandelt. +class DateipruefErgebnis: + #Definition der Parameter und Festlegung auf den Parametertyp,bzw den Standardwert + def __init__(self, erfolgreich: bool, pfad: str = None, temporär: bool = False, + entscheidung: DateiEntscheidung = None, fehler: list = None): + self.erfolgreich = erfolgreich + self.pfad = pfad + self.temporär = temporär + self.entscheidung = entscheidung + self.fehler = fehler or [] + + def __repr__(self): + return (f"DateipruefErgebnis(erfolgreich={self.erfolgreich}, " + f"pfad={repr(self.pfad)}, temporär={self.temporär}, " + f"entscheidung={repr(self.entscheidung)}, fehler={repr(self.fehler)})") + +# ------------------------------- +# DATEIPRÜFER +# ------------------------------- +class Dateipruefer: + def pruefe(self, pfad: str, + leer_modus: LeererPfadModus, + standardname: str = None, + plugin_pfad: str = None, + vorhandene_datei_entscheidung: DateiEntscheidung = None) -> DateipruefErgebnis: #Rückgabetypannotation; "Die Funktion "pruefe" gibt ein Objekt vom Typ "DateipruefErgebnis" zurück + + # 1. Prüfe, ob das Eingabefeld leer ist + if not pfad or pfad.strip() == "":#wenn der angegebene Pfad leer oder ungültig ist: + if leer_modus == LeererPfadModus.VERBOTEN: #wenn der Modus "verboten" vorgegeben ist, gib zurück, dass der Test fehlgeschlagen ist + return DateipruefErgebnis( + erfolgreich=False, + fehler=["Kein Pfad angegeben."] + ) + elif leer_modus == LeererPfadModus.NUTZE_STANDARD:#wenn der Modus "Nutze_Standard" vorgegeben ist... + if not plugin_pfad or not standardname:#wenn kein gültiger Pluginpfad angegeben ist oder die Standarddatei fehlt... + return DateipruefErgebnis( + erfolgreich=False, + fehler=["Standardpfad oder -name fehlen."]#..gib zurück, dass der Test fehlgeschlagen ist + ) + pfad = os.path.join(plugin_pfad, standardname)#...wenn es Standarddatei und Pluginpfad gibt...setze sie zum Pfad zusammen... + elif leer_modus == LeererPfadModus.TEMPORAER_ERLAUBT:#wenn der Modus "temporär" vorgegeben ist,... + return DateipruefErgebnis(#...gib zurück, dass das Prüfergebnis erfolgreich ist (Entscheidung, ob temporör gearbeitet werden soll oder nicht, kommt woanders) + erfolgreich=True, + pfad=None + ) + + # 2. Existiert die Datei bereits? + if os.path.exists(pfad):#wenn die Datei vorhanden ist... + if not vorhandene_datei_entscheidung:#aber noch keine Entscheidung getroffen ist... + return DateipruefErgebnis( + erfolgreich=True,#ist die Prüfung erfolgreich, aber es muss noch eine Entscheidung verlangt werden + pfad=pfad, + entscheidung=None, + fehler=["Datei existiert bereits – Entscheidung ausstehend."] + ) + + if vorhandene_datei_entscheidung == DateiEntscheidung.ABBRECHEN: + return DateipruefErgebnis(#...der Nutzer aber abgebrochen hat... + erfolgreich=False,#ist die Prüfung fehlgeschlagen ISSUE: ergibt das Sinn? + pfad=pfad, + fehler=["Benutzer hat abgebrochen."] + ) + + return DateipruefErgebnis( + erfolgreich=True, + pfad=pfad, + entscheidung=vorhandene_datei_entscheidung + ) + + # 3. Pfad gültig und Datei nicht vorhanden + #wenn alle Varianten NICHT zutreffen, weil ein gültiger Pfad eingegeben wurde und die Datei noch nicht vorhanden ist: + return DateipruefErgebnis( + erfolgreich=True, + pfad=pfad + ) diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py new file mode 100644 index 0000000..e3b97ce --- /dev/null +++ b/modules/Pruefmanager.py @@ -0,0 +1,51 @@ +from PyQt5.QtWidgets import QMessageBox, QFileDialog +from Dateipruefer import DateiEntscheidung + +class PruefManager: + + def __init__(self, iface=None, plugin_pfad=None): + self.iface = iface + self.plugin_pfad = plugin_pfad + + def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: + """Fragt den Nutzer, ob die vorhandene Datei ersetzt, angehängt oder abgebrochen werden soll.""" + msg = QMessageBox() + msg.setIcon(QMessageBox.Question) + msg.setWindowTitle("Datei existiert") + msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") + msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) + msg.setDefaultButton(QMessageBox.Yes) + msg.button(QMessageBox.Yes).setText("Ersetzen") + msg.button(QMessageBox.No).setText("Anhängen") + msg.button(QMessageBox.Cancel).setText("Abbrechen") + + result = msg.exec_() + + if result == QMessageBox.Yes: + return DateiEntscheidung.ERSETZEN + elif result == QMessageBox.No: + return DateiEntscheidung.ANHAENGEN + else: + return DateiEntscheidung.ABBRECHEN + + def frage_temporär_verwenden(self) -> bool: + """Fragt den Nutzer, ob mit temporären Layern gearbeitet werden soll.""" + msg = QMessageBox() + msg.setIcon(QMessageBox.Question) + msg.setWindowTitle("Temporäre Layer") + msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") + msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) + msg.setDefaultButton(QMessageBox.Yes) + + result = msg.exec_() + return result == QMessageBox.Yes + + def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: + """Öffnet einen QFileDialog zur Dateiauswahl.""" + pfad, _ = QFileDialog.getSaveFileName( + parent=None, + caption=titel, + directory=self.plugin_pfad or "", + filter=filter + ) + return pfad diff --git a/modules/__init__py b/modules/__init__py new file mode 100644 index 0000000..e69de29 diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py new file mode 100644 index 0000000..b840a25 --- /dev/null +++ b/modules/linkpruefer.py @@ -0,0 +1,94 @@ +# Importiert den Event-Loop und URL-Objekte aus der PyQt-Bibliothek von QGIS +from qgis.PyQt.QtCore import QEventLoop, QUrl +# Importiert den NetworkAccessManager aus dem QGIS Core-Modul +from qgis.core import QgsNetworkAccessManager +# Importiert das QNetworkRequest-Objekt für HTTP-Anfragen +from qgis.PyQt.QtNetwork import QNetworkRequest +# Importiert die Klasse für das Ergebnisobjekt der Prüfung +from pruef_ergebnis import PruefErgebnis + +# Definiert die Klasse zum Prüfen von Links +class Linkpruefer: + """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" + + # Statische Zuordnung möglicher Anbietertypen als Konstanten + ANBIETER_TYPEN: dict[str, str] = { + "REST": "REST", + "WFS": "WFS", + "WMS": "WMS", + "OGR": "OGR" + } + + # Konstruktor zum Initialisieren der Instanz + def __init__(self, link: str, anbieter: str): + # Speichert den übergebenen Link als Instanzvariable + self.link = link + + # Speichert den Anbietertyp, bereinigt und in Großbuchstaben (auch wenn leer oder None) + self.anbieter = anbieter.upper().strip() if anbieter else "" + # Erstellt einen neuen NetworkAccessManager für Netzwerkverbindungen + self.network_manager = QgsNetworkAccessManager() + + # Methode zur Klassifizierung des Anbieters und der Quelle + def klassifiziere_anbieter(self): + # Bestimmt den Typ auf Basis der vorgegebenen Konstante oder nimmt den Rohwert + typ = self.ANBIETER_TYPEN.get(self.anbieter, self.anbieter) + # Unterscheidet zwischen "remote" (http/https) oder "local" (Dateipfad) + quelle = "remote" if self.link.startswith(("http://", "https://")) else "local" + # Gibt Typ und Quelle als Dictionary zurück + return { + "typ": typ, + "quelle": quelle + } + + + # Prüft die Erreichbarkeit und Plausibilität des Links + def pruefe_link(self): + # Initialisiert Listen für Fehler und Warnungen + fehler = [] + warnungen = [] + + # Prüft, ob ein Link übergeben wurde + if not self.link: + fehler.append("Link fehlt.") + return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) + + # Prüft, ob ein Anbieter angegeben ist + if not self.anbieter or not self.anbieter.strip(): + fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") + + # Prüfung für Remote-Links (http/https) + if self.link.startswith(("http://", "https://")): + # Erstellt eine HTTP-Anfrage mit dem Link + request = QNetworkRequest(QUrl(self.link)) + # Startet eine HEAD-Anfrage über den NetworkManager + reply = self.network_manager.head(request) + + # Wartet synchron auf die Netzwerkanwort (Event Loop) + loop = QEventLoop() + reply.finished.connect(loop.quit) + loop.exec_() + + # Prüft auf Netzwerkfehler + if reply.error(): + fehler.append(f"Verbindungsfehler: {reply.errorString()}") + else: + # Holt den HTTP-Statuscode aus der Antwort + status = reply.attribute(reply.HttpStatusCodeAttribute) + # Prüft, ob der Status außerhalb des Erfolgsbereichs liegt + if status is None or status < 200 or status >= 400: + fehler.append(f"Link nicht erreichbar: HTTP {status}") + # Räumt die Antwort auf (Vermeidung von Speicherlecks) + reply.deleteLater() + else: + # Plausibilitäts-Check für lokale Links (Dateien), prüft auf Dateiendung + if "." not in self.link.split("/")[-1]: + warnungen.append("Der lokale Link sieht ungewöhnlich aus.") + + # Gibt das Ergebnisobjekt mit allen gesammelten Informationen zurück + return PruefErgebnis(len(fehler) == 0, daten=self.klassifiziere_anbieter(), fehler=fehler, warnungen=warnungen) + + # Führt die Linkprüfung als externe Methode aus + def ausfuehren(self): + # Gibt das Ergebnis der Prüf-Methode zurück + return self.pruefe_link() diff --git a/modules/pruef_ergebnis b/modules/pruef_ergebnis new file mode 100644 index 0000000..4f9b719 --- /dev/null +++ b/modules/pruef_ergebnis @@ -0,0 +1,11 @@ +# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann +class PruefErgebnis: + def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): + self.erfolgreich = erfolgreich + self.daten = daten or {} + self.fehler = fehler or [] + self.warnungen = warnungen or [] + + def __repr__(self): + return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " + f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py new file mode 100644 index 0000000..4f9b719 --- /dev/null +++ b/modules/pruef_ergebnis.py @@ -0,0 +1,11 @@ +# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann +class PruefErgebnis: + def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): + self.erfolgreich = erfolgreich + self.daten = daten or {} + self.fehler = fehler or [] + self.warnungen = warnungen or [] + + def __repr__(self): + return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " + f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py new file mode 100644 index 0000000..1ac65b1 --- /dev/null +++ b/modules/stilpruefer.py @@ -0,0 +1,45 @@ +import os +from pruef_ergebnis import PruefErgebnis + + +class Stilpruefer: + """ + Prüft, ob ein angegebener Stilpfad gültig und nutzbar ist. + - Wenn kein Stil angegeben ist, gilt die Prüfung als erfolgreich. + - Wenn angegeben: + * Datei muss existieren + * Dateiendung muss '.qml' sein + """ + + def pruefe(self, stilpfad: str) -> PruefErgebnis: + # kein Stil angegeben -> erfolgreich, keine Warnung + if not stilpfad or stilpfad.strip() == "": + return PruefErgebnis( + erfolgreich=True, + daten={"stil": None}, + warnungen=["Kein Stil angegeben."] + ) + + fehler = [] + warnungen = [] + + # Prüfung: Datei existiert? + if not os.path.exists(stilpfad): + fehler.append(f"Stildatei nicht gefunden: {stilpfad}") + + # Prüfung: Endung .qml? + elif not stilpfad.lower().endswith(".qml"): + fehler.append(f"Ungültige Dateiendung für Stil: {stilpfad}") + else: + # Hinweis: alle Checks bestanden + return PruefErgebnis( + erfolgreich=True, + daten={"stil": stilpfad} + ) + + return PruefErgebnis( + erfolgreich=False if fehler else True, + daten={"stil": stilpfad}, + fehler=fehler, + warnungen=warnungen + ) diff --git a/styles/GIS_63000F_Objekt_Denkmalschutz.qml b/styles/GIS_63000F_Objekt_Denkmalschutz.qml new file mode 100644 index 0000000..06bb9e5 --- /dev/null +++ b/styles/GIS_63000F_Objekt_Denkmalschutz.qml @@ -0,0 +1,609 @@ + + + + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "gml_id" + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + 0 + generatedlayout + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "gml_id" + + 2 + diff --git a/styles/GIS_Biotope_F.qml b/styles/GIS_Biotope_F.qml new file mode 100644 index 0000000..ed06272 --- /dev/null +++ b/styles/GIS_Biotope_F.qml @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml new file mode 100644 index 0000000..5e40734 --- /dev/null +++ b/styles/GIS_Flst_Beschriftung_ALKIS_NAS.qml @@ -0,0 +1,349 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/styles/GIS_LfULG_LSG.qml b/styles/GIS_LfULG_LSG.qml new file mode 100644 index 0000000..28082ba --- /dev/null +++ b/styles/GIS_LfULG_LSG.qml @@ -0,0 +1,371 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 2 + diff --git a/styles/verfahrensgebiet.qml b/styles/verfahrensgebiet.qml index 5504107..474e368 100644 --- a/styles/verfahrensgebiet.qml +++ b/styles/verfahrensgebiet.qml @@ -1,25 +1,83 @@ - - - 1 - 1 - 1 - - + + - - - - - - - - - - - - - + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 0 - 0 - 1 - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - . - - 0 - . - - 0 - generatedlayout - - - - - - - - - - - - - - - - - - - - - - COALESCE( "name", '<NULL>' ) - + + + + + + + + + + + + + + + + + + + 0 + 0 2 diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..324c4b2 --- /dev/null +++ b/test/__init__.py @@ -0,0 +1 @@ +#Testordner \ No newline at end of file diff --git a/test/run_tests.py b/test/run_tests.py new file mode 100644 index 0000000..6f94a3a --- /dev/null +++ b/test/run_tests.py @@ -0,0 +1,29 @@ +import sys +import os +import unittest + +# Projekt-Root dem Suchpfad hinzufügen +project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +def main(): + loader = unittest.TestLoader() + suite = unittest.TestSuite() + + test_modules = [ + "test_dateipruefer", + "test_stilpruefer", + "test_linkpruefer", + # "test_pruefmanager" enthält QGIS-spezifische Funktionen + ] + + for mod_name in test_modules: + mod = __import__(mod_name) + suite.addTests(loader.loadTestsFromModule(mod)) + + runner = unittest.TextTestRunner(verbosity=2) + runner.run(suite) + +if __name__ == "__main__": + main() diff --git a/test/start_osgeo4w_qgis.bat b/test/start_osgeo4w_qgis.bat new file mode 100644 index 0000000..a4b0c23 --- /dev/null +++ b/test/start_osgeo4w_qgis.bat @@ -0,0 +1,9 @@ +@echo off +SET OSGEO4W_ROOT=D:\QGISQT5 +call %OSGEO4W_ROOT%\bin\o4w_env.bat +set QGIS_PREFIX_PATH=%OSGEO4W_ROOT%\apps\qgis +set PYTHONPATH=%QGIS_PREFIX_PATH%\python;%PYTHONPATH% +set PATH=%OSGEO4W_ROOT%\bin;%QGIS_PREFIX_PATH%\bin;%PATH% + +REM Neue Eingabeaufforderung starten und Python-Skript ausführen +start cmd /k "python run_tests.py" diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py new file mode 100644 index 0000000..6f8ff7d --- /dev/null +++ b/test/test_dateipruefer.py @@ -0,0 +1,88 @@ +import unittest +import os +import tempfile +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from Dateipruefer import ( + Dateipruefer, + LeererPfadModus, + DateiEntscheidung, + DateipruefErgebnis +) + + +class TestDateipruefer(unittest.TestCase): + def setUp(self): + self.pruefer = Dateipruefer() + self.plugin_pfad = tempfile.gettempdir() + self.standardname = "test_standard.gpkg" + + def test_verbotener_leerer_pfad(self): + result = self.pruefer.pruefe( + pfad="", + leer_modus=LeererPfadModus.VERBOTEN + ) + self.assertFalse(result.erfolgreich) + self.assertIn("Kein Pfad angegeben.", result.fehler) + + def test_standardpfad_wird_verwendet(self): + result = self.pruefer.pruefe( + pfad="", + leer_modus=LeererPfadModus.NUTZE_STANDARD, + standardname=self.standardname, + plugin_pfad=self.plugin_pfad + ) + self.assertTrue(result.erfolgreich) + expected_path = os.path.join(self.plugin_pfad, self.standardname) + self.assertEqual(result.pfad, expected_path) + + def test_temporärer_layer_wird_erkannt(self): + result = self.pruefer.pruefe( + pfad="", + leer_modus=LeererPfadModus.TEMPORAER_ERLAUBT + ) + self.assertTrue(result.erfolgreich) + self.assertIsNone(result.pfad) + self.assertFalse(result.temporär) + + def test_existierende_datei_ohne_entscheidung(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + result = self.pruefer.pruefe( + pfad=tmp_path, + leer_modus=LeererPfadModus.VERBOTEN + ) + self.assertTrue(result.erfolgreich) # neu: jetzt True, nicht False + self.assertIn("Datei existiert bereits – Entscheidung ausstehend.", result.fehler) + self.assertIsNone(result.entscheidung) + finally: + os.remove(tmp_path) + + def test_existierende_datei_mit_entscheidung_ersetzen(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + result = self.pruefer.pruefe( + pfad=tmp_path, + leer_modus=LeererPfadModus.VERBOTEN, + vorhandene_datei_entscheidung=DateiEntscheidung.ERSETZEN + ) + self.assertTrue(result.erfolgreich) + self.assertEqual(result.entscheidung, DateiEntscheidung.ERSETZEN) + finally: + os.remove(tmp_path) + + def test_datei_nicht_existiert(self): + fake_path = os.path.join(self.plugin_pfad, "nicht_existierend.gpkg") + result = self.pruefer.pruefe( + pfad=fake_path, + leer_modus=LeererPfadModus.VERBOTEN + ) + self.assertTrue(result.erfolgreich) + self.assertEqual(result.pfad, fake_path) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py new file mode 100644 index 0000000..35baeb3 --- /dev/null +++ b/test/test_linkpruefer.py @@ -0,0 +1,125 @@ +# test/test_linkpruefer.py + +import unittest +import sys +from unittest.mock import patch +from qgis.PyQt.QtCore import QCoreApplication, QTimer +from qgis.PyQt.QtNetwork import QNetworkRequest + +from linkpruefer import Linkpruefer + +# Stelle sicher, dass eine Qt-App existiert +app = QCoreApplication.instance() +if app is None: + app = QCoreApplication(sys.argv) + + +class DummyReply: + """Fake-Reply, um Netzwerkabfragen zu simulieren""" + HttpStatusCodeAttribute = QNetworkRequest.HttpStatusCodeAttribute + + def __init__(self, error, status_code): + self._error = error + self._status_code = status_code + self.finished = self # Fake-Signal + + def connect(self, slot): + # sorgt dafür, dass loop.quit() nach Start von loop.exec_() ausgeführt wird + QTimer.singleShot(0, slot) + + def error(self): + return self._error + + def errorString(self): + return "Simulierter Fehler" if self._error != 0 else "" + + def attribute(self, attr): + if attr == self.HttpStatusCodeAttribute: + return self._status_code + return None + + def deleteLater(self): + # kein echtes QObject → wir tun einfach nichts + pass + + +class TestLinkpruefer(unittest.TestCase): + """Tests für alle Funktionen des Linkpruefer""" + + # ---------------------------- + # Remote-Tests mit DummyReply + # ---------------------------- + @patch('linkpruefer.QgsNetworkAccessManager.head') + def test_remote_link_success(self, mock_head): + mock_head.return_value = DummyReply(0, 200) + + checker = Linkpruefer("https://example.com/service", "REST") + result = checker.ausfuehren() + + self.assertTrue(result.erfolgreich) + self.assertEqual(result.daten['typ'], 'REST') + self.assertEqual(result.daten['quelle'], 'remote') + self.assertEqual(result.fehler, []) + self.assertEqual(result.warnungen, []) + + @patch('linkpruefer.QgsNetworkAccessManager.head') + def test_remote_link_failure(self, mock_head): + mock_head.return_value = DummyReply(1, 404) + + checker = Linkpruefer("https://example.com/kaputt", "WMS") + result = checker.ausfuehren() + + self.assertFalse(result.erfolgreich) + self.assertIn("Verbindungsfehler: Simulierter Fehler", result.fehler) + + # ---------------------------- + # Klassifizierungstests + # ---------------------------- + def test_klassifiziere_anbieter_remote(self): + checker = Linkpruefer("https://beispiel.de", "wms") + daten = checker.klassifiziere_anbieter() + self.assertEqual(daten["typ"], "WMS") + self.assertEqual(daten["quelle"], "remote") + + def test_klassifiziere_anbieter_local(self): + checker = Linkpruefer("C:/daten/test.shp", "ogr") + daten = checker.klassifiziere_anbieter() + self.assertEqual(daten["typ"], "OGR") + self.assertEqual(daten["quelle"], "local") + + # ---------------------------- + # Lokale Links + # ---------------------------- + def test_pruefe_link_local_ok(self): + checker = Linkpruefer("C:/daten/test.shp", "OGR") + result = checker.pruefe_link() + self.assertTrue(result.erfolgreich) + self.assertEqual(result.warnungen, []) + + def test_pruefe_link_local_warnung(self): + checker = Linkpruefer("C:/daten/ordner/", "OGR") + result = checker.pruefe_link() + self.assertTrue(result.erfolgreich) + self.assertIn("ungewöhnlich", result.warnungen[0]) + + # ---------------------------- + # Sonderfall: leerer Link + # ---------------------------- + def test_pruefe_link_empty(self): + checker = Linkpruefer("", "REST") + result = checker.pruefe_link() + self.assertFalse(result.erfolgreich) + self.assertIn("Link fehlt.", result.fehler) + + # ---------------------------- + # leerer Anbieter + # ---------------------------- + def test_pruefe_link_leerer_anbieter(self): + checker = Linkpruefer("https://example.com/service", "") + result = checker.pruefe_link() + self.assertFalse(result.erfolgreich) + self.assertIn("Anbieter muss gesetzt werden und darf nicht leer sein.", result.fehler) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py new file mode 100644 index 0000000..a33d4e5 --- /dev/null +++ b/test/test_pruefmanager.py @@ -0,0 +1,36 @@ +import unittest +import os +from unittest.mock import patch +from pruefmanager import PruefManager +from Dateipruefer import DateiEntscheidung +import sys + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +class TestPruefManager(unittest.TestCase): + + def setUp(self): + self.manager = PruefManager(plugin_pfad="/tmp") + + @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) + def test_frage_datei_ersetzen(self, mock_msgbox): + entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") + self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN) + + @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) + def test_frage_datei_anhaengen(self, mock_msgbox): + entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") + self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN) + + @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Cancel) + def test_frage_datei_abbrechen(self, mock_msgbox): + entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") + self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + + @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) + def test_frage_temporär_verwenden_ja(self, mock_msgbox): + self.assertTrue(self.manager.frage_temporär_verwenden()) + + @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) + def test_frage_temporär_verwenden_nein(self, mock_msgbox): + self.assertFalse(self.manager.frage_temporär_verwenden()) diff --git a/test/test_stilpruefer.py b/test/test_stilpruefer.py new file mode 100644 index 0000000..ea37db7 --- /dev/null +++ b/test/test_stilpruefer.py @@ -0,0 +1,47 @@ +import unittest +import tempfile +import os +from stilpruefer import Stilpruefer +from pruef_ergebnis import PruefErgebnis + + +class TestStilpruefer(unittest.TestCase): + def setUp(self): + self.pruefer = Stilpruefer() + + def test_keine_datei_angegeben(self): + result = self.pruefer.pruefe("") + self.assertTrue(result.erfolgreich) + self.assertIn("Kein Stil angegeben.", result.warnungen) + self.assertIsNone(result.daten["stil"]) + + def test_datei_existiert_mit_qml(self): + with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + result = self.pruefer.pruefe(tmp_path) + self.assertTrue(result.erfolgreich) + self.assertEqual(result.daten["stil"], tmp_path) + self.assertEqual(result.fehler, []) + finally: + os.remove(tmp_path) + + def test_datei_existiert_falsche_endung(self): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp_file: + tmp_path = tmp_file.name + try: + result = self.pruefer.pruefe(tmp_path) + self.assertFalse(result.erfolgreich) + self.assertIn("Ungültige Dateiendung", result.fehler[0]) + finally: + os.remove(tmp_path) + + def test_datei_existiert_nicht(self): + fake_path = os.path.join(tempfile.gettempdir(), "nichtvorhanden.qml") + result = self.pruefer.pruefe(fake_path) + self.assertFalse(result.erfolgreich) + self.assertIn("Stildatei nicht gefunden", result.fehler[0]) + + +if __name__ == "__main__": + unittest.main() From 2d67ce8adcf059e99a91502404994198159b9e6b Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 17 Dec 2025 11:41:17 +0100 Subject: [PATCH 02/11] =?UTF-8?q?Datenbank=20=C3=BCberarbeitet,=20V=20Absp?= =?UTF-8?q?rache=20Andreas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Pluginkonzept.md | 2 +- assets/moduluebersicht.md | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 assets/moduluebersicht.md diff --git a/assets/Pluginkonzept.md b/assets/Pluginkonzept.md index fd3ff8a..14f41dc 100644 --- a/assets/Pluginkonzept.md +++ b/assets/Pluginkonzept.md @@ -19,4 +19,4 @@ Jedes Modul wird durch ein Mermaid-ClassDiagram beschrieben. Die Entscheidungen Zur Verarbeitung werden alle Nutzerinteraktionen und Angaben zunächst in den zuständigen Prüfer übergeben. Wenn vorhanden, mit den erforderlichen Parametern. Das Ergebnis wird zur Auswertung an den Pruefmanager übergeben. Dieser bereitet das Ergebnis auf, behandelt alle Exceptions und Anwenderentscheidungen und gibt die Daten mit den richtigen Parametern zur Weiterverarbeitung an die eigentliche Funktion. - +Der Prüfmanager, die Stile und weitere, universelle Bausteine sind im Plugin sn_basis abgelegt und werden von dort in anderen Modulen verwendet. diff --git a/assets/moduluebersicht.md b/assets/moduluebersicht.md new file mode 100644 index 0000000..af9f90c --- /dev/null +++ b/assets/moduluebersicht.md @@ -0,0 +1,8 @@ +```mermaid +graph TD + M1["
➡ Initialisierung der GUI
➡ Exception Handling
➡ Bereitstellung der Stile"] + M2["
sn_verfahrensgebiet

➡ Abruf und Aufbereitung der Gebietsgrenze"
➡ Erstellung neuer Gebietsgrenzen
➡ Grenzpunktextraktion
➡ Grenzpunktprüfung] + M3["
sn_Plan41

➡ Fachdatenabruf
➡Versionierung der Fachdaten
➡ Planung der TG-Maßnahmen
➡Kartenerzeugung (NGG und P41)
➡ Erzeugung der Begleitdokumente (Anlagenverzeichnis, MVZ, Maßnahmeblätter)"] + + M1 --> M2 + M1 --> M3 From f64d56d4bcddeb86555cc330da612ed05f507bb2 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 17 Dec 2025 17:45:18 +0100 Subject: [PATCH 03/11] =?UTF-8?q?Tests=20=C3=BCberarbeitet,=20Mocks=20und?= =?UTF-8?q?=20coverage=20eingef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .coveragerc | 12 +++ assets/moduluebersicht.md | 1 + modules/Dateipruefer.py | 3 + modules/Datenbankpruefer.py | 1 + modules/Pruefmanager.py | 39 ++++----- modules/linkpruefer.py | 73 +++++++---------- modules/pruef_ergebnis | 11 --- modules/pruef_ergebnis.py | 1 + modules/qt_compat.py | 111 +++++++++++++++++++++++++ modules/stilpruefer.py | 3 +- test/run_tests.py | 100 ++++++++++++++++++++++- test/test_dateipruefer.py | 8 +- test/test_linkpruefer.py | 159 +++++++++++++----------------------- test/test_pruefmanager.py | 79 ++++++++++++++---- test/test_qgis.bat | 52 ++++++++++++ test/test_qt_compat.py | 100 +++++++++++++++++++++++ test/test_stilpruefer.py | 10 ++- 17 files changed, 562 insertions(+), 201 deletions(-) create mode 100644 .coveragerc create mode 100644 modules/Datenbankpruefer.py delete mode 100644 modules/pruef_ergebnis create mode 100644 modules/qt_compat.py create mode 100644 test/test_qgis.bat create mode 100644 test/test_qt_compat.py diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..4087545 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,12 @@ +[run] +source = modules +omit = + */test/* + */__init__.py + +[report] +show_missing = True +skip_covered = False + +[html] +directory = coverage_html diff --git a/assets/moduluebersicht.md b/assets/moduluebersicht.md index af9f90c..4375bab 100644 --- a/assets/moduluebersicht.md +++ b/assets/moduluebersicht.md @@ -6,3 +6,4 @@ graph TD M1 --> M2 M1 --> M3 +``` \ No newline at end of file diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 166eccc..8e29eb1 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,3 +1,6 @@ +#Modul zur Prüfung und zum Exception Handling für Dateieingaben +#Dateipruefer.py + import os from enum import Enum, auto diff --git a/modules/Datenbankpruefer.py b/modules/Datenbankpruefer.py new file mode 100644 index 0000000..5843763 --- /dev/null +++ b/modules/Datenbankpruefer.py @@ -0,0 +1 @@ +#Datenbankpruefer.py \ No newline at end of file diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index e3b97ce..a766aa3 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,5 +1,6 @@ -from PyQt5.QtWidgets import QMessageBox, QFileDialog -from Dateipruefer import DateiEntscheidung +#Pruefmanager.py +from modules.qt_compat import QMessageBox, QFileDialog, YES, NO, CANCEL, ICON_QUESTION, exec_dialog +from modules.Dateipruefer import DateiEntscheidung class PruefManager: @@ -8,40 +9,40 @@ class PruefManager: self.plugin_pfad = plugin_pfad def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: - """Fragt den Nutzer, ob die vorhandene Datei ersetzt, angehängt oder abgebrochen werden soll.""" msg = QMessageBox() - msg.setIcon(QMessageBox.Question) + msg.setIcon(ICON_QUESTION) msg.setWindowTitle("Datei existiert") msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") - msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) - msg.setDefaultButton(QMessageBox.Yes) - msg.button(QMessageBox.Yes).setText("Ersetzen") - msg.button(QMessageBox.No).setText("Anhängen") - msg.button(QMessageBox.Cancel).setText("Abbrechen") - result = msg.exec_() + msg.setStandardButtons(YES | NO | CANCEL) + msg.setDefaultButton(YES) - if result == QMessageBox.Yes: + msg.button(YES).setText("Ersetzen") + msg.button(NO).setText("Anhängen") + msg.button(CANCEL).setText("Abbrechen") + + result = exec_dialog(msg) + + if result == YES: return DateiEntscheidung.ERSETZEN - elif result == QMessageBox.No: + elif result == NO: return DateiEntscheidung.ANHAENGEN else: return DateiEntscheidung.ABBRECHEN def frage_temporär_verwenden(self) -> bool: - """Fragt den Nutzer, ob mit temporären Layern gearbeitet werden soll.""" msg = QMessageBox() - msg.setIcon(QMessageBox.Question) + msg.setIcon(ICON_QUESTION) msg.setWindowTitle("Temporäre Layer") msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") - msg.setStandardButtons(QMessageBox.Yes | QMessageBox.No) - msg.setDefaultButton(QMessageBox.Yes) - result = msg.exec_() - return result == QMessageBox.Yes + msg.setStandardButtons(YES | NO) + msg.setDefaultButton(YES) + + result = exec_dialog(msg) + return result == YES def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: - """Öffnet einen QFileDialog zur Dateiauswahl.""" pfad, _ = QFileDialog.getSaveFileName( parent=None, caption=titel, diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index b840a25..9283c85 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -1,17 +1,19 @@ -# Importiert den Event-Loop und URL-Objekte aus der PyQt-Bibliothek von QGIS -from qgis.PyQt.QtCore import QEventLoop, QUrl -# Importiert den NetworkAccessManager aus dem QGIS Core-Modul -from qgis.core import QgsNetworkAccessManager -# Importiert das QNetworkRequest-Objekt für HTTP-Anfragen -from qgis.PyQt.QtNetwork import QNetworkRequest -# Importiert die Klasse für das Ergebnisobjekt der Prüfung -from pruef_ergebnis import PruefErgebnis +# Linkpruefer.py – Qt5/Qt6-kompatibel über qt_compat + +from modules.qt_compat import ( + QEventLoop, + QUrl, + QNetworkRequest, + QNetworkReply +) + +from qgis.core import QgsNetworkAccessManager +from modules.pruef_ergebnis import PruefErgebnis + -# Definiert die Klasse zum Prüfen von Links class Linkpruefer: """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" - # Statische Zuordnung möglicher Anbietertypen als Konstanten ANBIETER_TYPEN: dict[str, str] = { "REST": "REST", "WFS": "WFS", @@ -19,76 +21,57 @@ class Linkpruefer: "OGR": "OGR" } - # Konstruktor zum Initialisieren der Instanz def __init__(self, link: str, anbieter: str): - # Speichert den übergebenen Link als Instanzvariable self.link = link - - # Speichert den Anbietertyp, bereinigt und in Großbuchstaben (auch wenn leer oder None) self.anbieter = anbieter.upper().strip() if anbieter else "" - # Erstellt einen neuen NetworkAccessManager für Netzwerkverbindungen self.network_manager = QgsNetworkAccessManager() - # Methode zur Klassifizierung des Anbieters und der Quelle def klassifiziere_anbieter(self): - # Bestimmt den Typ auf Basis der vorgegebenen Konstante oder nimmt den Rohwert typ = self.ANBIETER_TYPEN.get(self.anbieter, self.anbieter) - # Unterscheidet zwischen "remote" (http/https) oder "local" (Dateipfad) quelle = "remote" if self.link.startswith(("http://", "https://")) else "local" - # Gibt Typ und Quelle als Dictionary zurück - return { - "typ": typ, - "quelle": quelle - } + return {"typ": typ, "quelle": quelle} - - # Prüft die Erreichbarkeit und Plausibilität des Links def pruefe_link(self): - # Initialisiert Listen für Fehler und Warnungen fehler = [] warnungen = [] - # Prüft, ob ein Link übergeben wurde if not self.link: fehler.append("Link fehlt.") return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) - - # Prüft, ob ein Anbieter angegeben ist + if not self.anbieter or not self.anbieter.strip(): fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") - # Prüfung für Remote-Links (http/https) + # Remote-Links prüfen if self.link.startswith(("http://", "https://")): - # Erstellt eine HTTP-Anfrage mit dem Link request = QNetworkRequest(QUrl(self.link)) - # Startet eine HEAD-Anfrage über den NetworkManager reply = self.network_manager.head(request) - # Wartet synchron auf die Netzwerkanwort (Event Loop) loop = QEventLoop() reply.finished.connect(loop.quit) - loop.exec_() + loop.exec() # Qt5/Qt6-kompatibel über qt_compat - # Prüft auf Netzwerkfehler - if reply.error(): + # Fehlerprüfung Qt5/Qt6-kompatibel + if reply.error() != QNetworkReply.NetworkError.NoError: fehler.append(f"Verbindungsfehler: {reply.errorString()}") else: - # Holt den HTTP-Statuscode aus der Antwort - status = reply.attribute(reply.HttpStatusCodeAttribute) - # Prüft, ob der Status außerhalb des Erfolgsbereichs liegt + status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) if status is None or status < 200 or status >= 400: fehler.append(f"Link nicht erreichbar: HTTP {status}") - # Räumt die Antwort auf (Vermeidung von Speicherlecks) + reply.deleteLater() + else: - # Plausibilitäts-Check für lokale Links (Dateien), prüft auf Dateiendung + # Lokale Pfade: Plausibilitätscheck if "." not in self.link.split("/")[-1]: warnungen.append("Der lokale Link sieht ungewöhnlich aus.") - # Gibt das Ergebnisobjekt mit allen gesammelten Informationen zurück - return PruefErgebnis(len(fehler) == 0, daten=self.klassifiziere_anbieter(), fehler=fehler, warnungen=warnungen) + return PruefErgebnis( + len(fehler) == 0, + daten=self.klassifiziere_anbieter(), + fehler=fehler, + warnungen=warnungen + ) - # Führt die Linkprüfung als externe Methode aus def ausfuehren(self): - # Gibt das Ergebnis der Prüf-Methode zurück return self.pruefe_link() diff --git a/modules/pruef_ergebnis b/modules/pruef_ergebnis deleted file mode 100644 index 4f9b719..0000000 --- a/modules/pruef_ergebnis +++ /dev/null @@ -1,11 +0,0 @@ -# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann -class PruefErgebnis: - def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): - self.erfolgreich = erfolgreich - self.daten = daten or {} - self.fehler = fehler or [] - self.warnungen = warnungen or [] - - def __repr__(self): - return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " - f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index 4f9b719..54ecda4 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,3 +1,4 @@ +#pruef_ergebnis.py # Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann class PruefErgebnis: def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): diff --git a/modules/qt_compat.py b/modules/qt_compat.py new file mode 100644 index 0000000..dca7495 --- /dev/null +++ b/modules/qt_compat.py @@ -0,0 +1,111 @@ +""" +qt_compat.py – Einheitliche Qt-Kompatibilitätsschicht für QGIS-Plugins. + +Ziele: +- PyQt6 bevorzugt +- Fallback auf PyQt5 +- Mock-Modus, wenn kein Qt verfügbar ist (z. B. in Unittests) +- OR-fähige Fake-Enums im Mock-Modus +""" + +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 + +# --------------------------------------------------------- +# Versuch: PyQt6 importieren +# --------------------------------------------------------- +try: + from PyQt6.QtWidgets import QMessageBox, QFileDialog + from PyQt6.QtCore import Qt, QEventLoop, QUrl + from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply + + YES = QMessageBox.StandardButton.Yes + NO = QMessageBox.StandardButton.No + CANCEL = QMessageBox.StandardButton.Cancel + ICON_QUESTION = QMessageBox.Icon.Question + + QT_VERSION = 6 + + def exec_dialog(dialog): + """Einheitliche Ausführung eines Dialogs.""" + return dialog.exec() + +# --------------------------------------------------------- +# Versuch: PyQt5 importieren +# --------------------------------------------------------- +except Exception: + try: + from PyQt5.QtWidgets import QMessageBox, QFileDialog + from PyQt5.QtCore import Qt, QEventLoop, QUrl + from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply + + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + + QT_VERSION = 5 + + def exec_dialog(dialog): + return dialog.exec_() + + # --------------------------------------------------------- + # Mock-Modus (kein Qt verfügbar) + # --------------------------------------------------------- + except Exception: + QT_VERSION = 0 + + class FakeEnum(int): + """Ein OR-fähiger Enum-Ersatz für den Mock-Modus.""" + def __or__(self, other): + return FakeEnum(int(self) | int(other)) + + class QMessageBox: + Yes = FakeEnum(1) + No = FakeEnum(2) + Cancel = FakeEnum(4) + Question = FakeEnum(8) + + class QFileDialog: + """Minimaler Mock für QFileDialog.""" + @staticmethod + def getOpenFileName(*args, **kwargs): + return ("", "") # kein Dateipfad + + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + + def exec_dialog(dialog): + """Mock-Ausführung: gibt YES zurück, außer Tests patchen es.""" + return YES + # ------------------------- + # Mock Netzwerk-Klassen + # ------------------------- + class QEventLoop: + def exec(self): + return 0 + + def quit(self): + pass + + class QUrl(str): + pass + + class QNetworkRequest: + def __init__(self, url): + self.url = url + + class QNetworkReply: + def __init__(self): + self._data = b"" + + def readAll(self): + return self._data + + def error(self): + return 0 + + def exec_dialog(dialog): + return YES + \ No newline at end of file diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py index 1ac65b1..dc43f4d 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -1,5 +1,6 @@ +#stilpruefer.py import os -from pruef_ergebnis import PruefErgebnis +from modules.pruef_ergebnis import PruefErgebnis class Stilpruefer: diff --git a/test/run_tests.py b/test/run_tests.py index 6f94a3a..a0ec181 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -1,12 +1,106 @@ +#run_tests.py import sys import os import unittest +import datetime +import inspect + +# Farben +RED = "\033[91m" +YELLOW = "\033[93m" +GREEN = "\033[92m" +CYAN = "\033[96m" +MAGENTA = "\033[95m" +RESET = "\033[0m" + +# Globaler Testzähler +GLOBAL_TEST_COUNTER = 0 + + +# --------------------------------------------------------- +# Eigene TestResult-Klasse (färbt Fehler/Skipped/OK) +# --------------------------------------------------------- +class ColoredTestResult(unittest.TextTestResult): + + def startTest(self, test): + """Vor jedem Test eine Nummer ausgeben.""" + global GLOBAL_TEST_COUNTER + GLOBAL_TEST_COUNTER += 1 + self.stream.write(f"{CYAN}[Test {GLOBAL_TEST_COUNTER}]{RESET}\n") + super().startTest(test) + + def startTestRun(self): + """Wird einmal zu Beginn des gesamten Testlaufs ausgeführt.""" + super().startTestRun() + + def startTestClass(self, test): + """Wird aufgerufen, wenn eine neue Testklasse beginnt.""" + cls = test.__class__ + file = inspect.getfile(cls) + filename = os.path.basename(file) + + self.stream.write( + f"\n{MAGENTA}{'='*70}\n" + f"Starte Testklasse: {filename} → {cls.__name__}\n" + f"{'='*70}{RESET}\n" + ) + + def addError(self, test, err): + super().addError(test, err) + self.stream.write(f"{RED}ERROR{RESET}\n") + + def addFailure(self, test, err): + super().addFailure(test, err) + self.stream.write(f"{RED}FAILURE{RESET}\n") + + def addSkip(self, test, reason): + super().addSkip(test, reason) + self.stream.write(f"{YELLOW}SKIPPED{RESET}: {reason}\n") + + # unittest ruft diese Methode nicht automatisch auf → wir patchen es unten + def addSuccess(self, test): + super().addSuccess(test) + self.stream.write(f"{GREEN}OK{RESET}\n") + + +# --------------------------------------------------------- +# Eigener TestRunner, der unser ColoredTestResult nutzt +# --------------------------------------------------------- +class ColoredTestRunner(unittest.TextTestRunner): + resultclass = ColoredTestResult + + def _makeResult(self): + result = super()._makeResult() + + # Patch: unittest ruft startTestClass nicht automatisch auf + original_start_test = result.startTest + + def patched_start_test(test): + # Wenn neue Klasse → Kopf ausgeben + if not hasattr(result, "_last_test_class") or \ + result._last_test_class != test.__class__: + result.startTestClass(test) + result._last_test_class = test.__class__ + + original_start_test(test) + + result.startTest = patched_start_test + return result + + +# --------------------------------------------------------- +# Testlauf starten +# --------------------------------------------------------- +print("\n" + "="*70) +print(f"{CYAN}Testlauf gestartet am: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}") +print("="*70 + "\n") # Projekt-Root dem Suchpfad hinzufügen project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if project_root not in sys.path: sys.path.insert(0, project_root) + def main(): loader = unittest.TestLoader() suite = unittest.TestSuite() @@ -15,15 +109,17 @@ def main(): "test_dateipruefer", "test_stilpruefer", "test_linkpruefer", - # "test_pruefmanager" enthält QGIS-spezifische Funktionen + "test_qt_compat", + "test_pruefmanager", ] for mod_name in test_modules: mod = __import__(mod_name) suite.addTests(loader.loadTestsFromModule(mod)) - runner = unittest.TextTestRunner(verbosity=2) + runner = ColoredTestRunner(verbosity=2) runner.run(suite) + if __name__ == "__main__": main() diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py index 6f8ff7d..f6f537b 100644 --- a/test/test_dateipruefer.py +++ b/test/test_dateipruefer.py @@ -1,10 +1,12 @@ +#test_dateipruefer.py import unittest import os import tempfile import sys - -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from Dateipruefer import ( +# Plugin-Root ermitteln (ein Verzeichnis über "test") +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) +from modules.Dateipruefer import ( Dateipruefer, LeererPfadModus, DateiEntscheidung, diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py index 35baeb3..d9d4206 100644 --- a/test/test_linkpruefer.py +++ b/test/test_linkpruefer.py @@ -1,125 +1,78 @@ -# test/test_linkpruefer.py - +#test_linkpruefer.py import unittest -import sys -from unittest.mock import patch -from qgis.PyQt.QtCore import QCoreApplication, QTimer -from qgis.PyQt.QtNetwork import QNetworkRequest +from unittest.mock import MagicMock, patch -from linkpruefer import Linkpruefer - -# Stelle sicher, dass eine Qt-App existiert -app = QCoreApplication.instance() -if app is None: - app = QCoreApplication(sys.argv) - - -class DummyReply: - """Fake-Reply, um Netzwerkabfragen zu simulieren""" - HttpStatusCodeAttribute = QNetworkRequest.HttpStatusCodeAttribute - - def __init__(self, error, status_code): - self._error = error - self._status_code = status_code - self.finished = self # Fake-Signal - - def connect(self, slot): - # sorgt dafür, dass loop.quit() nach Start von loop.exec_() ausgeführt wird - QTimer.singleShot(0, slot) - - def error(self): - return self._error - - def errorString(self): - return "Simulierter Fehler" if self._error != 0 else "" - - def attribute(self, attr): - if attr == self.HttpStatusCodeAttribute: - return self._status_code - return None - - def deleteLater(self): - # kein echtes QObject → wir tun einfach nichts - pass +# QGIS-Module mocken, damit der Import funktioniert +with patch.dict("sys.modules", { + "qgis": MagicMock(), + "qgis.PyQt": MagicMock(), + "qgis.PyQt.QtCore": MagicMock(), + "qgis.PyQt.QtNetwork": MagicMock(), + "qgis.core": MagicMock(), +}): + from modules.linkpruefer import Linkpruefer class TestLinkpruefer(unittest.TestCase): - """Tests für alle Funktionen des Linkpruefer""" - # ---------------------------- - # Remote-Tests mit DummyReply - # ---------------------------- - @patch('linkpruefer.QgsNetworkAccessManager.head') - def test_remote_link_success(self, mock_head): - mock_head.return_value = DummyReply(0, 200) + @patch("modules.linkpruefer.QNetworkReply") + @patch("modules.linkpruefer.QNetworkRequest") + @patch("modules.linkpruefer.QUrl") + @patch("modules.linkpruefer.QEventLoop") + @patch("modules.linkpruefer.QgsNetworkAccessManager") + def test_remote_link_ok( + self, mock_manager, mock_loop, mock_url, mock_request, mock_reply + ): + # Setup: simulate successful HEAD request + reply_instance = MagicMock() + reply_instance.error.return_value = mock_reply.NetworkError.NoError + reply_instance.attribute.return_value = 200 - checker = Linkpruefer("https://example.com/service", "REST") - result = checker.ausfuehren() + mock_manager.return_value.head.return_value = reply_instance + + lp = Linkpruefer("http://example.com", "REST") + result = lp.pruefe_link() self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten['typ'], 'REST') - self.assertEqual(result.daten['quelle'], 'remote') - self.assertEqual(result.fehler, []) - self.assertEqual(result.warnungen, []) + self.assertEqual(result.daten["quelle"], "remote") - @patch('linkpruefer.QgsNetworkAccessManager.head') - def test_remote_link_failure(self, mock_head): - mock_head.return_value = DummyReply(1, 404) + @patch("modules.linkpruefer.QNetworkReply") + @patch("modules.linkpruefer.QNetworkRequest") + @patch("modules.linkpruefer.QUrl") + @patch("modules.linkpruefer.QEventLoop") + @patch("modules.linkpruefer.QgsNetworkAccessManager") + def test_remote_link_error( + self, mock_manager, mock_loop, mock_url, mock_request, mock_reply + ): + # Fake-Reply erzeugen + reply_instance = MagicMock() + reply_instance.error.return_value = mock_reply.NetworkError.ConnectionRefusedError + reply_instance.errorString.return_value = "Connection refused" - checker = Linkpruefer("https://example.com/kaputt", "WMS") - result = checker.ausfuehren() + # WICHTIG: finished-Signal simulieren + reply_instance.finished = MagicMock() + reply_instance.finished.connect = MagicMock() + + # Wenn loop.exec() aufgerufen wird, rufen wir loop.quit() sofort auf + mock_loop.return_value.exec.side_effect = lambda: mock_loop.return_value.quit() + + # Manager gibt unser Fake-Reply zurück + mock_manager.return_value.head.return_value = reply_instance + + lp = Linkpruefer("http://example.com", "REST") + result = lp.pruefe_link() self.assertFalse(result.erfolgreich) - self.assertIn("Verbindungsfehler: Simulierter Fehler", result.fehler) + self.assertIn("Verbindungsfehler", result.fehler[0]) - # ---------------------------- - # Klassifizierungstests - # ---------------------------- - def test_klassifiziere_anbieter_remote(self): - checker = Linkpruefer("https://beispiel.de", "wms") - daten = checker.klassifiziere_anbieter() - self.assertEqual(daten["typ"], "WMS") - self.assertEqual(daten["quelle"], "remote") - def test_klassifiziere_anbieter_local(self): - checker = Linkpruefer("C:/daten/test.shp", "ogr") - daten = checker.klassifiziere_anbieter() - self.assertEqual(daten["typ"], "OGR") - self.assertEqual(daten["quelle"], "local") + def test_local_link_warning(self): + lp = Linkpruefer("/path/to/file_without_extension", "OGR") + result = lp.pruefe_link() - # ---------------------------- - # Lokale Links - # ---------------------------- - def test_pruefe_link_local_ok(self): - checker = Linkpruefer("C:/daten/test.shp", "OGR") - result = checker.pruefe_link() - self.assertTrue(result.erfolgreich) - self.assertEqual(result.warnungen, []) - - def test_pruefe_link_local_warnung(self): - checker = Linkpruefer("C:/daten/ordner/", "OGR") - result = checker.pruefe_link() self.assertTrue(result.erfolgreich) self.assertIn("ungewöhnlich", result.warnungen[0]) - # ---------------------------- - # Sonderfall: leerer Link - # ---------------------------- - def test_pruefe_link_empty(self): - checker = Linkpruefer("", "REST") - result = checker.pruefe_link() - self.assertFalse(result.erfolgreich) - self.assertIn("Link fehlt.", result.fehler) - - # ---------------------------- - # leerer Anbieter - # ---------------------------- - def test_pruefe_link_leerer_anbieter(self): - checker = Linkpruefer("https://example.com/service", "") - result = checker.pruefe_link() - self.assertFalse(result.erfolgreich) - self.assertIn("Anbieter muss gesetzt werden und darf nicht leer sein.", result.fehler) - if __name__ == "__main__": unittest.main() diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py index a33d4e5..dd23c31 100644 --- a/test/test_pruefmanager.py +++ b/test/test_pruefmanager.py @@ -1,36 +1,87 @@ +#test_pruefmanager.py import unittest import os -from unittest.mock import patch -from pruefmanager import PruefManager -from Dateipruefer import DateiEntscheidung import sys +from unittest.mock import patch, MagicMock + +# Plugin-Root ermitteln (ein Verzeichnis über "test") +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) + +from modules.Pruefmanager import PruefManager +from modules.Dateipruefer import DateiEntscheidung +import modules.qt_compat as qt_compat + + +# Skip-Decorator für Mock-Modus +def skip_if_mock(reason): + return unittest.skipIf( + qt_compat.QT_VERSION == 0, + f"{reason} — MOCK-Modus erkannt. " + "Bitte diesen Test in einer echten QGIS-Umgebung ausführen." + ) -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) class TestPruefManager(unittest.TestCase): def setUp(self): self.manager = PruefManager(plugin_pfad="/tmp") - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) - def test_frage_datei_ersetzen(self, mock_msgbox): + # --------------------------------------------------------- + # Tests für frage_datei_ersetzen_oder_anhaengen + # --------------------------------------------------------- + + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) + def test_frage_datei_ersetzen(self, mock_exec): entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) - def test_frage_datei_anhaengen(self, mock_msgbox): + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) + def test_frage_datei_anhaengen(self, mock_exec): entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Cancel) - def test_frage_datei_abbrechen(self, mock_msgbox): + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.CANCEL) + def test_frage_datei_abbrechen(self, mock_exec): entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.Yes) - def test_frage_temporär_verwenden_ja(self, mock_msgbox): + # --------------------------------------------------------- + # Fehlerfall: exec_dialog liefert etwas Unerwartetes + # --------------------------------------------------------- + + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=999) + def test_frage_datei_unbekannte_antwort(self, mock_exec): + entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") + self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + + # --------------------------------------------------------- + # Tests für frage_temporär_verwenden + # --------------------------------------------------------- + + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) + def test_frage_temporär_verwenden_ja(self, mock_exec): self.assertTrue(self.manager.frage_temporär_verwenden()) - @patch("PyQt5.QtWidgets.QMessageBox.exec_", return_value=QMessageBox.No) - def test_frage_temporär_verwenden_nein(self, mock_msgbox): + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) + def test_frage_temporär_verwenden_nein(self, mock_exec): self.assertFalse(self.manager.frage_temporär_verwenden()) + + # --------------------------------------------------------- + # Fehlerfall: exec_dialog liefert etwas Unerwartetes + # --------------------------------------------------------- + + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + @patch("modules.qt_compat.exec_dialog", return_value=None) + def test_frage_temporär_verwenden_unbekannt(self, mock_exec): + self.assertFalse(self.manager.frage_temporär_verwenden()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qgis.bat b/test/test_qgis.bat new file mode 100644 index 0000000..fc9f9bc --- /dev/null +++ b/test/test_qgis.bat @@ -0,0 +1,52 @@ +@echo off +setlocal +echo BATCH WIRD AUSGEFÜHRT +pause + +echo ================================================ +echo Starte Tests in QGIS-Python-Umgebung +echo ================================================ + +REM Pfad zur QGIS-Installation +set QGIS_BIN=D:\OSGeo\bin + +REM Prüfen, ob python-qgis.bat existiert +if not exist "%QGIS_BIN%\python-qgis.bat" ( + echo. + echo [FEHLER] python-qgis.bat wurde nicht gefunden! + echo Erwarteter Pfad: + echo %QGIS_BIN%\python-qgis.bat + echo. + echo Bitte korrigiere den Pfad in test_qgis.bat. + echo. + pause + exit /b 1 +) + +echo. +echo [INFO] QGIS-Python gefunden. Starte Tests... +echo. + +"%QGIS_BIN%\python-qgis.bat" -m coverage run run_tests.py +if errorlevel 1 ( + echo. + echo [FEHLER] Testlauf fehlgeschlagen. + echo. + pause + exit /b 1 +) + +echo. +echo ================================================ +echo Coverage HTML-Bericht wird erzeugt... +echo ================================================ + +"%QGIS_BIN%\python-qgis.bat" -m coverage html + +echo. +echo Fertig! +echo Öffne jetzt: coverage_html\index.html +echo ================================================ + +pause +endlocal diff --git a/test/test_qt_compat.py b/test/test_qt_compat.py new file mode 100644 index 0000000..92bfd31 --- /dev/null +++ b/test/test_qt_compat.py @@ -0,0 +1,100 @@ +#test_qt_compat.py +import unittest +import os +import sys +from unittest.mock import MagicMock +import modules.qt_compat as qt_compat +# Plugin-Root ermitteln (ein Verzeichnis über "test") +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) + +def skip_if_mock(reason): + """Decorator: überspringt Test, wenn qt_compat im Mock-Modus läuft.""" + return unittest.skipIf( + qt_compat.QT_VERSION == 0, + f"{reason} — MOCK-Modus erkannt." + f"Bitte diesen Test in einer echten QGIS-Umgebung ausführen." + ) + + +class TestQtCompat(unittest.TestCase): + + def test_exports_exist(self): + """Prüft, ob alle erwarteten Symbole exportiert werden.""" + expected = { + "QMessageBox", + "QFileDialog", + "QEventLoop", + "QUrl", + "QNetworkRequest", + "QNetworkReply", + "YES", + "NO", + "CANCEL", + "ICON_QUESTION", + "exec_dialog", + "QT_VERSION", + } + + for symbol in expected: + self.assertTrue( + hasattr(qt_compat, symbol), + f"qt_compat sollte '{symbol}' exportieren" + ) + + @skip_if_mock("QT_VERSION kann im Mock-Modus nicht 5 oder 6 sein") + def test_qt_version_flag(self): + """QT_VERSION muss 5 oder 6 sein.""" + self.assertIn(qt_compat.QT_VERSION, (5, 6)) + + @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") + def test_enums_are_valid(self): + """Prüft, ob die Enums gültige QMessageBox-Werte sind.""" + + msg = qt_compat.QMessageBox() + try: + msg.setStandardButtons( + qt_compat.YES | + qt_compat.NO | + qt_compat.CANCEL + ) + except Exception as e: + self.fail(f"Qt-Enums sollten OR-kombinierbar sein, Fehler: {e}") + + self.assertTrue(True) + + @skip_if_mock("exec_dialog benötigt echtes Qt-Verhalten") + def test_exec_dialog_calls_correct_method(self): + """Prüft, ob exec_dialog() die richtige Methode aufruft.""" + + mock_msg = MagicMock() + + if qt_compat.QT_VERSION == 6: + qt_compat.exec_dialog(mock_msg) + mock_msg.exec.assert_called_once() + + elif qt_compat.QT_VERSION == 5: + qt_compat.exec_dialog(mock_msg) + mock_msg.exec_.assert_called_once() + + else: + self.fail("QT_VERSION hat einen unerwarteten Wert.") + + @skip_if_mock("Qt-Klassen können im Mock-Modus nicht real instanziiert werden") + def test_qt_classes_importable(self): + """Prüft, ob die wichtigsten Qt-Klassen instanziierbar sind.""" + + loop = qt_compat.QEventLoop() + self.assertIsNotNone(loop) + + url = qt_compat.QUrl("http://example.com") + self.assertTrue(url.isValid()) + + req = qt_compat.QNetworkRequest(url) + self.assertIsNotNone(req) + + self.assertTrue(hasattr(qt_compat.QNetworkReply, "NetworkError")) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_stilpruefer.py b/test/test_stilpruefer.py index ea37db7..6ee2a86 100644 --- a/test/test_stilpruefer.py +++ b/test/test_stilpruefer.py @@ -1,10 +1,14 @@ +#test_stilpruefer.py import unittest import tempfile import os -from stilpruefer import Stilpruefer -from pruef_ergebnis import PruefErgebnis - +import sys +# Plugin-Root ermitteln (ein Verzeichnis über "test") +ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, ROOT) +from modules.stilpruefer import Stilpruefer +from modules.pruef_ergebnis import PruefErgebnis class TestStilpruefer(unittest.TestCase): def setUp(self): self.pruefer = Stilpruefer() From e8fea163b570b0a1f11917e80de72557706c30a6 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 18 Dec 2025 22:00:31 +0100 Subject: [PATCH 04/11] =?UTF-8?q?Auf=20Wrapper=20umgestellt,=20Pr=C3=BCfar?= =?UTF-8?q?chitektur=20QT6-kompatibel=20gemacht=20(Nicht=20lauff=C3=A4hig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 20 + .vscode/settings.json | 35 ++ __init__.py | 2 - functions/messages.py | 44 -- functions/qgisqt_wrapper.py | 880 ++++++++++++++++++++++++++++++++++++ functions/settings_logic.py | 72 +-- functions/styles.py | 28 -- functions/syswrapper.py | 185 ++++++++ functions/variable_utils.py | 35 -- main.py | 24 +- modules/Dateipruefer.py | 202 +++++---- modules/Pruefmanager.py | 168 +++++-- modules/layerpruefer.py | 170 +++++++ modules/linkpruefer.py | 178 +++++--- modules/pruef_ergebnis.py | 68 ++- modules/qt_compat.py | 111 ----- modules/stilpruefer.py | 87 ++-- test/run_tests.py | 107 +++-- test/test_bootstrap.py | 2 + test/test_dateipruefer.py | 151 ++++--- test/test_layerpruefer.py | 170 +++++++ test/test_linkpruefer.py | 144 +++--- test/test_pruefmanager.py | 179 +++++--- test/test_qt_compat.py | 100 ---- test/test_settings_logic.py | 60 +++ test/test_stilpruefer.py | 79 +++- test/test_wrapper.py | 164 +++++++ ui/base_dockwidget.py | 75 ++- ui/dockmanager.py | 50 +- ui/navigation.py | 1 + ui/tabs/settings_tab.py | 89 +++- 31 files changed, 2791 insertions(+), 889 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/settings.json delete mode 100644 functions/messages.py create mode 100644 functions/qgisqt_wrapper.py delete mode 100644 functions/styles.py create mode 100644 functions/syswrapper.py delete mode 100644 functions/variable_utils.py create mode 100644 modules/layerpruefer.py delete mode 100644 modules/qt_compat.py create mode 100644 test/test_bootstrap.py create mode 100644 test/test_layerpruefer.py delete mode 100644 test/test_qt_compat.py create mode 100644 test/test_settings_logic.py create mode 100644 test/test_wrapper.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..0dfa213 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Attach to QGIS (Port 5678)", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5678 + }, + "pathMappings": [ + { + "localRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis", + "remoteRoot": "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + } + ] + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d8d3a84 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + // OSGeo Python als Interpreter (QGIS 4.0, Qt6) + "python.defaultInterpreterPath": "D:/OSGeo/apps/Python312/python.exe", + + // Pylance: zusätzliche Suchpfade + "python.analysis.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Autocomplete ebenfalls erweitern + "python.autoComplete.extraPaths": [ + "D:/OSGeo/apps/qgis/python", + "D:/OSGeo/apps/Python312/Lib/site-packages", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins", + "C:/Users/helbi/AppData/Roaming/QGIS/QGIS3/profiles/dev/python/plugins/sn_basis" + ], + + // Pylance-Modus + "python.analysis.typeCheckingMode": "basic", + "python.analysis.diagnosticMode": "workspace", + + // Tests aktivieren + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "python.testing.unittestArgs": [ + "-v", + "-s", + "./test", + "-p", + "*test*.py" + ] +} diff --git a/__init__.py b/__init__.py index f579213..854d63e 100644 --- a/__init__.py +++ b/__init__.py @@ -1,5 +1,3 @@ -from .functions.variable_utils import get_variable - def classFactory(iface): from .main import BasisPlugin return BasisPlugin(iface) diff --git a/functions/messages.py b/functions/messages.py deleted file mode 100644 index fadc330..0000000 --- a/functions/messages.py +++ /dev/null @@ -1,44 +0,0 @@ -# sn_basis/functions/messages.py - -from typing import Optional -from qgis.core import Qgis -from qgis.PyQt.QtWidgets import QWidget -from qgis.utils import iface - - -def push_message( - level: Qgis.MessageLevel, - title: str, - text: str, - duration: Optional[int] = 5, - parent: Optional[QWidget] = None, -): - """ - Zeigt eine Meldung in der QGIS-MessageBar. - - level: Qgis.Success | Qgis.Info | Qgis.Warning | Qgis.Critical - - title: Überschrift links (kurz halten) - - text: eigentliche Nachricht - - duration: Sekunden bis Auto-Ausblendung; None => bleibt sichtbar (mit Close-Button) - - parent: optionales Eltern-Widget (für Kontext), normalerweise nicht nötig - Rückgabe: MessageBarItem-Widget (kann später geschlossen/entfernt werden). - """ - bar = iface.messageBar() - # QGIS akzeptiert None als "sticky" Meldung - return bar.pushMessage(title, text, level=level, duration=duration) - - -def success(title: str, text: str, duration: int = 5): - return push_message(Qgis.Success, title, text, duration) - - -def info(title: str, text: str, duration: int = 5): - return push_message(Qgis.Info, title, text, duration) - - -def warning(title: str, text: str, duration: int = 5): - return push_message(Qgis.Warning, title, text, duration) - - -def error(title: str, text: str, duration: Optional[int] = 5): - # Fehler evtl. länger sichtbar lassen; setze duration=None falls gewünscht - return push_message(Qgis.Critical, title, text, duration) diff --git a/functions/qgisqt_wrapper.py b/functions/qgisqt_wrapper.py new file mode 100644 index 0000000..a8cd723 --- /dev/null +++ b/functions/qgisqt_wrapper.py @@ -0,0 +1,880 @@ +""" +sn_basis/functions/qgisqt_wrapper.py – zentrale QGIS/Qt-Abstraktion +""" + +from typing import Optional, Type, Any + + +# --------------------------------------------------------- +# Hilfsfunktionen +# --------------------------------------------------------- + +def getattr_safe(obj: Any, name: str, default: Any = None) -> Any: + """ + Sichere getattr-Variante: + - fängt Exceptions beim Attributzugriff ab + - liefert default zurück, wenn Attribut fehlt oder fehlschlägt + """ + try: + return getattr(obj, name) + except Exception: + return default + + +# --------------------------------------------------------- +# Qt‑Symbole (werden später dynamisch importiert) +# --------------------------------------------------------- + +QMessageBox: Optional[Type[Any]] = None +QFileDialog: Optional[Type[Any]] = None +QEventLoop: Optional[Type[Any]] = None +QUrl: Optional[Type[Any]] = None +QNetworkRequest: Optional[Type[Any]] = None +QNetworkReply: Optional[Type[Any]] = None +QCoreApplication: Optional[Type[Any]] = None + +QWidget: Type[Any] +QGridLayout: Type[Any] +QLabel: Type[Any] +QLineEdit: Type[Any] +QGroupBox: Type[Any] +QVBoxLayout: Type[Any] +QPushButton: Type[Any] + +YES: Optional[Any] = None +NO: Optional[Any] = None +CANCEL: Optional[Any] = None +ICON_QUESTION: Optional[Any] = None + + +def exec_dialog(dialog: Any) -> Any: + raise NotImplementedError + + +# --------------------------------------------------------- +# QGIS‑Symbole (werden später dynamisch importiert) +# --------------------------------------------------------- + +QgsProject: Optional[Type[Any]] = None +QgsVectorLayer: Optional[Type[Any]] = None +QgsNetworkAccessManager: Optional[Type[Any]] = None +Qgis: Optional[Type[Any]] = None +iface: Optional[Any] = None + + +# --------------------------------------------------------- +# Qt‑Versionserkennung +# --------------------------------------------------------- + +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 + + +# --------------------------------------------------------- +# Versuch: PyQt6 importieren +# --------------------------------------------------------- + +try: + from PyQt6.QtWidgets import ( #type: ignore + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + ) + from PyQt6.QtCore import ( #type: ignore + Qt, + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + ) + from PyQt6.QtNetwork import ( #type: ignore + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + + if QMessageBox is not None: + YES = QMessageBox.StandardButton.Yes + NO = QMessageBox.StandardButton.No + CANCEL = QMessageBox.StandardButton.Cancel + ICON_QUESTION = QMessageBox.Icon.Question + + QT_VERSION = 6 + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec() + +# --------------------------------------------------------- +# Versuch: PyQt5 importieren +# --------------------------------------------------------- + +except Exception: + try: + from PyQt5.QtWidgets import ( + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + ) + from PyQt5.QtCore import ( + Qt, + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + ) + from PyQt5.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + + if QMessageBox is not None: + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + + QT_VERSION = 5 + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec_() + + # --------------------------------------------------------- + # Mock‑Modus (kein Qt verfügbar) + # --------------------------------------------------------- + + except Exception: + QT_VERSION = 0 + + class FakeEnum(int): + """OR‑fähiger Enum‑Ersatz für Mock‑Modus.""" + + def __new__(cls, value: int): + return int.__new__(cls, value) + + def __or__(self, other: "FakeEnum") -> "FakeEnum": + return FakeEnum(int(self) | int(other)) + + class _MockQMessageBox: + Yes = FakeEnum(1) + No = FakeEnum(2) + Cancel = FakeEnum(4) + Question = FakeEnum(8) + + QMessageBox = _MockQMessageBox + + class _MockQFileDialog: + @staticmethod + def getOpenFileName(*args, **kwargs): + return ("", "") + + @staticmethod + def getSaveFileName(*args, **kwargs): + return ("", "") + + QFileDialog = _MockQFileDialog + + class _MockQEventLoop: + def exec(self) -> int: + return 0 + + def quit(self) -> None: + pass + + QEventLoop = _MockQEventLoop + + class _MockQUrl(str): + def isValid(self) -> bool: + return True + + QUrl = _MockQUrl + + class _MockQNetworkRequest: + def __init__(self, url: Any): + self.url = url + + QNetworkRequest = _MockQNetworkRequest + + class _MockQNetworkReply: + class NetworkError: + NoError = 0 + + def __init__(self): + self._data = b"" + + def error(self) -> int: + return 0 + + def errorString(self) -> str: + return "" + + def attribute(self, *args, **kwargs) -> Any: + return 200 + + def readAll(self) -> bytes: + return self._data + + def deleteLater(self) -> None: + pass + + QNetworkReply = _MockQNetworkReply + + YES = FakeEnum(1) + NO = FakeEnum(2) + CANCEL = FakeEnum(4) + ICON_QUESTION = FakeEnum(8) + + def exec_dialog(dialog: Any) -> Any: + return YES + + class _MockWidget: + def __init__(self, *args, **kwargs): + pass + + class _MockLayout: + def __init__(self, *args, **kwargs): + pass + + def addWidget(self, *args, **kwargs): + pass + + def addLayout(self, *args, **kwargs): + pass + + def addStretch(self, *args, **kwargs): + pass + + def setLayout(self, *args, **kwargs): + pass + + class _MockLabel: + def __init__(self, text: str = ""): + self._text = text + + class _MockLineEdit: + def __init__(self, *args, **kwargs): + self._text = "" + + def text(self) -> str: + return self._text + + def setText(self, value: str) -> None: + self._text = value + + class _MockButton: + def __init__(self, *args, **kwargs): + # einfache Attr für Kompatibilität mit Qt-Signal-Syntax + self.clicked = lambda *a, **k: None + + def connect(self, *args, **kwargs): + pass + + QWidget = _MockWidget + QGridLayout = _MockLayout + QLabel = _MockLabel + QLineEdit = _MockLineEdit + QGroupBox = _MockWidget + QVBoxLayout = _MockLayout + QPushButton = _MockButton + + # Kein echtes QCoreApplication im Mock + QCoreApplication = None + + +# --------------------------------------------------------- +# QGIS‑Imports +# --------------------------------------------------------- + +try: + from qgis.core import ( + QgsProject as _QgsProject, + QgsVectorLayer as _QgsVectorLayer, + QgsNetworkAccessManager as _QgsNetworkAccessManager, + Qgis as _Qgis, + ) + from qgis.utils import iface as _iface + + QgsProject = _QgsProject + QgsVectorLayer = _QgsVectorLayer + QgsNetworkAccessManager = _QgsNetworkAccessManager + Qgis = _Qgis + iface = _iface + + QGIS_AVAILABLE = True + +except Exception: + QGIS_AVAILABLE = False + + class _MockQgsProject: + @staticmethod + def instance() -> "_MockQgsProject": + return _MockQgsProject() + + def __init__(self): + self._variables = {} + + def read(self) -> bool: + return True + + QgsProject = _MockQgsProject + + class _MockQgsVectorLayer: + def __init__(self, *args, **kwargs): + self._valid = True + + def isValid(self) -> bool: + return self._valid + + def loadNamedStyle(self, path: str): + return True, "" + + def triggerRepaint(self) -> None: + pass + + QgsVectorLayer = _MockQgsVectorLayer + + class _MockQgsNetworkAccessManager: + def head(self, request: Any) -> _MockQNetworkReply: + return _MockQNetworkReply() + + QgsNetworkAccessManager = _MockQgsNetworkAccessManager + + class _MockQgis: + class MessageLevel: + Success = 0 + Info = 1 + Warning = 2 + Critical = 3 + + Qgis = _MockQgis + + class FakeIface: + class FakeMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return {"title": title, "text": text, "level": level, "duration": duration} + + def messageBar(self): + return self.FakeMessageBar() + + def mainWindow(self): + return None + + iface = FakeIface() + + +# --------------------------------------------------------- +# Message‑Funktionen +# --------------------------------------------------------- + +def _get_message_bar(): + if iface is not None: + bar_attr = getattr_safe(iface, "messageBar") + if callable(bar_attr): + try: + return bar_attr() + except Exception: + pass + + class _MockMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return { + "title": title, + "text": text, + "level": level, + "duration": duration, + } + + return _MockMessageBar() + + +def push_message(level, title, text, duration=5, parent=None): + bar = _get_message_bar() + push = getattr_safe(bar, "pushMessage") + if callable(push): + return push(title, text, level=level, duration=duration) + return None + + +def info(title, text, duration=5): + level = Qgis.MessageLevel.Info if Qgis is not None else 1 + return push_message(level, title, text, duration) + + +def warning(title, text, duration=5): + level = Qgis.MessageLevel.Warning if Qgis is not None else 2 + return push_message(level, title, text, duration) + + +def error(title, text, duration=5): + level = Qgis.MessageLevel.Critical if Qgis is not None else 3 + return push_message(level, title, text, duration) + + +def success(title, text, duration=5): + level = Qgis.MessageLevel.Success if Qgis is not None else 0 + return push_message(level, title, text, duration) + +# --------------------------------------------------------- +# Dialog‑Interaktionen +# --------------------------------------------------------- + +def ask_yes_no( + title: str, + message: str, + default: bool = False, + parent: Any = None, +) -> bool: + """ + Fragt den Benutzer eine Ja/Nein‑Frage. + + - In QGIS/Qt: zeigt einen QMessageBox‑Dialog + - Im Mock/Test‑Modus: gibt default zurück + """ + if QMessageBox is None: + return default + + try: + buttons = YES | NO + result = QMessageBox.question( + parent, + title, + message, + buttons, + YES if default else NO, + ) + return result == YES + except Exception: + return default + + +# --------------------------------------------------------- +# Variablen‑Wrapper +# --------------------------------------------------------- + +try: + from qgis.core import QgsExpressionContextUtils + + _HAS_QGIS_VARIABLES = True +except Exception: + _HAS_QGIS_VARIABLES = False + + class _MockVariableStore: + global_vars: dict[str, str] = {} + project_vars: dict[str, str] = {} + + class QgsExpressionContextUtils: + @staticmethod + def setGlobalVariable(name: str, value: str) -> None: + _MockVariableStore.global_vars[name] = value + + @staticmethod + def globalScope(): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.global_vars.get(name, "") + + return _Scope() + + @staticmethod + def setProjectVariable(project: Any, name: str, value: str) -> None: + _MockVariableStore.project_vars[name] = value + + @staticmethod + def projectScope(project: Any): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.project_vars.get(name, "") + + return _Scope() + + +def get_variable(key: str, scope: str = "project") -> str: + var_name = f"sn_{key}" + + if scope == "project": + if QgsProject is not None: + projekt = QgsProject.instance() + else: + projekt = None # type: ignore[assignment] + return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or "" + + if scope == "global": + return QgsExpressionContextUtils.globalScope().variable(var_name) or "" + + raise ValueError("Scope muss 'project' oder 'global' sein.") + + +def set_variable(key: str, value: str, scope: str = "project") -> None: + var_name = f"sn_{key}" + + if scope == "project": + if QgsProject is not None: + projekt = QgsProject.instance() + else: + projekt = None # type: ignore[assignment] + QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value) + return + + if scope == "global": + QgsExpressionContextUtils.setGlobalVariable(var_name, value) + return + + raise ValueError("Scope muss 'project' oder 'global' sein.") + + +# --------------------------------------------------------- +# syswrapper Lazy‑Import +# --------------------------------------------------------- + +def _sys(): + from sn_basis.functions import syswrapper + return syswrapper + + +# --------------------------------------------------------- +# Style‑Funktion +# --------------------------------------------------------- + +def apply_style(layer, style_name: str) -> bool: + if layer is None: + return False + + is_valid_attr = getattr_safe(layer, "isValid") + if not callable(is_valid_attr) or not is_valid_attr(): + return False + + sys = _sys() + base_dir = sys.get_plugin_root() + style_path = sys.join_path(base_dir, "styles", style_name) + + if not sys.file_exists(style_path): + return False + + try: + ok, error_msg = layer.loadNamedStyle(style_path) + except Exception: + return False + + if not ok: + return False + + try: + trigger = getattr_safe(layer, "triggerRepaint") + if callable(trigger): + trigger() + except Exception: + pass + + return True + + +# --------------------------------------------------------- +# Layer‑Wrapper +# --------------------------------------------------------- + +def layer_exists(layer) -> bool: + if layer is None: + return False + + # Mock/Wrapper-Attribut + is_valid_flag = getattr_safe(layer, "is_valid") + if is_valid_flag is not None: + try: + return bool(is_valid_flag) + except Exception: + return False + + try: + is_valid_attr = getattr_safe(layer, "isValid") + if callable(is_valid_attr): + return bool(is_valid_attr()) + return True + except Exception: + return False + + +def get_layer_geometry_type(layer) -> str: + if layer is None: + return "None" + + geometry_type_attr = getattr_safe(layer, "geometry_type") + if geometry_type_attr is not None: + return str(geometry_type_attr) + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr) and not is_spatial_attr(): + return "None" + + geometry_type_qgis = getattr_safe(layer, "geometryType") + if callable(geometry_type_qgis): + gtype = geometry_type_qgis() + if gtype == 0: + return "Point" + if gtype == 1: + return "LineString" + if gtype == 2: + return "Polygon" + return "None" + + return "None" + except Exception: + return "None" + + +def get_layer_feature_count(layer) -> int: + if layer is None: + return 0 + + feature_count_attr = getattr_safe(layer, "feature_count") + if feature_count_attr is not None: + try: + return int(feature_count_attr) + except Exception: + return 0 + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr) and not is_spatial_attr(): + return 0 + + feature_count_qgis = getattr_safe(layer, "featureCount") + if callable(feature_count_qgis): + return int(feature_count_qgis()) + + return 0 + except Exception: + return 0 + + +def is_layer_visible(layer) -> bool: + if layer is None: + return False + + visible_attr = getattr_safe(layer, "visible") + if visible_attr is not None: + try: + return bool(visible_attr) + except Exception: + return False + + try: + is_visible_attr = getattr_safe(layer, "isVisible") + if callable(is_visible_attr): + return bool(is_visible_attr()) + + tree_layer_attr = getattr_safe(layer, "treeLayer") + if callable(tree_layer_attr): + node = tree_layer_attr() + else: + node = tree_layer_attr + + if node is not None: + node_visible_attr = getattr_safe(node, "isVisible") + if callable(node_visible_attr): + return bool(node_visible_attr()) + + return False + except Exception: + return False + +def set_layer_visible(layer, visible: bool) -> bool: + """ + Setzt die Sichtbarkeit eines Layers. + + Unterstützt: + - Mock-/Wrapper-Attribute (layer.visible) + - QGIS-LayerTreeNode (treeLayer().setItemVisibilityChecked) + - Fallbacks ohne Exception-Wurf + + Gibt True zurück, wenn die Sichtbarkeit gesetzt werden konnte. + """ + if layer is None: + return False + + # 1️⃣ Mock / Wrapper-Attribut + try: + if hasattr(layer, "visible"): + layer.visible = bool(visible) + return True + except Exception: + pass + + # 2️⃣ QGIS: LayerTreeNode + try: + tree_layer_attr = getattr_safe(layer, "treeLayer") + node = tree_layer_attr() if callable(tree_layer_attr) else tree_layer_attr + + if node is not None: + set_visible = getattr_safe(node, "setItemVisibilityChecked") + if callable(set_visible): + set_visible(bool(visible)) + return True + except Exception: + pass + + # 3️⃣ QGIS-Fallback: setVisible (selten, aber vorhanden) + try: + set_visible_attr = getattr_safe(layer, "setVisible") + if callable(set_visible_attr): + set_visible_attr(bool(visible)) + return True + except Exception: + pass + + return False + + +def get_layer_type(layer) -> str: + if layer is None: + return "unknown" + + layer_type_attr = getattr_safe(layer, "layer_type") + if layer_type_attr is not None: + return str(layer_type_attr) + + try: + is_spatial_attr = getattr_safe(layer, "isSpatial") + if callable(is_spatial_attr): + return "vector" if is_spatial_attr() else "table" + + data_provider_attr = getattr_safe(layer, "dataProvider") + raster_type_attr = getattr_safe(layer, "rasterType") + if data_provider_attr is not None and raster_type_attr is not None: + return "raster" + + return "unknown" + except Exception: + return "unknown" + + +def get_layer_crs(layer) -> str: + if layer is None: + return "None" + + crs_attr_direct = getattr_safe(layer, "crs") + if crs_attr_direct is not None and not callable(crs_attr_direct): + # direkter Attributzugriff (z. B. im Mock) + return str(crs_attr_direct) + + try: + crs_callable = getattr_safe(layer, "crs") + if callable(crs_callable): + crs = crs_callable() + authid_attr = getattr_safe(crs, "authid") + if callable(authid_attr): + return authid_attr() or "None" + return "None" + except Exception: + return "None" + + +def get_layer_fields(layer) -> list[str]: + if layer is None: + return [] + + # direkter Attributzugriff (Mock / Wrapper) + fields_attr_direct = getattr_safe(layer, "fields") + if fields_attr_direct is not None and not callable(fields_attr_direct): + try: + # direkter Iterable oder Mapping von Namen + if hasattr(fields_attr_direct, "__iter__") and not isinstance( + fields_attr_direct, (str, bytes) + ): + return list(fields_attr_direct) + except Exception: + return [] + + try: + fields_callable = getattr_safe(layer, "fields") + if callable(fields_callable): + fields = fields_callable() + + # QGIS: QgsFields.names() + names_attr = getattr_safe(fields, "names") + if callable(names_attr): + return list(names_attr()) + + # Fallback: iterierbar? + if hasattr(fields, "__iter__") and not isinstance(fields, (str, bytes)): + return list(fields) + + return [] + except Exception: + return [] + + +def get_layer_source(layer) -> str: + if layer is None: + return "None" + + source_attr_direct = getattr_safe(layer, "source") + if source_attr_direct is not None and not callable(source_attr_direct): + return str(source_attr_direct) + + try: + source_callable = getattr_safe(layer, "source") + if callable(source_callable): + return source_callable() or "None" + return "None" + except Exception: + return "None" + + +def is_layer_editable(layer) -> bool: + if layer is None: + return False + + editable_attr = getattr_safe(layer, "editable") + if editable_attr is not None: + try: + return bool(editable_attr) + except Exception: + return False + + try: + editable_callable = getattr_safe(layer, "isEditable") + if callable(editable_callable): + return bool(editable_callable()) + return False + except Exception: + return False diff --git a/functions/settings_logic.py b/functions/settings_logic.py index 64b209a..77d049c 100644 --- a/functions/settings_logic.py +++ b/functions/settings_logic.py @@ -1,37 +1,47 @@ -from qgis.core import QgsProject, QgsExpressionContextUtils +""" +sn_basis/funktions/settings_logic.py – Logik zum Lesen und Schreiben der Plugin-Einstellungen +über den zentralen qgisqt_wrapper. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + get_variable, + set_variable, +) + class SettingsLogic: - def __init__(self): - self.project = QgsProject.instance() + """ + Verwaltet das Laden und Speichern der Plugin-Einstellungen. + Alle Variablen werden als sn_* Projektvariablen gespeichert. + """ - # Definition der Variablen-Namen - self.global_vars = ["amt", "behoerde", "landkreis_user", "sachgebiet"] - self.project_vars = ["bezeichnung", "verfahrensnummer", "gemeinden", "landkreise_proj"] - - def save(self, fields: dict): - """Speichert Felder als globale und projektbezogene Ausdrucksvariablen.""" - - # Globale Variablen - for key in self.global_vars: - QgsExpressionContextUtils.setGlobalVariable(f"sn_{key}", fields.get(key, "")) - - # Projektvariablen - for key in self.project_vars: - QgsExpressionContextUtils.setProjectVariable(self.project, f"sn_{key}", fields.get(key, "")) - - print("✅ Ausdrucksvariablen gespeichert.") + # Alle Variablen, die gespeichert werden sollen + VARIABLEN = [ + "amt", + "behoerde", + "landkreis_user", + "sachgebiet", + "bezeichnung", + "verfahrensnummer", + "gemeinden", + "landkreise_proj", + ] def load(self) -> dict: - """Lädt Werte ausschließlich aus Ausdrucksvariablen (global + projektbezogen).""" + """ + Lädt alle Variablen aus dem Projekt. + Rückgabe: dict mit allen Werten (leere Strings, wenn nicht gesetzt). + """ + daten = {} + for key in self.VARIABLEN: + daten[key] = get_variable(key, scope="project") + return daten - data = {} - - # Globale Variablen - for key in self.global_vars: - data[key] = QgsExpressionContextUtils.globalScope().variable(f"sn_{key}") or "" - - # Projektvariablen - for key in self.project_vars: - data[key] = QgsExpressionContextUtils.projectScope(self.project).variable(f"sn_{key}") or "" - - return data + def save(self, daten: dict): + """ + Speichert alle übergebenen Variablen im Projekt. + daten: dict mit key → value + """ + for key, value in daten.items(): + if key in self.VARIABLEN: + set_variable(key, value, scope="project") diff --git a/functions/styles.py b/functions/styles.py deleted file mode 100644 index 0723717..0000000 --- a/functions/styles.py +++ /dev/null @@ -1,28 +0,0 @@ -# sn_basis/functions/styles.py -import os -from qgis.core import QgsVectorLayer - -def apply_style(layer: QgsVectorLayer, style_name: str) -> bool: - """ - Lädt einen QML-Style aus dem styles-Ordner des Plugins und wendet ihn auf den Layer an. - style_name: Dateiname ohne Pfad, z.B. 'verfahrensgebiet.qml' - Rückgabe: True bei Erfolg, False sonst - """ - if not layer or not layer.isValid(): - return False - - # Basis-Pfad: sn_basis/styles - base_dir = os.path.dirname(os.path.dirname(__file__)) # geht von functions/ eins hoch - style_path = os.path.join(base_dir, "styles", style_name) - - if not os.path.exists(style_path): - print(f"Style-Datei nicht gefunden: {style_path}") - return False - - ok, error_msg = layer.loadNamedStyle(style_path) - if not ok: - print(f"Style konnte nicht geladen werden: {error_msg}") - return False - - layer.triggerRepaint() - return True diff --git a/functions/syswrapper.py b/functions/syswrapper.py new file mode 100644 index 0000000..2ab5a6b --- /dev/null +++ b/functions/syswrapper.py @@ -0,0 +1,185 @@ +""" +snbasis/functions/syswrapper.py – zentrale OS-/Dateisystem-Abstraktion +Robust, testfreundlich, mock-fähig. +""" + +import os +import tempfile +import pathlib +import sys + + +# --------------------------------------------------------- +# Dateisystem‑Funktionen +# --------------------------------------------------------- + +def file_exists(path: str) -> bool: + """Prüft, ob eine Datei existiert.""" + try: + return os.path.exists(path) + except Exception: + return False + + +def is_file(path: str) -> bool: + """Prüft, ob ein Pfad eine Datei ist.""" + try: + return os.path.isfile(path) + except Exception: + return False + + +def is_dir(path: str) -> bool: + """Prüft, ob ein Pfad ein Verzeichnis ist.""" + try: + return os.path.isdir(path) + except Exception: + return False + + +def join_path(*parts) -> str: + """Verbindet Pfadbestandteile OS‑unabhängig.""" + try: + return os.path.join(*parts) + except Exception: + # Fallback: naive Verkettung + return "/".join(str(p) for p in parts) + + +# --------------------------------------------------------- +# Pfad‑ und Systemfunktionen +# --------------------------------------------------------- + +def get_temp_dir() -> str: + """Gibt das temporäre Verzeichnis zurück.""" + try: + return tempfile.gettempdir() + except Exception: + return "/tmp" + + +def get_plugin_root() -> str: + """ + Ermittelt den Plugin‑Root‑Pfad. + Annahme: syswrapper liegt in sn_basis/funktions/ + → also zwei Ebenen hoch. + """ + try: + here = pathlib.Path(__file__).resolve() + return str(here.parent.parent) + except Exception: + # Fallback: aktuelles Arbeitsverzeichnis + return os.getcwd() + + +# --------------------------------------------------------- +# Datei‑I/O (optional, aber nützlich) +# --------------------------------------------------------- + +def read_file(path: str, mode="r"): + """Liest eine Datei ein. Gibt None zurück, wenn Fehler auftreten.""" + try: + with open(path, mode) as f: + return f.read() + except Exception: + return None + + +def write_file(path: str, data, mode="w"): + """Schreibt Daten in eine Datei. Gibt True/False zurück.""" + try: + with open(path, mode) as f: + f.write(data) + return True + except Exception: + return False + + +# --------------------------------------------------------- +# Mock‑Modus (optional erweiterbar) +# --------------------------------------------------------- + +class FakeFileSystem: + """ + Minimaler Mock‑Dateisystem‑Ersatz. + Wird nicht automatisch aktiviert, aber kann in Tests gepatcht werden. + """ + files = {} + + @classmethod + def add_file(cls, path, content=""): + cls.files[path] = content + + @classmethod + def exists(cls, path): + return path in cls.files + + @classmethod + def read(cls, path): + return cls.files.get(path, None) + +# --------------------------------------------------------- +# Betriebssystem‑Erkennung +# --------------------------------------------------------- + +import platform + +def get_os() -> str: + """ + Gibt das Betriebssystem zurück: + - 'windows' + - 'linux' + - 'mac' + """ + system = platform.system().lower() + + if "windows" in system: + return "windows" + if "darwin" in system: + return "mac" + if "linux" in system: + return "linux" + + return "unknown" + + +def is_windows() -> bool: + return get_os() == "windows" + + +def is_linux() -> bool: + return get_os() == "linux" + + +def is_mac() -> bool: + return get_os() == "mac" + + +# --------------------------------------------------------- +# Pfad‑Normalisierung +# --------------------------------------------------------- + +def normalize_path(path: str) -> str: + """ + Normalisiert Pfade OS‑unabhängig: + - ersetzt Backslashes durch Slashes + - entfernt doppelte Slashes + - löst relative Pfade auf + """ + try: + p = pathlib.Path(path).resolve() + return str(p) + except Exception: + # Fallback: einfache Normalisierung + return path.replace("\\", "/").replace("//", "/") + +def add_to_sys_path(path: str) -> None: + """ + Fügt einen Pfad sicher zum Python-Importpfad hinzu. + """ + try: + if path not in sys.path: + sys.path.insert(0, path) + except Exception: + pass + diff --git a/functions/variable_utils.py b/functions/variable_utils.py deleted file mode 100644 index 6ac4af9..0000000 --- a/functions/variable_utils.py +++ /dev/null @@ -1,35 +0,0 @@ -from qgis.core import QgsProject, QgsExpressionContextUtils - -def get_variable(key: str, scope: str = "project") -> str: - """ - Liefert den Wert einer sn_* Variable zurück. - key: Name ohne Präfix, z.B. "verfahrensnummer" - scope: 'project' oder 'global' - """ - projekt = QgsProject.instance() - var_name = f"sn_{key}" - - if scope == "project": - return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or "" - elif scope == "global": - return QgsExpressionContextUtils.globalScope().variable(var_name) or "" - else: - raise ValueError("Scope muss 'project' oder 'global' sein.") - - -def set_variable(key: str, value: str, scope: str = "project"): - """ - Schreibt den Wert einer sn_* Variable. - key: Name ohne Präfix, z.B. "verfahrensnummer" - value: Wert, der gespeichert werden soll - scope: 'project' oder 'global' - """ - projekt = QgsProject.instance() - var_name = f"sn_{key}" - - if scope == "project": - QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value) - elif scope == "global": - QgsExpressionContextUtils.setGlobalVariable(var_name, value) - else: - raise ValueError("Scope muss 'project' oder 'global' sein.") diff --git a/main.py b/main.py index 8fc2b7f..d71c114 100644 --- a/main.py +++ b/main.py @@ -1,12 +1,24 @@ -from qgis.PyQt.QtCore import QCoreApplication +# sn_basis/main.py + +from sn_basis.functions.qgisqt_wrapper import QCoreApplication, getattr_safe from qgis.utils import plugins from sn_basis.ui.navigation import Navigation + class BasisPlugin: def __init__(self, iface): self.iface = iface self.ui = None - QCoreApplication.instance().aboutToQuit.connect(self.unload) + + # QCoreApplication kann im Mock-Modus None sein + if QCoreApplication is not None: + app = getattr_safe(QCoreApplication, "instance") + if callable(app): + instance = app() + about_to_quit = getattr_safe(instance, "aboutToQuit") + connect = getattr_safe(about_to_quit, "connect") + if callable(connect): + connect(self.unload) def initGui(self): # Basis-Navigation neu aufbauen @@ -16,11 +28,15 @@ class BasisPlugin: for name, plugin in plugins.items(): if name.startswith("sn_") and name != "sn_basis": try: - plugin.initGui() + init_gui = getattr_safe(plugin, "initGui") + if callable(init_gui): + init_gui() except Exception as e: print(f"Fehler beim Neuinitialisieren von {name}: {e}") def unload(self): if self.ui: - self.ui.remove_all() + remove_all = getattr_safe(self.ui, "remove_all") + if callable(remove_all): + remove_all() self.ui = None diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 8e29eb1..a2fd17e 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,100 +1,126 @@ -#Modul zur Prüfung und zum Exception Handling für Dateieingaben -#Dateipruefer.py +""" +sn_basis/modulesdateipruefer.py – Prüfung von Dateieingaben für das Plugin. +Verwendet syswrapper und gibt pruef_ergebnis an den Pruefmanager zurück. +""" -import os -from enum import Enum, auto +from sn_basis.functions.syswrapper import ( + file_exists, + is_file, + join_path, +) + +from sn_basis.modules.Pruefmanager import pruef_ergebnis -# ------------------------------- -# ENUMS -# ------------------------------- -class LeererPfadModus(Enum):#legt die modi fest, die für Dateipfade möglich sind - VERBOTEN = auto() #ein leeres Eingabefeld stellt einen Fehler dar - NUTZE_STANDARD = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: nutze Standard oder brich ab - TEMPORAER_ERLAUBT = auto() #ein leeres Eingabefeld fordert zur Entscheidung auf: arbeite temporär oder brich ab. - - -class DateiEntscheidung(Enum):#legt die Modi fest, wie mit bestehenden Dateien umgegangen werden soll (hat das das QGSFile-Objekt schon selbst?) - ERSETZEN = auto()#Ergebnis der Nutzerentscheidung: bestehende Datei ersetzen - ANHAENGEN = auto()#Ergebnis der Nutzerentscheidung: an bestehende Datei anhängen - ABBRECHEN = auto()#bricht den Vorgang ab. (muss das eine definierte Option sein? oder geht das auch mit einem normalen Abbruch-Button) - - -# ------------------------------- -# RÜCKGABEOBJEKT -# ------------------------------- -#Das Dateiprüfergebnis wird an den Prüfmanager übergeben. Alle GUI-Abfragen werden im Prüfmanager behandelt. -class DateipruefErgebnis: - #Definition der Parameter und Festlegung auf den Parametertyp,bzw den Standardwert - def __init__(self, erfolgreich: bool, pfad: str = None, temporär: bool = False, - entscheidung: DateiEntscheidung = None, fehler: list = None): - self.erfolgreich = erfolgreich - self.pfad = pfad - self.temporär = temporär - self.entscheidung = entscheidung - self.fehler = fehler or [] - - def __repr__(self): - return (f"DateipruefErgebnis(erfolgreich={self.erfolgreich}, " - f"pfad={repr(self.pfad)}, temporär={self.temporär}, " - f"entscheidung={repr(self.entscheidung)}, fehler={repr(self.fehler)})") - -# ------------------------------- -# DATEIPRÜFER -# ------------------------------- class Dateipruefer: - def pruefe(self, pfad: str, - leer_modus: LeererPfadModus, - standardname: str = None, - plugin_pfad: str = None, - vorhandene_datei_entscheidung: DateiEntscheidung = None) -> DateipruefErgebnis: #Rückgabetypannotation; "Die Funktion "pruefe" gibt ein Objekt vom Typ "DateipruefErgebnis" zurück + """ + Prüft Dateieingaben und liefert ein pruef_ergebnis zurück. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + """ - # 1. Prüfe, ob das Eingabefeld leer ist - if not pfad or pfad.strip() == "":#wenn der angegebene Pfad leer oder ungültig ist: - if leer_modus == LeererPfadModus.VERBOTEN: #wenn der Modus "verboten" vorgegeben ist, gib zurück, dass der Test fehlgeschlagen ist - return DateipruefErgebnis( - erfolgreich=False, - fehler=["Kein Pfad angegeben."] - ) - elif leer_modus == LeererPfadModus.NUTZE_STANDARD:#wenn der Modus "Nutze_Standard" vorgegeben ist... - if not plugin_pfad or not standardname:#wenn kein gültiger Pluginpfad angegeben ist oder die Standarddatei fehlt... - return DateipruefErgebnis( - erfolgreich=False, - fehler=["Standardpfad oder -name fehlen."]#..gib zurück, dass der Test fehlgeschlagen ist - ) - pfad = os.path.join(plugin_pfad, standardname)#...wenn es Standarddatei und Pluginpfad gibt...setze sie zum Pfad zusammen... - elif leer_modus == LeererPfadModus.TEMPORAER_ERLAUBT:#wenn der Modus "temporär" vorgegeben ist,... - return DateipruefErgebnis(#...gib zurück, dass das Prüfergebnis erfolgreich ist (Entscheidung, ob temporör gearbeitet werden soll oder nicht, kommt woanders) - erfolgreich=True, - pfad=None - ) + def __init__( + self, + pfad: str, + basis_pfad: str = "", + leereingabe_erlaubt: bool = False, + standarddatei: str | None = None, + temporaer_erlaubt: bool = False, + ): + self.pfad = pfad + self.basis_pfad = basis_pfad + self.leereingabe_erlaubt = leereingabe_erlaubt + self.standarddatei = standarddatei + self.temporaer_erlaubt = temporaer_erlaubt - # 2. Existiert die Datei bereits? - if os.path.exists(pfad):#wenn die Datei vorhanden ist... - if not vorhandene_datei_entscheidung:#aber noch keine Entscheidung getroffen ist... - return DateipruefErgebnis( - erfolgreich=True,#ist die Prüfung erfolgreich, aber es muss noch eine Entscheidung verlangt werden - pfad=pfad, - entscheidung=None, - fehler=["Datei existiert bereits – Entscheidung ausstehend."] - ) - if vorhandene_datei_entscheidung == DateiEntscheidung.ABBRECHEN: - return DateipruefErgebnis(#...der Nutzer aber abgebrochen hat... - erfolgreich=False,#ist die Prüfung fehlgeschlagen ISSUE: ergibt das Sinn? - pfad=pfad, - fehler=["Benutzer hat abgebrochen."] - ) + # --------------------------------------------------------- + # Hilfsfunktion + # --------------------------------------------------------- - return DateipruefErgebnis( - erfolgreich=True, + def _pfad(self, relativer_pfad: str) -> str: + """Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis.""" + return join_path(self.basis_pfad, relativer_pfad) + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + """ + Prüft eine Dateieingabe und liefert ein pruef_ergebnis zurück. + Der Pruefmanager entscheidet später, wie der Nutzer gefragt wird. + """ + + # ----------------------------------------------------- + # 1. Fall: Eingabe ist leer + # ----------------------------------------------------- + if not self.pfad: + return self._handle_leere_eingabe() + + # ----------------------------------------------------- + # 2. Fall: Eingabe ist nicht leer → Datei prüfen + # ----------------------------------------------------- + pfad = self._pfad(self.pfad) + + if not file_exists(pfad) or not is_file(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Die Datei '{self.pfad}' wurde nicht gefunden.", + aktion="datei_nicht_gefunden", pfad=pfad, - entscheidung=vorhandene_datei_entscheidung ) - # 3. Pfad gültig und Datei nicht vorhanden - #wenn alle Varianten NICHT zutreffen, weil ein gültiger Pfad eingegeben wurde und die Datei noch nicht vorhanden ist: - return DateipruefErgebnis( - erfolgreich=True, - pfad=pfad + # ----------------------------------------------------- + # 3. Datei existiert → Erfolg + # ----------------------------------------------------- + return pruef_ergebnis( + ok=True, + meldung="Datei gefunden.", + aktion="ok", + pfad=pfad, + ) + + # --------------------------------------------------------- + # Behandlung leerer Eingaben + # --------------------------------------------------------- + + def _handle_leere_eingabe(self) -> pruef_ergebnis: + """ + Liefert ein pruef_ergebnis für den Fall, dass das Dateifeld leer ist. + Der Pruefmanager fragt später den Nutzer. + """ + + # 1. Leereingabe erlaubt → Nutzer fragen, ob das beabsichtigt war + if self.leereingabe_erlaubt: + return pruef_ergebnis( + ok=False, + meldung="Das Dateifeld ist leer. Soll ohne Datei fortgefahren werden?", + aktion="leereingabe_erlaubt", + pfad=None, + ) + + # 2. Standarddatei verfügbar → Nutzer fragen, ob sie verwendet werden soll + if self.standarddatei: + return pruef_ergebnis( + ok=False, + meldung=f"Es wurde keine Datei angegeben. Soll die Standarddatei '{self.standarddatei}' verwendet werden?", + aktion="standarddatei_vorschlagen", + pfad=self._pfad(self.standarddatei), + ) + + # 3. Temporäre Datei erlaubt → Nutzer fragen, ob temporär gearbeitet werden soll + if self.temporaer_erlaubt: + return pruef_ergebnis( + ok=False, + meldung="Es wurde keine Datei angegeben. Soll eine temporäre Datei erzeugt werden?", + aktion="temporaer_erlaubt", + pfad=None, + ) + + # 4. Leereingabe nicht erlaubt → Fehler + return pruef_ergebnis( + ok=False, + meldung="Es wurde keine Datei angegeben.", + aktion="leereingabe_nicht_erlaubt", + pfad=None, ) diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index a766aa3..5eea743 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,52 +1,138 @@ -#Pruefmanager.py -from modules.qt_compat import QMessageBox, QFileDialog, YES, NO, CANCEL, ICON_QUESTION, exec_dialog -from modules.Dateipruefer import DateiEntscheidung +""" +sn_basis/modules/pruefmanager.py – zentrale Verarbeitung von pruef_ergebnis-Objekten. +Steuert die Nutzerinteraktion über qgisqt_wrapper. +""" -class PruefManager: +from sn_basis.functions.qgisqt_wrapper import ( + ask_yes_no, + info, + warning, + error, + set_layer_visible, # optional, falls implementiert +) - def __init__(self, iface=None, plugin_pfad=None): - self.iface = iface - self.plugin_pfad = plugin_pfad +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis - def frage_datei_ersetzen_oder_anhaengen(self, pfad: str) -> DateiEntscheidung: - msg = QMessageBox() - msg.setIcon(ICON_QUESTION) - msg.setWindowTitle("Datei existiert") - msg.setText(f"Die Datei '{pfad}' existiert bereits.\nWas möchtest du tun?") - msg.setStandardButtons(YES | NO | CANCEL) - msg.setDefaultButton(YES) +class Pruefmanager: + """ + Verarbeitet pruef_ergebnis-Objekte und steuert die Nutzerinteraktion. + """ - msg.button(YES).setText("Ersetzen") - msg.button(NO).setText("Anhängen") - msg.button(CANCEL).setText("Abbrechen") + def __init__(self, ui_modus: str = "qgis"): + self.ui_modus = ui_modus - result = exec_dialog(msg) + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- - if result == YES: - return DateiEntscheidung.ERSETZEN - elif result == NO: - return DateiEntscheidung.ANHAENGEN - else: - return DateiEntscheidung.ABBRECHEN + def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """ + Verarbeitet ein pruef_ergebnis und führt ggf. Nutzerinteraktion durch. + Rückgabe: neues oder unverändertes pruef_ergebnis. + """ - def frage_temporär_verwenden(self) -> bool: - msg = QMessageBox() - msg.setIcon(ICON_QUESTION) - msg.setWindowTitle("Temporäre Layer") - msg.setText("Kein Speicherpfad wurde angegeben.\nMit temporären Layern fortfahren?") + if ergebnis.ok: + return ergebnis - msg.setStandardButtons(YES | NO) - msg.setDefaultButton(YES) + aktion = ergebnis.aktion - result = exec_dialog(msg) - return result == YES + # ----------------------------------------------------- + # Allgemeine Aktionen + # ----------------------------------------------------- - def waehle_dateipfad(self, titel="Speicherort wählen", filter="GeoPackage (*.gpkg)") -> str: - pfad, _ = QFileDialog.getSaveFileName( - parent=None, - caption=titel, - directory=self.plugin_pfad or "", - filter=filter - ) - return pfad + if aktion == "leer": + warning("Eingabe fehlt", ergebnis.meldung) + return ergebnis + + if aktion == "leereingabe_erlaubt": + if ask_yes_no("Ohne Eingabe fortfahren", ergebnis.meldung): + return pruef_ergebnis(True, "Ohne Eingabe fortgefahren.", "ok", None) + return ergebnis + + if aktion == "leereingabe_nicht_erlaubt": + warning("Eingabe erforderlich", ergebnis.meldung) + return ergebnis + + if aktion == "standarddatei_vorschlagen": + if ask_yes_no("Standarddatei verwenden", ergebnis.meldung): + return pruef_ergebnis(True, "Standarddatei wird verwendet.", "ok", ergebnis.pfad) + return ergebnis + + if aktion == "temporaer_erlaubt": + if ask_yes_no("Temporäre Datei erzeugen", ergebnis.meldung): + return pruef_ergebnis(True, "Temporäre Datei soll erzeugt werden.", "temporaer_erzeugen", None) + return ergebnis + + if aktion == "datei_nicht_gefunden": + warning("Datei nicht gefunden", ergebnis.meldung) + return ergebnis + + if aktion == "kein_dateipfad": + warning("Ungültiger Pfad", ergebnis.meldung) + return ergebnis + + if aktion == "pfad_nicht_gefunden": + warning("Pfad nicht gefunden", ergebnis.meldung) + return ergebnis + + if aktion == "url_nicht_erreichbar": + warning("URL nicht erreichbar", ergebnis.meldung) + return ergebnis + + if aktion == "netzwerkfehler": + error("Netzwerkfehler", ergebnis.meldung) + return ergebnis + + # ----------------------------------------------------- + # Layer-Aktionen + # ----------------------------------------------------- + + if aktion == "layer_nicht_gefunden": + error("Layer fehlt", ergebnis.meldung) + return ergebnis + + if aktion == "layer_unsichtbar": + if ask_yes_no("Layer einblenden", ergebnis.meldung): + # Falls set_layer_visible implementiert ist + try: + set_layer_visible(ergebnis.pfad, True) + except Exception: + pass + return pruef_ergebnis(True, "Layer wurde eingeblendet.", "ok", ergebnis.pfad) + return ergebnis + + if aktion == "falscher_geotyp": + warning("Falscher Geometrietyp", ergebnis.meldung) + return ergebnis + + if aktion == "layer_leer": + warning("Layer enthält keine Objekte", ergebnis.meldung) + return ergebnis + + if aktion == "falscher_layertyp": + warning("Falscher Layertyp", ergebnis.meldung) + return ergebnis + + if aktion == "falsches_crs": + warning("Falsches CRS", ergebnis.meldung) + return ergebnis + + if aktion == "felder_fehlen": + warning("Fehlende Felder", ergebnis.meldung) + return ergebnis + + if aktion == "datenquelle_unerwartet": + warning("Unerwartete Datenquelle", ergebnis.meldung) + return ergebnis + + if aktion == "layer_nicht_editierbar": + warning("Layer nicht editierbar", ergebnis.meldung) + return ergebnis + + # ----------------------------------------------------- + # Fallback + # ----------------------------------------------------- + + warning("Unbekannte Aktion", f"Unbekannte Aktion: {aktion}") + return ergebnis diff --git a/modules/layerpruefer.py b/modules/layerpruefer.py new file mode 100644 index 0000000..b0d5c56 --- /dev/null +++ b/modules/layerpruefer.py @@ -0,0 +1,170 @@ +""" +sn_basis/modules/layerpruefer.py – Prüfung von QGIS-Layern. +Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + layer_exists, + get_layer_geometry_type, + get_layer_feature_count, + is_layer_visible, + get_layer_type, + get_layer_crs, + get_layer_fields, + get_layer_source, + is_layer_editable, +) + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +class Layerpruefer: + """ + Prüft Layer auf Existenz, Sichtbarkeit, Geometrietyp, Objektanzahl, + Layertyp, CRS, Felder, Datenquelle und Editierbarkeit. + """ + + def __init__( + self, + layer, + erwarteter_geotyp: str | None = None, + muss_sichtbar_sein: bool = False, + erwarteter_layertyp: str | None = None, + erwartetes_crs: str | None = None, + erforderliche_felder: list[str] | None = None, + erlaubte_datenquellen: list[str] | None = None, + muss_editierbar_sein: bool = False, + ): + self.layer = layer + self.erwarteter_geotyp = erwarteter_geotyp + self.muss_sichtbar_sein = muss_sichtbar_sein + self.erwarteter_layertyp = erwarteter_layertyp + self.erwartetes_crs = erwartetes_crs + self.erforderliche_felder = erforderliche_felder or [] + self.erlaubte_datenquellen = erlaubte_datenquellen or [] + self.muss_editierbar_sein = muss_editierbar_sein + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + + # ----------------------------------------------------- + # 1. Existenz + # ----------------------------------------------------- + if not layer_exists(self.layer): + return pruef_ergebnis( + ok=False, + meldung="Der Layer existiert nicht oder wurde nicht geladen.", + aktion="layer_nicht_gefunden", # type: ignore + pfad=None, + ) + + # ----------------------------------------------------- + # 2. Sichtbarkeit + # ----------------------------------------------------- + sichtbar = is_layer_visible(self.layer) + if self.muss_sichtbar_sein and not sichtbar: + return pruef_ergebnis( + ok=False, + meldung="Der Layer ist unsichtbar. Soll er eingeblendet werden?", + aktion="layer_unsichtbar", # type: ignore + pfad=self.layer, # Layerobjekt wird übergeben + ) + + # ----------------------------------------------------- + # 3. Layertyp + # ----------------------------------------------------- + layertyp = get_layer_type(self.layer) + if self.erwarteter_layertyp and layertyp != self.erwarteter_layertyp: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat den Typ '{layertyp}', erwartet wurde '{self.erwarteter_layertyp}'.", + aktion="falscher_layertyp", + pfad=None, + ) + + # ----------------------------------------------------- + # 4. Geometrietyp + # ----------------------------------------------------- + geotyp = get_layer_geometry_type(self.layer) + if self.erwarteter_geotyp and geotyp != self.erwarteter_geotyp: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat den Geometrietyp '{geotyp}', erwartet wurde '{self.erwarteter_geotyp}'.", + aktion="falscher_geotyp", + pfad=None, + ) + + # ----------------------------------------------------- + # 5. Featureanzahl + # ----------------------------------------------------- + anzahl = get_layer_feature_count(self.layer) + if anzahl == 0: + return pruef_ergebnis( + ok=False, + meldung="Der Layer enthält keine Objekte.", + aktion="layer_leer", + pfad=None, + ) + + # ----------------------------------------------------- + # 6. CRS + # ----------------------------------------------------- + crs = get_layer_crs(self.layer) + if self.erwartetes_crs and crs != self.erwartetes_crs: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer hat das CRS '{crs}', erwartet wurde '{self.erwartetes_crs}'.", + aktion="falsches_crs", + pfad=None, + ) + + # ----------------------------------------------------- + # 7. Felder + # ----------------------------------------------------- + felder = get_layer_fields(self.layer) + fehlende = [f for f in self.erforderliche_felder if f not in felder] + + if fehlende: + return pruef_ergebnis( + ok=False, + meldung=f"Der Layer enthält nicht alle erforderlichen Felder: {', '.join(fehlende)}", + aktion="felder_fehlen", + pfad=None, + ) + + # ----------------------------------------------------- + # 8. Datenquelle + # ----------------------------------------------------- + quelle = get_layer_source(self.layer) + if self.erlaubte_datenquellen and quelle not in self.erlaubte_datenquellen: + return pruef_ergebnis( + ok=False, + meldung=f"Die Datenquelle '{quelle}' ist nicht erlaubt.", + aktion="datenquelle_unerwartet", + pfad=None, + ) + + # ----------------------------------------------------- + # 9. Editierbarkeit + # ----------------------------------------------------- + editable = is_layer_editable(self.layer) + if self.muss_editierbar_sein and not editable: + return pruef_ergebnis( + ok=False, + meldung="Der Layer ist nicht editierbar.", + aktion="layer_nicht_editierbar", + pfad=None, + ) + + # ----------------------------------------------------- + # 10. Alles OK + # ----------------------------------------------------- + return pruef_ergebnis( + ok=True, + meldung="Layerprüfung erfolgreich.", + aktion="ok", + pfad=None, + ) diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index 9283c85..0b15889 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -1,77 +1,141 @@ -# Linkpruefer.py – Qt5/Qt6-kompatibel über qt_compat +""" +sn_basis/modules/linkpruefer.py – Prüfung von URLs und lokalen Links. +Verwendet syswrapper und qgisqt_wrapper. +Gibt pruef_ergebnis an den Pruefmanager zurück. +""" -from modules.qt_compat import ( - QEventLoop, - QUrl, - QNetworkRequest, - QNetworkReply +from sn_basis.functions.syswrapper import ( + file_exists, + is_file, + join_path, ) -from qgis.core import QgsNetworkAccessManager -from modules.pruef_ergebnis import PruefErgebnis +from sn_basis.functions.qgisqt_wrapper import ( + network_head, +) + +from sn_basis.modules.Pruefmanager import pruef_ergebnis class Linkpruefer: - """Prüft den Link mit QgsNetworkAccessManager und klassifiziert Anbieter nach Attribut.""" + """ + Prüft URLs und lokale Pfade. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + """ - ANBIETER_TYPEN: dict[str, str] = { - "REST": "REST", - "WFS": "WFS", - "WMS": "WMS", - "OGR": "OGR" - } + def __init__(self, basis_pfad: str | None = None): + """ + basis_pfad: optionaler Basisordner für relative Pfade. + """ + self.basis = basis_pfad - def __init__(self, link: str, anbieter: str): - self.link = link - self.anbieter = anbieter.upper().strip() if anbieter else "" - self.network_manager = QgsNetworkAccessManager() + # --------------------------------------------------------- + # Hilfsfunktionen + # --------------------------------------------------------- - def klassifiziere_anbieter(self): - typ = self.ANBIETER_TYPEN.get(self.anbieter, self.anbieter) - quelle = "remote" if self.link.startswith(("http://", "https://")) else "local" - return {"typ": typ, "quelle": quelle} + def _pfad(self, relativer_pfad: str) -> str: + """Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis.""" + if not self.basis: + return relativer_pfad + return join_path(self.basis, relativer_pfad) - def pruefe_link(self): - fehler = [] - warnungen = [] + def _ist_url(self, text: str) -> bool: + """Einfache URL-Erkennung.""" + return text.startswith("http://") or text.startswith("https://") - if not self.link: - fehler.append("Link fehlt.") - return PruefErgebnis(False, fehler=fehler, warnungen=warnungen) + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- - if not self.anbieter or not self.anbieter.strip(): - fehler.append("Anbieter muss gesetzt werden und darf nicht leer sein.") + def pruefe(self, eingabe: str) -> pruef_ergebnis: + """ + Prüft einen Link (URL oder lokalen Pfad). + Rückgabe: pruef_ergebnis + """ - # Remote-Links prüfen - if self.link.startswith(("http://", "https://")): - request = QNetworkRequest(QUrl(self.link)) - reply = self.network_manager.head(request) + if not eingabe: + return pruef_ergebnis( + ok=False, + meldung="Es wurde kein Link angegeben.", + aktion="leer", + pfad=None, + ) - loop = QEventLoop() - reply.finished.connect(loop.quit) - loop.exec() # Qt5/Qt6-kompatibel über qt_compat + # ----------------------------------------------------- + # 1. Fall: URL + # ----------------------------------------------------- + if self._ist_url(eingabe): + return self._pruefe_url(eingabe) - # Fehlerprüfung Qt5/Qt6-kompatibel - if reply.error() != QNetworkReply.NetworkError.NoError: - fehler.append(f"Verbindungsfehler: {reply.errorString()}") - else: - status = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status is None or status < 200 or status >= 400: - fehler.append(f"Link nicht erreichbar: HTTP {status}") + # ----------------------------------------------------- + # 2. Fall: lokaler Pfad + # ----------------------------------------------------- + return self._pruefe_dateipfad(eingabe) - reply.deleteLater() + # --------------------------------------------------------- + # URL‑Prüfung + # --------------------------------------------------------- - else: - # Lokale Pfade: Plausibilitätscheck - if "." not in self.link.split("/")[-1]: - warnungen.append("Der lokale Link sieht ungewöhnlich aus.") + def _pruefe_url(self, url: str) -> pruef_ergebnis: + """ + Prüft eine URL über einen HEAD‑Request. + """ - return PruefErgebnis( - len(fehler) == 0, - daten=self.klassifiziere_anbieter(), - fehler=fehler, - warnungen=warnungen + reply = network_head(url) + + if reply is None: + return pruef_ergebnis( + ok=False, + meldung=f"Die URL '{url}' konnte nicht geprüft werden.", + aktion="netzwerkfehler", + pfad=url, + ) + + if reply.error != 0: + return pruef_ergebnis( + ok=False, + meldung=f"Die URL '{url}' ist nicht erreichbar.", + aktion="url_nicht_erreichbar", + pfad=url, + ) + + return pruef_ergebnis( + ok=True, + meldung="URL ist erreichbar.", + aktion="ok", + pfad=url, ) - def ausfuehren(self): - return self.pruefe_link() + # --------------------------------------------------------- + # Lokale Datei‑/Pfadprüfung + # --------------------------------------------------------- + + def _pruefe_dateipfad(self, eingabe: str) -> pruef_ergebnis: + """ + Prüft einen lokalen Pfad. + """ + + pfad = self._pfad(eingabe) + + if not file_exists(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Der Pfad '{eingabe}' wurde nicht gefunden.", + aktion="pfad_nicht_gefunden", + pfad=pfad, + ) + + if not is_file(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Der Pfad '{eingabe}' ist keine Datei.", + aktion="kein_dateipfad", + pfad=pfad, + ) + + return pruef_ergebnis( + ok=True, + meldung="Dateipfad ist gültig.", + aktion="ok", + pfad=pfad, + ) diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index 54ecda4..e15d8ad 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,12 +1,58 @@ -#pruef_ergebnis.py -# Klasse zur Definition eines Pruefergebnis-Objekts, das in allen Prüfern verwendet werden kann -class PruefErgebnis: - def __init__(self, erfolgreich: bool, daten=None, fehler=None, warnungen=None): - self.erfolgreich = erfolgreich - self.daten = daten or {} - self.fehler = fehler or [] - self.warnungen = warnungen or [] +""" +sn_basis/modules/pruef_ergebnis.py – Ergebnisobjekt für alle Prüfer. - def __repr__(self): - return (f"PruefErgebnis(erfolgreich={self.erfolgreich}, " - f"daten={self.daten}, fehler={self.fehler}, warnungen={self.warnungen})") +""" + +from dataclasses import dataclass +from typing import Optional, Literal + + +# Alle möglichen Aktionen, die ein Prüfer auslösen kann. +# Erweiterbar ohne Umbau der Klasse. +PruefAktion = Literal[ + "ok", + "leer", + "leereingabe_erlaubt", + "leereingabe_nicht_erlaubt", + "standarddatei_vorschlagen", + "temporaer_erlaubt", + "datei_nicht_gefunden", + "kein_dateipfad", + "pfad_nicht_gefunden", + "url_nicht_erreichbar", + "netzwerkfehler", + "falscher_layertyp", + "falscher_geotyp", + "layer_leer", + "falsches_crs", + "felder_fehlen", + "datenquelle_unerwartet", + "layer_nicht_editierbar", + "temporaer_erzeugen", + "stil_nicht_anwendbar", + "layer_unsichtbar", + "unbekannt", +] + + +@dataclass +class pruef_ergebnis: + """ + Reines Datenobjekt, das das Ergebnis einer Prüfung beschreibt. + + ok: True → Prüfung erfolgreich + False → Nutzerinteraktion oder Fehler nötig + + meldung: Text, der dem Nutzer angezeigt werden soll + + aktion: Maschinenlesbarer Code, der dem Pruefmanager sagt, + wie er weiter verfahren soll + + pfad: Optionaler Pfad oder URL, die geprüft wurde oder + verwendet werden soll + """ + + ok: bool + meldung: str + aktion: PruefAktion + pfad: Optional[str] = None diff --git a/modules/qt_compat.py b/modules/qt_compat.py deleted file mode 100644 index dca7495..0000000 --- a/modules/qt_compat.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -qt_compat.py – Einheitliche Qt-Kompatibilitätsschicht für QGIS-Plugins. - -Ziele: -- PyQt6 bevorzugt -- Fallback auf PyQt5 -- Mock-Modus, wenn kein Qt verfügbar ist (z. B. in Unittests) -- OR-fähige Fake-Enums im Mock-Modus -""" - -QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 - -# --------------------------------------------------------- -# Versuch: PyQt6 importieren -# --------------------------------------------------------- -try: - from PyQt6.QtWidgets import QMessageBox, QFileDialog - from PyQt6.QtCore import Qt, QEventLoop, QUrl - from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply - - YES = QMessageBox.StandardButton.Yes - NO = QMessageBox.StandardButton.No - CANCEL = QMessageBox.StandardButton.Cancel - ICON_QUESTION = QMessageBox.Icon.Question - - QT_VERSION = 6 - - def exec_dialog(dialog): - """Einheitliche Ausführung eines Dialogs.""" - return dialog.exec() - -# --------------------------------------------------------- -# Versuch: PyQt5 importieren -# --------------------------------------------------------- -except Exception: - try: - from PyQt5.QtWidgets import QMessageBox, QFileDialog - from PyQt5.QtCore import Qt, QEventLoop, QUrl - from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply - - YES = QMessageBox.Yes - NO = QMessageBox.No - CANCEL = QMessageBox.Cancel - ICON_QUESTION = QMessageBox.Question - - QT_VERSION = 5 - - def exec_dialog(dialog): - return dialog.exec_() - - # --------------------------------------------------------- - # Mock-Modus (kein Qt verfügbar) - # --------------------------------------------------------- - except Exception: - QT_VERSION = 0 - - class FakeEnum(int): - """Ein OR-fähiger Enum-Ersatz für den Mock-Modus.""" - def __or__(self, other): - return FakeEnum(int(self) | int(other)) - - class QMessageBox: - Yes = FakeEnum(1) - No = FakeEnum(2) - Cancel = FakeEnum(4) - Question = FakeEnum(8) - - class QFileDialog: - """Minimaler Mock für QFileDialog.""" - @staticmethod - def getOpenFileName(*args, **kwargs): - return ("", "") # kein Dateipfad - - YES = QMessageBox.Yes - NO = QMessageBox.No - CANCEL = QMessageBox.Cancel - ICON_QUESTION = QMessageBox.Question - - def exec_dialog(dialog): - """Mock-Ausführung: gibt YES zurück, außer Tests patchen es.""" - return YES - # ------------------------- - # Mock Netzwerk-Klassen - # ------------------------- - class QEventLoop: - def exec(self): - return 0 - - def quit(self): - pass - - class QUrl(str): - pass - - class QNetworkRequest: - def __init__(self, url): - self.url = url - - class QNetworkReply: - def __init__(self): - self._data = b"" - - def readAll(self): - return self._data - - def error(self): - return 0 - - def exec_dialog(dialog): - return YES - \ No newline at end of file diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py index dc43f4d..43734f2 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -1,46 +1,59 @@ -#stilpruefer.py -import os -from modules.pruef_ergebnis import PruefErgebnis +""" +sn_basis/modules/stilpruefer.py – Prüfung und Anwendung von Layerstilen. +Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +""" + +from sn_basis.functions.qgisqt_wrapper import ( + apply_style, +) + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class Stilpruefer: """ - Prüft, ob ein angegebener Stilpfad gültig und nutzbar ist. - - Wenn kein Stil angegeben ist, gilt die Prüfung als erfolgreich. - - Wenn angegeben: - * Datei muss existieren - * Dateiendung muss '.qml' sein + Prüft, ob ein Stil auf einen Layer angewendet werden kann. + Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. """ - def pruefe(self, stilpfad: str) -> PruefErgebnis: - # kein Stil angegeben -> erfolgreich, keine Warnung - if not stilpfad or stilpfad.strip() == "": - return PruefErgebnis( - erfolgreich=True, - daten={"stil": None}, - warnungen=["Kein Stil angegeben."] + def __init__(self, layer, stil_pfad: str): + """ + layer: QGIS-Layer oder Mock-Layer + stil_pfad: relativer oder absoluter Pfad zum .qml-Stil + """ + self.layer = layer + self.stil_pfad = stil_pfad + + # --------------------------------------------------------- + # Hauptfunktion + # --------------------------------------------------------- + + def pruefe(self) -> pruef_ergebnis: + """ + Versucht, den Stil anzuwenden. + Rückgabe: pruef_ergebnis + """ + + # Wrapper übernimmt: + # - Pfadberechnung + # - Existenzprüfung + # - loadNamedStyle + # - Fehlerbehandlung + # - Mock-Modus + erfolg, meldung = apply_style(self.layer, self.stil_pfad) + + if erfolg: + return pruef_ergebnis( + ok=True, + meldung=f"Stil erfolgreich angewendet: {self.stil_pfad}", + aktion="ok", + pfad=self.stil_pfad, ) - fehler = [] - warnungen = [] - - # Prüfung: Datei existiert? - if not os.path.exists(stilpfad): - fehler.append(f"Stildatei nicht gefunden: {stilpfad}") - - # Prüfung: Endung .qml? - elif not stilpfad.lower().endswith(".qml"): - fehler.append(f"Ungültige Dateiendung für Stil: {stilpfad}") - else: - # Hinweis: alle Checks bestanden - return PruefErgebnis( - erfolgreich=True, - daten={"stil": stilpfad} - ) - - return PruefErgebnis( - erfolgreich=False if fehler else True, - daten={"stil": stilpfad}, - fehler=fehler, - warnungen=warnungen + # Fehlerfall → Nutzerinteraktion nötig + return pruef_ergebnis( + ok=False, + meldung=meldung, + aktion="stil_nicht_anwendbar", + pfad=self.stil_pfad, ) diff --git a/test/run_tests.py b/test/run_tests.py index a0ec181..7c2b03a 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -1,11 +1,49 @@ -#run_tests.py -import sys -import os +""" +sn_basis/test/run_tests.py + +Zentraler Test-Runner für sn_basis. +Wrapper-konform, QGIS-unabhängig, CI- und IDE-fähig. +""" + import unittest import datetime import inspect +import os +import sys + + + +# Minimaler Bootstrap, um sn_basis importierbar zu machen +TEST_DIR = os.path.dirname(__file__) +PLUGIN_ROOT = os.path.abspath(os.path.join(TEST_DIR, "..", "..")) + +if PLUGIN_ROOT not in sys.path: + sys.path.insert(0, PLUGIN_ROOT) + + +from sn_basis.functions import syswrapper + +# --------------------------------------------------------- +# Bootstrap: Plugin-Root in sys.path eintragen +# --------------------------------------------------------- + +def bootstrap(): + """ + Simuliert das QGIS-Plugin-Startverhalten: + stellt sicher, dass sn_basis importierbar ist. + """ + plugin_root = syswrapper.get_plugin_root() + syswrapper.add_to_sys_path(plugin_root) + + +bootstrap() + + +# --------------------------------------------------------- # Farben +# --------------------------------------------------------- + RED = "\033[91m" YELLOW = "\033[93m" GREEN = "\033[92m" @@ -13,36 +51,30 @@ CYAN = "\033[96m" MAGENTA = "\033[95m" RESET = "\033[0m" -# Globaler Testzähler GLOBAL_TEST_COUNTER = 0 # --------------------------------------------------------- -# Eigene TestResult-Klasse (färbt Fehler/Skipped/OK) +# Farbige TestResult-Klasse # --------------------------------------------------------- + class ColoredTestResult(unittest.TextTestResult): def startTest(self, test): - """Vor jedem Test eine Nummer ausgeben.""" global GLOBAL_TEST_COUNTER GLOBAL_TEST_COUNTER += 1 self.stream.write(f"{CYAN}[Test {GLOBAL_TEST_COUNTER}]{RESET}\n") super().startTest(test) - def startTestRun(self): - """Wird einmal zu Beginn des gesamten Testlaufs ausgeführt.""" - super().startTestRun() - def startTestClass(self, test): - """Wird aufgerufen, wenn eine neue Testklasse beginnt.""" cls = test.__class__ file = inspect.getfile(cls) filename = os.path.basename(file) self.stream.write( - f"\n{MAGENTA}{'='*70}\n" + f"\n{MAGENTA}{'=' * 70}\n" f"Starte Testklasse: {filename} → {cls.__name__}\n" - f"{'='*70}{RESET}\n" + f"{'=' * 70}{RESET}\n" ) def addError(self, test, err): @@ -57,31 +89,27 @@ class ColoredTestResult(unittest.TextTestResult): super().addSkip(test, reason) self.stream.write(f"{YELLOW}SKIPPED{RESET}: {reason}\n") - # unittest ruft diese Methode nicht automatisch auf → wir patchen es unten def addSuccess(self, test): super().addSuccess(test) self.stream.write(f"{GREEN}OK{RESET}\n") # --------------------------------------------------------- -# Eigener TestRunner, der unser ColoredTestResult nutzt +# Farbiger TestRunner # --------------------------------------------------------- + class ColoredTestRunner(unittest.TextTestRunner): resultclass = ColoredTestResult def _makeResult(self): result = super()._makeResult() - - # Patch: unittest ruft startTestClass nicht automatisch auf original_start_test = result.startTest def patched_start_test(test): - # Wenn neue Klasse → Kopf ausgeben if not hasattr(result, "_last_test_class") or \ result._last_test_class != test.__class__: result.startTestClass(test) result._last_test_class = test.__class__ - original_start_test(test) result.startTest = patched_start_test @@ -89,37 +117,30 @@ class ColoredTestRunner(unittest.TextTestRunner): # --------------------------------------------------------- -# Testlauf starten +# Testlauf starten # --------------------------------------------------------- -print("\n" + "="*70) -print(f"{CYAN}Testlauf gestartet am: {datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}") -print("="*70 + "\n") - -# Projekt-Root dem Suchpfad hinzufügen -project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -if project_root not in sys.path: - sys.path.insert(0, project_root) - def main(): + print("\n" + "=" * 70) + print( + f"{CYAN}Testlauf gestartet am: " + f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}" + ) + print("=" * 70 + "\n") + loader = unittest.TestLoader() - suite = unittest.TestSuite() - test_modules = [ - "test_dateipruefer", - "test_stilpruefer", - "test_linkpruefer", - "test_qt_compat", - "test_pruefmanager", - ] - - for mod_name in test_modules: - mod = __import__(mod_name) - suite.addTests(loader.loadTestsFromModule(mod)) + suite = loader.discover( + start_dir=os.path.dirname(__file__), + pattern="test_*.py" + ) runner = ColoredTestRunner(verbosity=2) - runner.run(suite) + result = runner.run(suite) + + # Exit-Code für CI / Skripte + return 0 if result.wasSuccessful() else 1 if __name__ == "__main__": - main() + raise SystemExit(main()) diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py new file mode 100644 index 0000000..10db7cd --- /dev/null +++ b/test/test_bootstrap.py @@ -0,0 +1,2 @@ +from sn_basis.functions import syswrapper +syswrapper.add_to_sys_path(syswrapper.get_plugin_root()) diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py index f6f537b..d61ac70 100644 --- a/test/test_dateipruefer.py +++ b/test/test_dateipruefer.py @@ -1,89 +1,102 @@ -#test_dateipruefer.py +# sn_basis/test/test_dateipruefer.py + import unittest -import os -import tempfile -import sys -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) -from modules.Dateipruefer import ( - Dateipruefer, - LeererPfadModus, - DateiEntscheidung, - DateipruefErgebnis -) +from unittest.mock import patch + +from sn_basis.modules.Dateipruefer import Dateipruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + class TestDateipruefer(unittest.TestCase): - def setUp(self): - self.pruefer = Dateipruefer() - self.plugin_pfad = tempfile.gettempdir() - self.standardname = "test_standard.gpkg" - def test_verbotener_leerer_pfad(self): - result = self.pruefer.pruefe( + # ----------------------------------------------------- + # 1. Leere Eingabe erlaubt + # ----------------------------------------------------- + def test_leereingabe_erlaubt(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.VERBOTEN + leereingabe_erlaubt=True ) - self.assertFalse(result.erfolgreich) - self.assertIn("Kein Pfad angegeben.", result.fehler) - def test_standardpfad_wird_verwendet(self): - result = self.pruefer.pruefe( + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "leereingabe_erlaubt") + + # ----------------------------------------------------- + # 2. Leere Eingabe nicht erlaubt + # ----------------------------------------------------- + def test_leereingabe_nicht_erlaubt(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.NUTZE_STANDARD, - standardname=self.standardname, - plugin_pfad=self.plugin_pfad + leereingabe_erlaubt=False ) - self.assertTrue(result.erfolgreich) - expected_path = os.path.join(self.plugin_pfad, self.standardname) - self.assertEqual(result.pfad, expected_path) - def test_temporärer_layer_wird_erkannt(self): - result = self.pruefer.pruefe( + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "leereingabe_nicht_erlaubt") + + # ----------------------------------------------------- + # 3. Standarddatei vorschlagen + # ----------------------------------------------------- + def test_standarddatei_vorschlagen(self): + pruefer = Dateipruefer( pfad="", - leer_modus=LeererPfadModus.TEMPORAER_ERLAUBT + standarddatei="/tmp/std.txt" ) - self.assertTrue(result.erfolgreich) - self.assertIsNone(result.pfad) - self.assertFalse(result.temporär) - def test_existierende_datei_ohne_entscheidung(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_path = tmp_file.name - try: - result = self.pruefer.pruefe( - pfad=tmp_path, - leer_modus=LeererPfadModus.VERBOTEN - ) - self.assertTrue(result.erfolgreich) # neu: jetzt True, nicht False - self.assertIn("Datei existiert bereits – Entscheidung ausstehend.", result.fehler) - self.assertIsNone(result.entscheidung) - finally: - os.remove(tmp_path) + result = pruefer.pruefe() - def test_existierende_datei_mit_entscheidung_ersetzen(self): - with tempfile.NamedTemporaryFile(delete=False) as tmp_file: - tmp_path = tmp_file.name - try: - result = self.pruefer.pruefe( - pfad=tmp_path, - leer_modus=LeererPfadModus.VERBOTEN, - vorhandene_datei_entscheidung=DateiEntscheidung.ERSETZEN - ) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.entscheidung, DateiEntscheidung.ERSETZEN) - finally: - os.remove(tmp_path) + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "standarddatei_vorschlagen") + self.assertEqual(result.pfad, "/tmp/std.txt") - def test_datei_nicht_existiert(self): - fake_path = os.path.join(self.plugin_pfad, "nicht_existierend.gpkg") - result = self.pruefer.pruefe( - pfad=fake_path, - leer_modus=LeererPfadModus.VERBOTEN + # ----------------------------------------------------- + # 4. Temporäre Datei erlaubt + # ----------------------------------------------------- + def test_temporaer_erlaubt(self): + pruefer = Dateipruefer( + pfad="", + temporaer_erlaubt=True ) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.pfad, fake_path) + + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "temporaer_erlaubt") + + # ----------------------------------------------------- + # 5. Datei existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + def test_datei_nicht_gefunden(self, mock_exists): + pruefer = Dateipruefer( + pfad="/tmp/nichtvorhanden.txt" + ) + + result = pruefer.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "datei_nicht_gefunden") + + # ----------------------------------------------------- + # 6. Datei existiert + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_ok(self, mock_isfile, mock_exists): + pruefer = Dateipruefer( + pfad="/tmp/test.txt" + ) + + result = pruefer.pruefe() + + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertEqual(result.pfad, "/tmp/test.txt") if __name__ == "__main__": diff --git a/test/test_layerpruefer.py b/test/test_layerpruefer.py new file mode 100644 index 0000000..46bde78 --- /dev/null +++ b/test/test_layerpruefer.py @@ -0,0 +1,170 @@ +# sn_basis/test/test_layerpruefer.py + +import unittest + +from sn_basis.modules.layerpruefer import Layerpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +# --------------------------------------------------------- +# Mock-Layer für Wrapper-Tests +# --------------------------------------------------------- +class MockLayer: + def __init__( + self, + exists=True, + visible=True, + layer_type="vector", + geometry_type="Polygon", + feature_count=10, + crs="EPSG:25833", + fields=None, + source="/tmp/test.shp", + editable=True, + ): + self.exists = exists + self.visible = visible + self.layer_type = layer_type + self.geometry_type = geometry_type + self.feature_count = feature_count + self.crs = crs + self.fields = fields or [] + self.source = source + self.editable = editable + + +# --------------------------------------------------------- +# Wrapper-Mocks (monkeypatching) +# --------------------------------------------------------- +def mock_layer_exists(layer): + return layer is not None and layer.exists + + +def mock_is_layer_visible(layer): + return layer.visible + + +def mock_get_layer_type(layer): + return layer.layer_type + + +def mock_get_layer_geometry_type(layer): + return layer.geometry_type + + +def mock_get_layer_feature_count(layer): + return layer.feature_count + + +def mock_get_layer_crs(layer): + return layer.crs + + +def mock_get_layer_fields(layer): + return layer.fields + + +def mock_get_layer_source(layer): + return layer.source + + +def mock_is_layer_editable(layer): + return layer.editable + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- +class TestLayerpruefer(unittest.TestCase): + + def setUp(self): + # Monkeypatching der Wrapper-Funktionen + import sn_basis.functions.qgisqt_wrapper as wrapper + + wrapper.layer_exists = mock_layer_exists + wrapper.is_layer_visible = mock_is_layer_visible + wrapper.get_layer_type = mock_get_layer_type + wrapper.get_layer_geometry_type = mock_get_layer_geometry_type + wrapper.get_layer_feature_count = mock_get_layer_feature_count + wrapper.get_layer_crs = mock_get_layer_crs + wrapper.get_layer_fields = mock_get_layer_fields + wrapper.get_layer_source = mock_get_layer_source + wrapper.is_layer_editable = mock_is_layer_editable + + # ----------------------------------------------------- + # Tests + # ----------------------------------------------------- + + def test_layer_exists(self): + layer = MockLayer(exists=False) + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_nicht_gefunden") + + def test_layer_unsichtbar(self): + layer = MockLayer(visible=False) + pruefer = Layerpruefer(layer, muss_sichtbar_sein=True) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_unsichtbar") + + def test_falscher_layertyp(self): + layer = MockLayer(layer_type="raster") + pruefer = Layerpruefer(layer, erwarteter_layertyp="vector") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falscher_layertyp") + + def test_falscher_geotyp(self): + layer = MockLayer(geometry_type="Point") + pruefer = Layerpruefer(layer, erwarteter_geotyp="Polygon") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falscher_geotyp") + + def test_layer_leer(self): + layer = MockLayer(feature_count=0) + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_leer") + + def test_falsches_crs(self): + layer = MockLayer(crs="EPSG:4326") + pruefer = Layerpruefer(layer, erwartetes_crs="EPSG:25833") + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "falsches_crs") + + def test_felder_fehlen(self): + layer = MockLayer(fields=["id"]) + pruefer = Layerpruefer(layer, erforderliche_felder=["id", "name"]) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "felder_fehlen") + + def test_datenquelle_unerwartet(self): + layer = MockLayer(source="/tmp/test.shp") + pruefer = Layerpruefer(layer, erlaubte_datenquellen=["/tmp/allowed.shp"]) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "datenquelle_unerwartet") + + def test_layer_nicht_editierbar(self): + layer = MockLayer(editable=False) + pruefer = Layerpruefer(layer, muss_editierbar_sein=True) + ergebnis = pruefer.pruefe() + self.assertFalse(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "layer_nicht_editierbar") + + def test_layer_ok(self): + layer = MockLayer() + pruefer = Layerpruefer(layer) + ergebnis = pruefer.pruefe() + self.assertTrue(ergebnis.ok) + self.assertEqual(ergebnis.aktion, "ok") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py index d9d4206..89ea0e3 100644 --- a/test/test_linkpruefer.py +++ b/test/test_linkpruefer.py @@ -1,77 +1,107 @@ -#test_linkpruefer.py +# sn_basis/test/test_linkpruefer.py + import unittest -from unittest.mock import MagicMock, patch +from unittest.mock import patch -# QGIS-Module mocken, damit der Import funktioniert -with patch.dict("sys.modules", { - "qgis": MagicMock(), - "qgis.PyQt": MagicMock(), - "qgis.PyQt.QtCore": MagicMock(), - "qgis.PyQt.QtNetwork": MagicMock(), - "qgis.core": MagicMock(), -}): - from modules.linkpruefer import Linkpruefer +from sn_basis.modules.linkpruefer import Linkpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +# --------------------------------------------------------- +# Mock-Ergebnisse für network_head() +# --------------------------------------------------------- + +class MockResponseOK: + ok = True + status = 200 + error = None + + +class MockResponseNotFound: + ok = False + status = 404 + error = "Not Found" + + +class MockResponseConnectionError: + ok = False + status = None + error = "Connection refused" + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- + class TestLinkpruefer(unittest.TestCase): - @patch("modules.linkpruefer.QNetworkReply") - @patch("modules.linkpruefer.QNetworkRequest") - @patch("modules.linkpruefer.QUrl") - @patch("modules.linkpruefer.QEventLoop") - @patch("modules.linkpruefer.QgsNetworkAccessManager") - def test_remote_link_ok( - self, mock_manager, mock_loop, mock_url, mock_request, mock_reply - ): - # Setup: simulate successful HEAD request - reply_instance = MagicMock() - reply_instance.error.return_value = mock_reply.NetworkError.NoError - reply_instance.attribute.return_value = 200 - - mock_manager.return_value.head.return_value = reply_instance + # ----------------------------------------------------- + # 1. Remote-Link erreichbar + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_ok(self, mock_head): + mock_head.return_value = MockResponseOK() lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten["quelle"], "remote") + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") - @patch("modules.linkpruefer.QNetworkReply") - @patch("modules.linkpruefer.QNetworkRequest") - @patch("modules.linkpruefer.QUrl") - @patch("modules.linkpruefer.QEventLoop") - @patch("modules.linkpruefer.QgsNetworkAccessManager") - def test_remote_link_error( - self, mock_manager, mock_loop, mock_url, mock_request, mock_reply - ): - # Fake-Reply erzeugen - reply_instance = MagicMock() - reply_instance.error.return_value = mock_reply.NetworkError.ConnectionRefusedError - reply_instance.errorString.return_value = "Connection refused" - - # WICHTIG: finished-Signal simulieren - reply_instance.finished = MagicMock() - reply_instance.finished.connect = MagicMock() - - # Wenn loop.exec() aufgerufen wird, rufen wir loop.quit() sofort auf - mock_loop.return_value.exec.side_effect = lambda: mock_loop.return_value.quit() - - # Manager gibt unser Fake-Reply zurück - mock_manager.return_value.head.return_value = reply_instance + # ----------------------------------------------------- + # 2. Remote-Link nicht erreichbar + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_error(self, mock_head): + mock_head.return_value = MockResponseConnectionError() lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertFalse(result.erfolgreich) - self.assertIn("Verbindungsfehler", result.fehler[0]) + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "url_nicht_erreichbar") + self.assertIn("Connection refused", result.meldung) + # ----------------------------------------------------- + # 3. Remote-Link 404 + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.network_head") + def test_remote_link_404(self, mock_head): + mock_head.return_value = MockResponseNotFound() + + lp = Linkpruefer("http://example.com/missing", "REST") + result = lp.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "url_nicht_erreichbar") + self.assertIn("404", result.meldung) + + # ----------------------------------------------------- + # 4. Lokaler Pfad existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists") + def test_local_link_not_found(self, mock_exists): + mock_exists.return_value = False + + lp = Linkpruefer("/path/to/missing/file.shp", "OGR") + result = lp.pruefe() + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "pfad_nicht_gefunden") + + # ----------------------------------------------------- + # 5. Lokaler Pfad existiert, aber ungewöhnlich + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists") + def test_local_link_warning(self, mock_exists): + mock_exists.return_value = True - def test_local_link_warning(self): lp = Linkpruefer("/path/to/file_without_extension", "OGR") - result = lp.pruefe_link() + result = lp.pruefe() - self.assertTrue(result.erfolgreich) - self.assertIn("ungewöhnlich", result.warnungen[0]) + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertIn("ungewöhnlich", result.meldung) if __name__ == "__main__": diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py index dd23c31..3a0cfe6 100644 --- a/test/test_pruefmanager.py +++ b/test/test_pruefmanager.py @@ -1,86 +1,133 @@ -#test_pruefmanager.py +# sn_basis/test/test_pruefmanager.py + import unittest -import os -import sys -from unittest.mock import patch, MagicMock +from unittest.mock import patch -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) - -from modules.Pruefmanager import PruefManager -from modules.Dateipruefer import DateiEntscheidung -import modules.qt_compat as qt_compat +from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis -# Skip-Decorator für Mock-Modus -def skip_if_mock(reason): - return unittest.skipIf( - qt_compat.QT_VERSION == 0, - f"{reason} — MOCK-Modus erkannt. " - "Bitte diesen Test in einer echten QGIS-Umgebung ausführen." - ) - - -class TestPruefManager(unittest.TestCase): +class TestPruefmanager(unittest.TestCase): def setUp(self): - self.manager = PruefManager(plugin_pfad="/tmp") + self.manager = Pruefmanager() - # --------------------------------------------------------- - # Tests für frage_datei_ersetzen_oder_anhaengen - # --------------------------------------------------------- + # ----------------------------------------------------- + # 1. OK-Ergebnis → keine Interaktion + # ----------------------------------------------------- + def test_ok(self): + ergebnis = pruef_ergebnis(True, "Alles gut", "ok", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) - def test_frage_datei_ersetzen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ERSETZEN) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) - def test_frage_datei_anhaengen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ANHAENGEN) + # ----------------------------------------------------- + # 2. Leere Eingabe erlaubt → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_leereingabe_erlaubt_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.CANCEL) - def test_frage_datei_abbrechen(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") - # --------------------------------------------------------- - # Fehlerfall: exec_dialog liefert etwas Unerwartetes - # --------------------------------------------------------- + # ----------------------------------------------------- + # 3. Leere Eingabe erlaubt → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_leereingabe_erlaubt_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=999) - def test_frage_datei_unbekannte_antwort(self, mock_exec): - entscheidung = self.manager.frage_datei_ersetzen_oder_anhaengen("dummy.gpkg") - self.assertEqual(entscheidung, DateiEntscheidung.ABBRECHEN) + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "leereingabe_erlaubt") - # --------------------------------------------------------- - # Tests für frage_temporär_verwenden - # --------------------------------------------------------- + # ----------------------------------------------------- + # 4. Standarddatei vorschlagen → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_standarddatei_vorschlagen_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + entscheidung = self.manager.verarbeite(ergebnis) - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.YES) - def test_frage_temporär_verwenden_ja(self, mock_exec): - self.assertTrue(self.manager.frage_temporär_verwenden()) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") + self.assertEqual(entscheidung.pfad, "/tmp/std.txt") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=qt_compat.NO) - def test_frage_temporär_verwenden_nein(self, mock_exec): - self.assertFalse(self.manager.frage_temporär_verwenden()) + # ----------------------------------------------------- + # 5. Standarddatei vorschlagen → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_standarddatei_vorschlagen_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + entscheidung = self.manager.verarbeite(ergebnis) - # --------------------------------------------------------- - # Fehlerfall: exec_dialog liefert etwas Unerwartetes - # --------------------------------------------------------- + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "standarddatei_vorschlagen") - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - @patch("modules.qt_compat.exec_dialog", return_value=None) - def test_frage_temporär_verwenden_unbekannt(self, mock_exec): - self.assertFalse(self.manager.frage_temporär_verwenden()) + # ----------------------------------------------------- + # 6. Temporäre Datei erzeugen → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + def test_temporaer_erlaubt_ja(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "temporaer_erzeugen") + + # ----------------------------------------------------- + # 7. Temporäre Datei erzeugen → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_temporaer_erlaubt_nein(self, mock_ask): + ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "temporaer_erlaubt") + + # ----------------------------------------------------- + # 8. Layer unsichtbar → Nutzer sagt JA + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + @patch("sn_basis.functions.qgisqt_wrapper.set_layer_visible") + def test_layer_unsichtbar_ja(self, mock_set, mock_ask): + fake_layer = object() + ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) + + entscheidung = self.manager.verarbeite(ergebnis) + + mock_set.assert_called_once_with(fake_layer, True) + self.assertTrue(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "ok") + + # ----------------------------------------------------- + # 9. Layer unsichtbar → Nutzer sagt NEIN + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + def test_layer_unsichtbar_nein(self, mock_ask): + fake_layer = object() + ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) + + entscheidung = self.manager.verarbeite(ergebnis) + + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "layer_unsichtbar") + + # ----------------------------------------------------- + # 10. Fehlerhafte Aktion → Fallback + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.warning") + def test_unbekannte_aktion(self, mock_warn): + ergebnis = pruef_ergebnis(False, "???", "unbekannt", None) + entscheidung = self.manager.verarbeite(ergebnis) + + mock_warn.assert_called_once() + self.assertFalse(entscheidung.ok) + self.assertEqual(entscheidung.aktion, "unbekannt") if __name__ == "__main__": diff --git a/test/test_qt_compat.py b/test/test_qt_compat.py deleted file mode 100644 index 92bfd31..0000000 --- a/test/test_qt_compat.py +++ /dev/null @@ -1,100 +0,0 @@ -#test_qt_compat.py -import unittest -import os -import sys -from unittest.mock import MagicMock -import modules.qt_compat as qt_compat -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) - -def skip_if_mock(reason): - """Decorator: überspringt Test, wenn qt_compat im Mock-Modus läuft.""" - return unittest.skipIf( - qt_compat.QT_VERSION == 0, - f"{reason} — MOCK-Modus erkannt." - f"Bitte diesen Test in einer echten QGIS-Umgebung ausführen." - ) - - -class TestQtCompat(unittest.TestCase): - - def test_exports_exist(self): - """Prüft, ob alle erwarteten Symbole exportiert werden.""" - expected = { - "QMessageBox", - "QFileDialog", - "QEventLoop", - "QUrl", - "QNetworkRequest", - "QNetworkReply", - "YES", - "NO", - "CANCEL", - "ICON_QUESTION", - "exec_dialog", - "QT_VERSION", - } - - for symbol in expected: - self.assertTrue( - hasattr(qt_compat, symbol), - f"qt_compat sollte '{symbol}' exportieren" - ) - - @skip_if_mock("QT_VERSION kann im Mock-Modus nicht 5 oder 6 sein") - def test_qt_version_flag(self): - """QT_VERSION muss 5 oder 6 sein.""" - self.assertIn(qt_compat.QT_VERSION, (5, 6)) - - @skip_if_mock("Qt-Enums können im Mock-Modus nicht OR-kombiniert werden") - def test_enums_are_valid(self): - """Prüft, ob die Enums gültige QMessageBox-Werte sind.""" - - msg = qt_compat.QMessageBox() - try: - msg.setStandardButtons( - qt_compat.YES | - qt_compat.NO | - qt_compat.CANCEL - ) - except Exception as e: - self.fail(f"Qt-Enums sollten OR-kombinierbar sein, Fehler: {e}") - - self.assertTrue(True) - - @skip_if_mock("exec_dialog benötigt echtes Qt-Verhalten") - def test_exec_dialog_calls_correct_method(self): - """Prüft, ob exec_dialog() die richtige Methode aufruft.""" - - mock_msg = MagicMock() - - if qt_compat.QT_VERSION == 6: - qt_compat.exec_dialog(mock_msg) - mock_msg.exec.assert_called_once() - - elif qt_compat.QT_VERSION == 5: - qt_compat.exec_dialog(mock_msg) - mock_msg.exec_.assert_called_once() - - else: - self.fail("QT_VERSION hat einen unerwarteten Wert.") - - @skip_if_mock("Qt-Klassen können im Mock-Modus nicht real instanziiert werden") - def test_qt_classes_importable(self): - """Prüft, ob die wichtigsten Qt-Klassen instanziierbar sind.""" - - loop = qt_compat.QEventLoop() - self.assertIsNotNone(loop) - - url = qt_compat.QUrl("http://example.com") - self.assertTrue(url.isValid()) - - req = qt_compat.QNetworkRequest(url) - self.assertIsNotNone(req) - - self.assertTrue(hasattr(qt_compat.QNetworkReply, "NetworkError")) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_settings_logic.py b/test/test_settings_logic.py new file mode 100644 index 0000000..b360bb1 --- /dev/null +++ b/test/test_settings_logic.py @@ -0,0 +1,60 @@ +# sn_basis/test/test_settings_logic.py + +import unittest +from unittest.mock import patch + +from sn_basis.functions.settings_logic import SettingsLogic + + +class TestSettingsLogic(unittest.TestCase): + + # ----------------------------------------------------- + # Test: load() liest alle Variablen über get_variable() + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.get_variable") + def test_load(self, mock_get): + # Mock-Rückgabe für jede Variable + mock_get.side_effect = lambda key, scope="project": f"wert_{key}" + + logic = SettingsLogic() + daten = logic.load() + + # Alle Variablen müssen enthalten sein + for key in SettingsLogic.VARIABLEN: + self.assertIn(key, daten) + self.assertEqual(daten[key], f"wert_{key}") + + # get_variable muss für jede Variable genau einmal aufgerufen werden + self.assertEqual(mock_get.call_count, len(SettingsLogic.VARIABLEN)) + + # ----------------------------------------------------- + # Test: save() ruft set_variable() nur für bekannte Keys auf + # ----------------------------------------------------- + @patch("sn_basis.functions.qgisqt_wrapper.set_variable") + def test_save(self, mock_set): + logic = SettingsLogic() + + # Eingabedaten enthalten gültige und ungültige Keys + daten = { + "amt": "A1", + "behoerde": "B1", + "unbekannt": "IGNORIEREN", + "gemeinden": "G1", + } + + logic.save(daten) + + # set_variable muss nur für gültige Keys aufgerufen werden + expected_calls = 3 # amt, behoerde, gemeinden + self.assertEqual(mock_set.call_count, expected_calls) + + # Prüfen, ob die richtigen Keys gespeichert wurden + saved_keys = [call.args[0] for call in mock_set.call_args_list] + self.assertIn("amt", saved_keys) + self.assertIn("behoerde", saved_keys) + self.assertIn("gemeinden", saved_keys) + self.assertNotIn("unbekannt", saved_keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_stilpruefer.py b/test/test_stilpruefer.py index 6ee2a86..28ec3e8 100644 --- a/test/test_stilpruefer.py +++ b/test/test_stilpruefer.py @@ -1,50 +1,79 @@ -#test_stilpruefer.py +# sn_basis/test/test_stilpruefer.py + import unittest import tempfile import os -import sys +from unittest.mock import patch + +from sn_basis.modules.stilpruefer import Stilpruefer +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + -# Plugin-Root ermitteln (ein Verzeichnis über "test") -ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) -sys.path.insert(0, ROOT) -from modules.stilpruefer import Stilpruefer -from modules.pruef_ergebnis import PruefErgebnis class TestStilpruefer(unittest.TestCase): + def setUp(self): self.pruefer = Stilpruefer() + # ----------------------------------------------------- + # 1. Keine Datei angegeben + # ----------------------------------------------------- def test_keine_datei_angegeben(self): result = self.pruefer.pruefe("") - self.assertTrue(result.erfolgreich) - self.assertIn("Kein Stil angegeben.", result.warnungen) - self.assertIsNone(result.daten["stil"]) - def test_datei_existiert_mit_qml(self): - with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp_file: - tmp_path = tmp_file.name + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertIn("Kein Stil angegeben", result.meldung) + + # ----------------------------------------------------- + # 2. Datei existiert und ist .qml + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_existiert_mit_qml(self, mock_isfile, mock_exists): + with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp: + tmp_path = tmp.name + try: result = self.pruefer.pruefe(tmp_path) - self.assertTrue(result.erfolgreich) - self.assertEqual(result.daten["stil"], tmp_path) - self.assertEqual(result.fehler, []) + + self.assertTrue(result.ok) + self.assertEqual(result.aktion, "ok") + self.assertEqual(result.pfad, tmp_path) + finally: os.remove(tmp_path) - def test_datei_existiert_falsche_endung(self): - with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp_file: - tmp_path = tmp_file.name + # ----------------------------------------------------- + # 3. Datei existiert, aber falsche Endung + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) + @patch("sn_basis.functions.syswrapper.is_file", return_value=True) + def test_datei_existiert_falsche_endung(self, mock_isfile, mock_exists): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: + tmp_path = tmp.name + try: result = self.pruefer.pruefe(tmp_path) - self.assertFalse(result.erfolgreich) - self.assertIn("Ungültige Dateiendung", result.fehler[0]) + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "falsche_endung") + self.assertIn(".qml", result.meldung) + finally: os.remove(tmp_path) - def test_datei_existiert_nicht(self): - fake_path = os.path.join(tempfile.gettempdir(), "nichtvorhanden.qml") + # ----------------------------------------------------- + # 4. Datei existiert nicht + # ----------------------------------------------------- + @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + def test_datei_existiert_nicht(self, mock_exists): + fake_path = "/tmp/nichtvorhanden.qml" + result = self.pruefer.pruefe(fake_path) - self.assertFalse(result.erfolgreich) - self.assertIn("Stildatei nicht gefunden", result.fehler[0]) + + self.assertFalse(result.ok) + self.assertEqual(result.aktion, "datei_nicht_gefunden") + self.assertIn("nicht gefunden", result.meldung) if __name__ == "__main__": diff --git a/test/test_wrapper.py b/test/test_wrapper.py new file mode 100644 index 0000000..f57b5bb --- /dev/null +++ b/test/test_wrapper.py @@ -0,0 +1,164 @@ +# sn_basis/test/test_wrapper.py + +import unittest +import os +import tempfile + +# Wrapper importieren +import sn_basis.functions.syswrapper as syswrapper +import sn_basis.functions.qgisqt_wrapper as qgisqt + + +# --------------------------------------------------------- +# Mock-Layer für qgisqt_wrapper +# --------------------------------------------------------- +class MockLayer: + def __init__( + self, + exists=True, + visible=True, + layer_type="vector", + geometry_type="Polygon", + feature_count=10, + crs="EPSG:25833", + fields=None, + source="/tmp/test.shp", + editable=True, + ): + self.exists = exists + self.visible = visible + self.layer_type = layer_type + self.geometry_type = geometry_type + self.feature_count = feature_count + self.crs = crs + self.fields = fields or [] + self.source = source + self.editable = editable + + +# --------------------------------------------------------- +# Monkeypatching für qgisqt_wrapper +# --------------------------------------------------------- +def mock_layer_exists(layer): + return layer is not None and layer.exists + + +def mock_is_layer_visible(layer): + return layer.visible + + +def mock_get_layer_type(layer): + return layer.layer_type + + +def mock_get_layer_geometry_type(layer): + return layer.geometry_type + + +def mock_get_layer_feature_count(layer): + return layer.feature_count + + +def mock_get_layer_crs(layer): + return layer.crs + + +def mock_get_layer_fields(layer): + return layer.fields + + +def mock_get_layer_source(layer): + return layer.source + + +def mock_is_layer_editable(layer): + return layer.editable + + +# --------------------------------------------------------- +# Testklasse +# --------------------------------------------------------- +class TestWrapper(unittest.TestCase): + + def setUp(self): + # qgisqt_wrapper monkeypatchen + qgisqt.layer_exists = mock_layer_exists + qgisqt.is_layer_visible = mock_is_layer_visible + qgisqt.get_layer_type = mock_get_layer_type + qgisqt.get_layer_geometry_type = mock_get_layer_geometry_type + qgisqt.get_layer_feature_count = mock_get_layer_feature_count + qgisqt.get_layer_crs = mock_get_layer_crs + qgisqt.get_layer_fields = mock_get_layer_fields + qgisqt.get_layer_source = mock_get_layer_source + qgisqt.is_layer_editable = mock_is_layer_editable + + # ----------------------------------------------------- + # syswrapper Tests + # ----------------------------------------------------- + + def test_syswrapper_file_exists(self): + with tempfile.NamedTemporaryFile(delete=True) as tmp: + self.assertTrue(syswrapper.file_exists(tmp.name)) + self.assertFalse(syswrapper.file_exists("/path/does/not/exist")) + + def test_syswrapper_is_file(self): + with tempfile.NamedTemporaryFile(delete=True) as tmp: + self.assertTrue(syswrapper.is_file(tmp.name)) + self.assertFalse(syswrapper.is_file("/path/does/not/exist")) + + def test_syswrapper_join_path(self): + result = syswrapper.join_path("/tmp", "test.txt") + self.assertEqual(result, "/tmp/test.txt") + + # ----------------------------------------------------- + # qgisqt_wrapper Tests (Mock-Modus) + # ----------------------------------------------------- + + def test_qgisqt_layer_exists(self): + layer = MockLayer(exists=True) + self.assertTrue(qgisqt.layer_exists(layer)) + + layer = MockLayer(exists=False) + self.assertFalse(qgisqt.layer_exists(layer)) + + def test_qgisqt_layer_visible(self): + layer = MockLayer(visible=True) + self.assertTrue(qgisqt.is_layer_visible(layer)) + + layer = MockLayer(visible=False) + self.assertFalse(qgisqt.is_layer_visible(layer)) + + def test_qgisqt_layer_type(self): + layer = MockLayer(layer_type="vector") + self.assertEqual(qgisqt.get_layer_type(layer), "vector") + + def test_qgisqt_geometry_type(self): + layer = MockLayer(geometry_type="Polygon") + self.assertEqual(qgisqt.get_layer_geometry_type(layer), "Polygon") + + def test_qgisqt_feature_count(self): + layer = MockLayer(feature_count=12) + self.assertEqual(qgisqt.get_layer_feature_count(layer), 12) + + def test_qgisqt_crs(self): + layer = MockLayer(crs="EPSG:4326") + self.assertEqual(qgisqt.get_layer_crs(layer), "EPSG:4326") + + def test_qgisqt_fields(self): + layer = MockLayer(fields=["id", "name"]) + self.assertEqual(qgisqt.get_layer_fields(layer), ["id", "name"]) + + def test_qgisqt_source(self): + layer = MockLayer(source="/tmp/test.shp") + self.assertEqual(qgisqt.get_layer_source(layer), "/tmp/test.shp") + + def test_qgisqt_editable(self): + layer = MockLayer(editable=True) + self.assertTrue(qgisqt.is_layer_editable(layer)) + + layer = MockLayer(editable=False) + self.assertFalse(qgisqt.is_layer_editable(layer)) + + +if __name__ == "__main__": + unittest.main() diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py index 4184b6d..9ee75af 100644 --- a/ui/base_dockwidget.py +++ b/ui/base_dockwidget.py @@ -1,28 +1,73 @@ +# sn_basis/ui/base_dockwidget.py + from qgis.PyQt.QtWidgets import QDockWidget, QTabWidget +from sn_basis.functions.qgisqt_wrapper import warning, error + class BaseDockWidget(QDockWidget): + """ + Basis-Dockwidget für alle LNO-Module. + - Titel wird automatisch aus base_title + subtitle erzeugt + - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt + - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt + """ + base_title = "LNO Sachsen" - tabs = [] - action = None # Referenz auf die Toolbar-Action + tabs = [] # Liste von Tab-Klassen + action = None # Referenz auf die Toolbar-Action def __init__(self, parent=None, subtitle=""): super().__init__(parent) - # Titel zusammensetzen - title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}" - self.setWindowTitle(title) + # ----------------------------------------------------- + # Titel setzen + # ----------------------------------------------------- + try: + title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}" + self.setWindowTitle(title) + except Exception as e: + warning("Titel konnte nicht gesetzt werden", str(e)) - # Dock fixieren (nur schließen erlaubt) - self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) + # ----------------------------------------------------- + # Dock-Features + # ----------------------------------------------------- + try: + self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) + except Exception as e: + warning("Dock-Features konnten nicht gesetzt werden", str(e)) - # Tabs hinzufügen - tab_widget = QTabWidget() - for tab_class in self.tabs: - tab_widget.addTab(tab_class(), getattr(tab_class, "tab_title", tab_class.__name__)) - self.setWidget(tab_widget) + # ----------------------------------------------------- + # Tabs erzeugen + # ----------------------------------------------------- + try: + tab_widget = QTabWidget() + + for tab_class in self.tabs: + try: + tab_instance = tab_class() + tab_title = getattr(tab_class, "tab_title", tab_class.__name__) + tab_widget.addTab(tab_instance, tab_title) + except Exception as e: + error("Tab konnte nicht geladen werden", f"{tab_class}: {e}") + + self.setWidget(tab_widget) + + except Exception as e: + error("Tab-Widget konnte nicht initialisiert werden", str(e)) + + # --------------------------------------------------------- + # Dock schließen + # --------------------------------------------------------- def closeEvent(self, event): - """Wird aufgerufen, wenn das Dock geschlossen wird.""" - if self.action: - self.action.setChecked(False) # Toolbar-Button zurücksetzen + """ + Wird aufgerufen, wenn das Dock geschlossen wird. + Setzt die zugehörige Toolbar-Action zurück. + """ + try: + if self.action: + self.action.setChecked(False) + except Exception as e: + warning("Toolbar-Status konnte nicht zurückgesetzt werden", str(e)) + super().closeEvent(event) diff --git a/ui/dockmanager.py b/ui/dockmanager.py index 50bdd34..8830e60 100644 --- a/ui/dockmanager.py +++ b/ui/dockmanager.py @@ -1,21 +1,53 @@ +# sn_basis/ui/dockmanager.py + from qgis.PyQt.QtCore import Qt from qgis.PyQt.QtWidgets import QDockWidget from qgis.utils import iface +from sn_basis.functions.qgisqt_wrapper import warning, error + class DockManager: + """ + Verwaltet das Anzeigen und Ersetzen von DockWidgets. + Stellt sicher, dass immer nur ein LNO-Dock gleichzeitig sichtbar ist. + """ + default_area = Qt.DockWidgetArea.RightDockWidgetArea + dock_prefix = "sn_dock_" @classmethod def show(cls, dock_widget, area=None): - area = area or cls.default_area + """ + Zeigt ein DockWidget an und entfernt vorher alle anderen + LNO-Docks (erkennbar am Prefix 'sn_dock_'). + """ + if dock_widget is None: + error("Dock konnte nicht angezeigt werden", "Dock-Widget ist None.") + return - # Bestehende Plugin-Docks mit Präfix schließen - for widget in iface.mainWindow().findChildren(QDockWidget): - if widget is not dock_widget and widget.objectName().startswith("sn_dock_"): - iface.removeDockWidget(widget) - widget.deleteLater() + try: + area = area or cls.default_area - # Neues Dock anzeigen - iface.addDockWidget(area, dock_widget) - dock_widget.show() + # Prüfen, ob das Dock einen gültigen Namen hat + if not dock_widget.objectName(): + dock_widget.setObjectName(f"{cls.dock_prefix}{id(dock_widget)}") + + # Bestehende Plugin-Docks schließen + try: + for widget in iface.mainWindow().findChildren(QDockWidget): + if widget is not dock_widget and widget.objectName().startswith(cls.dock_prefix): + iface.removeDockWidget(widget) + widget.deleteLater() + except Exception as e: + warning("Vorherige Docks konnten nicht entfernt werden", str(e)) + + # Neues Dock anzeigen + try: + iface.addDockWidget(area, dock_widget) + dock_widget.show() + except Exception as e: + error("Dock konnte nicht angezeigt werden", str(e)) + + except Exception as e: + error("DockManager-Fehler", str(e)) diff --git a/ui/navigation.py b/ui/navigation.py index 44a8895..36c786c 100644 --- a/ui/navigation.py +++ b/ui/navigation.py @@ -1,3 +1,4 @@ +#sn_basis/ui/navigation.py from qgis.PyQt.QtWidgets import QAction, QMenu, QToolBar, QActionGroup class Navigation: diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py index a8f5de9..ae164f3 100644 --- a/ui/tabs/settings_tab.py +++ b/ui/tabs/settings_tab.py @@ -1,12 +1,21 @@ -from qgis.PyQt.QtWidgets import ( +# sn_basis/ui/tabs/settings_tab.py + +from sn_basis.functions.qgisqt_wrapper import ( QWidget, QGridLayout, QLabel, QLineEdit, - QGroupBox, QVBoxLayout, QPushButton + QGroupBox, QVBoxLayout, QPushButton, + info, warning, error ) + from sn_basis.functions.settings_logic import SettingsLogic class SettingsTab(QWidget): - tab_title = "Projekteigenschaften" # Titel für den Tab + """ + Tab für benutzer- und projektspezifische Einstellungen. + Nutzt SettingsLogic für das Laden/Speichern und den Wrapper für Meldungen. + """ + + tab_title = "Projekteigenschaften" def __init__(self, parent=None): super().__init__(parent) @@ -14,13 +23,16 @@ class SettingsTab(QWidget): main_layout = QVBoxLayout() + # ----------------------------------------------------- # Definition der Felder + # ----------------------------------------------------- self.user_fields = { "amt": "Amt:", "behoerde": "Behörde:", "landkreis_user": "Landkreis:", "sachgebiet": "Sachgebiet:" } + self.project_fields = { "bezeichnung": "Bezeichnung:", "verfahrensnummer": "Verfahrensnummer:", @@ -28,45 +40,90 @@ class SettingsTab(QWidget): "landkreise_proj": "Landkreis(e):" } - # 🟦 Benutzerspezifische Festlegungen + # ----------------------------------------------------- + # Benutzer-Felder + # ----------------------------------------------------- user_group = QGroupBox("Benutzerspezifische Festlegungen") user_layout = QGridLayout() self.user_inputs = {} + for row, (key, label) in enumerate(self.user_fields.items()): - self.user_inputs[key] = QLineEdit() + line_edit = QLineEdit() + self.user_inputs[key] = line_edit user_layout.addWidget(QLabel(label), row, 0) - user_layout.addWidget(self.user_inputs[key], row, 1) + user_layout.addWidget(line_edit, row, 1) + user_group.setLayout(user_layout) - # 🟨 Projektspezifische Festlegungen + # ----------------------------------------------------- + # Projekt-Felder + # ----------------------------------------------------- project_group = QGroupBox("Projektspezifische Festlegungen") project_layout = QGridLayout() self.project_inputs = {} + for row, (key, label) in enumerate(self.project_fields.items()): - self.project_inputs[key] = QLineEdit() + line_edit = QLineEdit() + self.project_inputs[key] = line_edit project_layout.addWidget(QLabel(label), row, 0) - project_layout.addWidget(self.project_inputs[key], row, 1) + project_layout.addWidget(line_edit, row, 1) + project_group.setLayout(project_layout) - # 🟩 Speichern-Button + # ----------------------------------------------------- + # Speichern-Button + # ----------------------------------------------------- save_button = QPushButton("Speichern") save_button.clicked.connect(self.save_data) + # ----------------------------------------------------- # Layout zusammenfügen + # ----------------------------------------------------- main_layout.addWidget(user_group) main_layout.addWidget(project_group) main_layout.addStretch() main_layout.addWidget(save_button) self.setLayout(main_layout) + + # Daten laden self.load_data() + # --------------------------------------------------------- + # Speichern + # --------------------------------------------------------- + def save_data(self): - # Alle Felder zusammenführen - fields = {key: widget.text() for key, widget in {**self.user_inputs, **self.project_inputs}.items()} - self.logic.save(fields) + """ + Speichert alle Eingaben über SettingsLogic. + Fehler werden über den Wrapper gemeldet. + """ + try: + fields = { + key: widget.text() + for key, widget in {**self.user_inputs, **self.project_inputs}.items() + } + + self.logic.save(fields) + info("Gespeichert", "Die Einstellungen wurden erfolgreich gespeichert.") + + except Exception as e: + error("Fehler beim Speichern", str(e)) + + # --------------------------------------------------------- + # Laden + # --------------------------------------------------------- def load_data(self): - data = self.logic.load() - for key, widget in {**self.user_inputs, **self.project_inputs}.items(): - widget.setText(data.get(key, "")) + """ + Lädt gespeicherte Einstellungen und füllt die Felder. + Fehler werden über den Wrapper gemeldet. + """ + try: + data = self.logic.load() + + for key, widget in {**self.user_inputs, **self.project_inputs}.items(): + widget.setText(data.get(key, "")) + + except Exception as e: + warning("Einstellungen konnten nicht geladen werden", str(e)) From f88b5da51f809a86dc4dbc6e8ab7edc0458a6b55 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 19 Dec 2025 14:29:52 +0100 Subject: [PATCH 05/11] =?UTF-8?q?Wrappe=20modular=20aufgebaut,=20Tests=20e?= =?UTF-8?q?rfolgreich,=20Men=C3=BCleiste=20und=20Werzeugleiste=20werden=20?= =?UTF-8?q?eingetragen=20(QT6=20und=20QT5)-=20(Es=20fehlen=20noch=20Fachpl?= =?UTF-8?q?ugins,=20um=20zu=20pr=C3=BCfen,=20ob=20es=20auch=20wirklich=20i?= =?UTF-8?q?n=20QGIS=20geht)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/__init__.py | 43 ++ functions/dialog_wrapper.py | 41 ++ functions/ly_existence_wrapper.py | 20 + functions/ly_geometry_wrapper.py | 57 ++ functions/ly_metadata_wrapper.py | 90 +++ functions/ly_style_wrapper.py | 27 + functions/ly_visibility_wrapper.py | 40 ++ functions/message_wrapper.py | 84 +++ functions/os_wrapper.py | 77 +++ functions/qgiscore_wrapper.py | 139 +++++ functions/qgisqt_wrapper.py | 880 ----------------------------- functions/qgisui_wrapper.py | 140 +++++ functions/qt_wrapper.py | 393 +++++++++++++ functions/settings_logic.py | 12 +- functions/sys_wrapper.py | 104 ++++ functions/syswrapper.py | 185 ------ functions/variable_wrapper.py | 115 ++++ main.py | 29 +- modules/Dateipruefer.py | 42 +- modules/Pruefmanager.py | 48 +- modules/layerpruefer.py | 50 +- modules/linkpruefer.py | 47 +- modules/pruef_ergebnis.py | 29 +- modules/stilpruefer.py | 80 +-- test/run_tests.py | 40 +- test/test_bootstrap.py | 4 +- test/test_dateipruefer.py | 19 +- test/test_layerpruefer.py | 23 +- test/test_linkpruefer.py | 87 +-- test/test_pruefmanager.py | 38 +- test/test_settings_logic.py | 4 +- test/test_stilpruefer.py | 21 +- test/test_wrapper.py | 164 ------ ui/base_dockwidget.py | 46 +- ui/dockmanager.py | 54 +- ui/navigation.py | 125 ++-- ui/tabs/settings_tab.py | 168 +++--- 37 files changed, 1886 insertions(+), 1679 deletions(-) create mode 100644 functions/dialog_wrapper.py create mode 100644 functions/ly_existence_wrapper.py create mode 100644 functions/ly_geometry_wrapper.py create mode 100644 functions/ly_metadata_wrapper.py create mode 100644 functions/ly_style_wrapper.py create mode 100644 functions/ly_visibility_wrapper.py create mode 100644 functions/message_wrapper.py create mode 100644 functions/os_wrapper.py create mode 100644 functions/qgiscore_wrapper.py delete mode 100644 functions/qgisqt_wrapper.py create mode 100644 functions/qgisui_wrapper.py create mode 100644 functions/qt_wrapper.py create mode 100644 functions/sys_wrapper.py delete mode 100644 functions/syswrapper.py create mode 100644 functions/variable_wrapper.py delete mode 100644 test/test_wrapper.py diff --git a/functions/__init__.py b/functions/__init__.py index e69de29..cfbfc4a 100644 --- a/functions/__init__.py +++ b/functions/__init__.py @@ -0,0 +1,43 @@ +from .ly_existence_wrapper import layer_exists +from .ly_geometry_wrapper import ( + get_layer_geometry_type, + get_layer_feature_count, +) +from .ly_visibility_wrapper import ( + is_layer_visible, + set_layer_visible, +) +from .ly_metadata_wrapper import ( + get_layer_type, + get_layer_crs, + get_layer_fields, + get_layer_source, + is_layer_editable, +) +from .ly_style_wrapper import apply_style +from .dialog_wrapper import ask_yes_no + +from .message_wrapper import ( + _get_message_bar, + push_message, + error, + warning, + info, + success, +) + +from .os_wrapper import * +from .qgiscore_wrapper import * +from .qt_wrapper import * +from .settings_logic import * +from .sys_wrapper import * +from .variable_wrapper import * +from .qgisui_wrapper import ( +get_main_window, +add_dock_widget, +remove_dock_widget, +find_dock_widgets, +add_menu, +remove_menu, +add_toolbar, +remove_toolbar) diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py new file mode 100644 index 0000000..43e7654 --- /dev/null +++ b/functions/dialog_wrapper.py @@ -0,0 +1,41 @@ +""" +sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge +""" + +from typing import Any + +from sn_basis.functions.qt_wrapper import ( + QMessageBox, + YES, + NO, +) + + +# --------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------- + +def ask_yes_no( + title: str, + message: str, + default: bool = False, + parent: Any = None, +) -> bool: + """ + Fragt den Benutzer eine Ja/Nein-Frage. + + - In Qt: zeigt einen QMessageBox-Dialog + - Im Mock-Modus: gibt den Default-Wert zurück + """ + try: + buttons = QMessageBox.Yes | QMessageBox.No + result = QMessageBox.question( + parent, + title, + message, + buttons, + YES if default else NO, + ) + return result == YES + except Exception: + return default diff --git a/functions/ly_existence_wrapper.py b/functions/ly_existence_wrapper.py new file mode 100644 index 0000000..d39e000 --- /dev/null +++ b/functions/ly_existence_wrapper.py @@ -0,0 +1,20 @@ +# sn_basis/functions/ly_existence_wrapper.py + +def layer_exists(layer) -> bool: + if layer is None: + return False + + is_valid_flag = getattr(layer, "is_valid", None) + if is_valid_flag is not None: + try: + return bool(is_valid_flag) + except Exception: + return False + + try: + is_valid = getattr(layer, "isValid", None) + if callable(is_valid): + return bool(is_valid()) + return True + except Exception: + return False diff --git a/functions/ly_geometry_wrapper.py b/functions/ly_geometry_wrapper.py new file mode 100644 index 0000000..97c2b6a --- /dev/null +++ b/functions/ly_geometry_wrapper.py @@ -0,0 +1,57 @@ +# sn_basis/functions/ly_geometry_wrapper.py + +def get_layer_geometry_type(layer) -> str: + if layer is None: + return "None" + + geometry_type = getattr(layer, "geometry_type", None) + if geometry_type is not None: + return str(geometry_type) + + try: + if callable(getattr(layer, "isSpatial", None)) and not layer.isSpatial(): + return "None" + + gtype = getattr(layer, "geometryType", None) + if callable(gtype): + value = gtype() + if not isinstance(value, int): + return "None" + + return { + 0: "Point", + 1: "LineString", + 2: "Polygon", + }.get(value, "None") + except Exception: + pass + + return "None" + + + + +def get_layer_feature_count(layer) -> int: + if layer is None: + return 0 + + count = getattr(layer, "feature_count", None) + if count is not None: + if isinstance(count, int): + return count + return 0 + + try: + if callable(getattr(layer, "isSpatial", None)) and not layer.isSpatial(): + return 0 + + fc = getattr(layer, "featureCount", None) + if callable(fc): + value = fc() + if isinstance(value, int): + return value + except Exception: + pass + + return 0 + diff --git a/functions/ly_metadata_wrapper.py b/functions/ly_metadata_wrapper.py new file mode 100644 index 0000000..48c4d19 --- /dev/null +++ b/functions/ly_metadata_wrapper.py @@ -0,0 +1,90 @@ +# layer/metadata.py + +def get_layer_type(layer) -> str: + if layer is None: + return "unknown" + + layer_type = getattr(layer, "layer_type", None) + if layer_type is not None: + return str(layer_type) + + try: + if callable(getattr(layer, "isSpatial", None)): + return "vector" if layer.isSpatial() else "table" + except Exception: + pass + + return "unknown" + + +def get_layer_crs(layer) -> str: + if layer is None: + return "None" + + crs = getattr(layer, "crs", None) + if crs is not None and not callable(crs): + if isinstance(crs, str): + return crs + return "None" + + try: + crs_obj = layer.crs() + authid = getattr(crs_obj, "authid", None) + if callable(authid): + value = authid() + if isinstance(value, str): + return value + except Exception: + pass + + return "None" + + + +def get_layer_fields(layer) -> list[str]: + if layer is None: + return [] + + fields = getattr(layer, "fields", None) + if fields is not None and not callable(fields): + return list(fields) + + try: + f = layer.fields() + if callable(getattr(f, "names", None)): + return list(f.names()) + return list(f) + except Exception: + return [] + + +def get_layer_source(layer) -> str: + if layer is None: + return "None" + + source = getattr(layer, "source", None) + if source is not None and not callable(source): + return str(source) + + try: + return layer.source() or "None" + except Exception: + return "None" + + +def is_layer_editable(layer) -> bool: + if layer is None: + return False + + editable = getattr(layer, "editable", None) + if editable is not None: + return bool(editable) + + try: + is_editable = getattr(layer, "isEditable", None) + if callable(is_editable): + return bool(is_editable()) + except Exception: + pass + + return False diff --git a/functions/ly_style_wrapper.py b/functions/ly_style_wrapper.py new file mode 100644 index 0000000..71d48e6 --- /dev/null +++ b/functions/ly_style_wrapper.py @@ -0,0 +1,27 @@ +# layer/style.py + +from sn_basis.functions.ly_existence_wrapper import layer_exists +from sn_basis.functions.sys_wrapper import ( + get_plugin_root, + join_path, + file_exists, +) + + +def apply_style(layer, style_name: str) -> bool: + if not layer_exists(layer): + return False + + style_path = join_path(get_plugin_root(), "styles", style_name) + if not file_exists(style_path): + return False + + try: + ok, _ = layer.loadNamedStyle(style_path) + if ok: + getattr(layer, "triggerRepaint", lambda: None)() + return True + except Exception: + pass + + return False diff --git a/functions/ly_visibility_wrapper.py b/functions/ly_visibility_wrapper.py new file mode 100644 index 0000000..b37a4ce --- /dev/null +++ b/functions/ly_visibility_wrapper.py @@ -0,0 +1,40 @@ +# sn_basis/functions/ly_visibility_wrapper.py + +def is_layer_visible(layer) -> bool: + if layer is None: + return False + + visible = getattr(layer, "visible", None) + if visible is not None: + return bool(visible) + + try: + is_visible = getattr(layer, "isVisible", None) + if callable(is_visible): + return bool(is_visible()) + except Exception: + pass + + return False + + +def set_layer_visible(layer, visible: bool) -> bool: + if layer is None: + return False + + try: + if hasattr(layer, "visible"): + layer.visible = bool(visible) + return True + except Exception: + pass + + try: + node = getattr(layer, "treeLayer", lambda: None)() + if node and callable(getattr(node, "setItemVisibilityChecked", None)): + node.setItemVisibilityChecked(bool(visible)) + return True + except Exception: + pass + + return False diff --git a/functions/message_wrapper.py b/functions/message_wrapper.py new file mode 100644 index 0000000..c6f75f3 --- /dev/null +++ b/functions/message_wrapper.py @@ -0,0 +1,84 @@ +""" +sn_basis/functions/message_wrapper.py – zentrale MessageBar-Abstraktion +""" + +from typing import Any + +from sn_basis.functions.qgisui_wrapper import iface +from sn_basis.functions.qgiscore_wrapper import Qgis + + +# --------------------------------------------------------- +# Interne Hilfsfunktion +# --------------------------------------------------------- + +def _get_message_bar(): + """ + Liefert eine MessageBar-Instanz (QGIS oder Mock). + """ + try: + bar = iface.messageBar() + if bar is not None: + return bar + except Exception: + pass + + class _MockMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return { + "title": title, + "text": text, + "level": level, + "duration": duration, + } + + return _MockMessageBar() + + +# --------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------- + +def push_message( + level: int, + title: str, + text: str, + duration: int = 5, + parent: Any = None, +): + """ + Zeigt eine Message in der QGIS-MessageBar an. + + Im Mock-Modus wird ein strukturierter Dict zurückgegeben. + """ + bar = _get_message_bar() + + try: + return bar.pushMessage( + title, + text, + level=level, + duration=duration, + ) + except Exception: + return None + + +def info(title: str, text: str, duration: int = 5): + level = Qgis.MessageLevel.Info + return push_message(level, title, text, duration) + + +def warning(title: str, text: str, duration: int = 5): + level = Qgis.MessageLevel.Warning + return push_message(level, title, text, duration) + + +def error(title: str, text: str, duration: int = 5): + level = Qgis.MessageLevel.Critical + return push_message(level, title, text, duration) + + +def success(title: str, text: str, duration: int = 5): + level = Qgis.MessageLevel.Success + return push_message(level, title, text, duration) diff --git a/functions/os_wrapper.py b/functions/os_wrapper.py new file mode 100644 index 0000000..6bd6d10 --- /dev/null +++ b/functions/os_wrapper.py @@ -0,0 +1,77 @@ +""" +sn_basis/functions/os_wrapper.py – Betriebssystem-Abstraktion +""" + +from pathlib import Path +import platform +from typing import Union + + +# --------------------------------------------------------- +# OS-Erkennung +# --------------------------------------------------------- + +_SYSTEM = platform.system().lower() + +if _SYSTEM.startswith("win"): + OS_NAME = "windows" +elif _SYSTEM.startswith("darwin"): + OS_NAME = "macos" +else: + OS_NAME = "linux" + +IS_WINDOWS = OS_NAME == "windows" +IS_LINUX = OS_NAME == "linux" +IS_MACOS = OS_NAME == "macos" + + +# --------------------------------------------------------- +# OS-Eigenschaften +# --------------------------------------------------------- + +PATH_SEPARATOR = "\\" if IS_WINDOWS else "/" +LINE_SEPARATOR = "\r\n" if IS_WINDOWS else "\n" + + +# --------------------------------------------------------- +# Pfad-Utilities +# --------------------------------------------------------- + +_PathLike = Union[str, Path] + + +def normalize_path(path: _PathLike) -> Path: + """ + Normalisiert einen Pfad OS-unabhängig. + """ + try: + return Path(path).expanduser().resolve() + except Exception: + return Path(path) + + +def get_home_dir() -> Path: + """ + Liefert das Home-Verzeichnis des aktuellen Users. + """ + return Path.home() + + +# --------------------------------------------------------- +# Dateisystem-Eigenschaften +# --------------------------------------------------------- + +def is_case_sensitive_fs() -> bool: + """ + Gibt zurück, ob das Dateisystem case-sensitiv ist. + """ + # Windows ist immer case-insensitive + if IS_WINDOWS: + return False + + # macOS meist case-insensitive, aber nicht garantiert + if IS_MACOS: + return False + + # Linux praktisch immer case-sensitiv + return True diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py new file mode 100644 index 0000000..77e8ae3 --- /dev/null +++ b/functions/qgiscore_wrapper.py @@ -0,0 +1,139 @@ +""" +sn_basis/functions/qgiscore_wrapper.py – zentrale QGIS-Core-Abstraktion +""" + +from typing import Type, Any + +from sn_basis.functions.qt_wrapper import ( + QUrl, + QEventLoop, + QNetworkRequest, +) + +# --------------------------------------------------------- +# QGIS-Symbole (werden dynamisch gesetzt) +# --------------------------------------------------------- + +QgsProject: Type[Any] +QgsVectorLayer: Type[Any] +QgsNetworkAccessManager: Type[Any] +Qgis: Type[Any] + +QGIS_AVAILABLE = False + +# --------------------------------------------------------- +# Versuch: QGIS-Core importieren +# --------------------------------------------------------- + +try: + from qgis.core import ( + QgsProject as _QgsProject, + QgsVectorLayer as _QgsVectorLayer, + QgsNetworkAccessManager as _QgsNetworkAccessManager, + Qgis as _Qgis, + ) + + QgsProject = _QgsProject + QgsVectorLayer = _QgsVectorLayer + QgsNetworkAccessManager = _QgsNetworkAccessManager + Qgis = _Qgis + + QGIS_AVAILABLE = True + +# --------------------------------------------------------- +# Mock-Modus +# --------------------------------------------------------- + +except Exception: + QGIS_AVAILABLE = False + + class _MockQgsProject: + def __init__(self): + self._variables = {} + + @staticmethod + def instance() -> "_MockQgsProject": + return _MockQgsProject() + + def read(self) -> bool: + return True + + QgsProject = _MockQgsProject + + class _MockQgsVectorLayer: + def __init__(self, *args, **kwargs): + self._valid = True + + def isValid(self) -> bool: + return self._valid + + def loadNamedStyle(self, path: str): + return True, "" + + def triggerRepaint(self) -> None: + pass + + QgsVectorLayer = _MockQgsVectorLayer + + class _MockQgsNetworkAccessManager: + @staticmethod + def instance(): + return _MockQgsNetworkAccessManager() + + def head(self, request: Any): + return None + + QgsNetworkAccessManager = _MockQgsNetworkAccessManager + + class _MockQgis: + class MessageLevel: + Success = 0 + Info = 1 + Warning = 2 + Critical = 3 + + Qgis = _MockQgis + + +# --------------------------------------------------------- +# Netzwerk +# --------------------------------------------------------- + +class NetworkReply: + """ + Minimaler Wrapper für Netzwerkantworten. + """ + def __init__(self, error: int): + self.error = error + + +def network_head(url: str) -> NetworkReply | None: + """ + Führt einen HTTP-HEAD-Request aus. + + Rückgabe: + - NetworkReply(error=0) → erreichbar + - NetworkReply(error!=0) → nicht erreichbar + - None → Netzwerk nicht verfügbar / Fehler beim Request + """ + + if not QGIS_AVAILABLE: + return None + + if QUrl is None or QNetworkRequest is None: + return None + + try: + manager = QgsNetworkAccessManager.instance() + request = QNetworkRequest(QUrl(url)) + reply = manager.head(request) + + # synchron warten (kurz) + if QEventLoop is not None: + loop = QEventLoop() + reply.finished.connect(loop.quit) + loop.exec() + + return NetworkReply(error=reply.error()) + except Exception: + return None diff --git a/functions/qgisqt_wrapper.py b/functions/qgisqt_wrapper.py deleted file mode 100644 index a8cd723..0000000 --- a/functions/qgisqt_wrapper.py +++ /dev/null @@ -1,880 +0,0 @@ -""" -sn_basis/functions/qgisqt_wrapper.py – zentrale QGIS/Qt-Abstraktion -""" - -from typing import Optional, Type, Any - - -# --------------------------------------------------------- -# Hilfsfunktionen -# --------------------------------------------------------- - -def getattr_safe(obj: Any, name: str, default: Any = None) -> Any: - """ - Sichere getattr-Variante: - - fängt Exceptions beim Attributzugriff ab - - liefert default zurück, wenn Attribut fehlt oder fehlschlägt - """ - try: - return getattr(obj, name) - except Exception: - return default - - -# --------------------------------------------------------- -# Qt‑Symbole (werden später dynamisch importiert) -# --------------------------------------------------------- - -QMessageBox: Optional[Type[Any]] = None -QFileDialog: Optional[Type[Any]] = None -QEventLoop: Optional[Type[Any]] = None -QUrl: Optional[Type[Any]] = None -QNetworkRequest: Optional[Type[Any]] = None -QNetworkReply: Optional[Type[Any]] = None -QCoreApplication: Optional[Type[Any]] = None - -QWidget: Type[Any] -QGridLayout: Type[Any] -QLabel: Type[Any] -QLineEdit: Type[Any] -QGroupBox: Type[Any] -QVBoxLayout: Type[Any] -QPushButton: Type[Any] - -YES: Optional[Any] = None -NO: Optional[Any] = None -CANCEL: Optional[Any] = None -ICON_QUESTION: Optional[Any] = None - - -def exec_dialog(dialog: Any) -> Any: - raise NotImplementedError - - -# --------------------------------------------------------- -# QGIS‑Symbole (werden später dynamisch importiert) -# --------------------------------------------------------- - -QgsProject: Optional[Type[Any]] = None -QgsVectorLayer: Optional[Type[Any]] = None -QgsNetworkAccessManager: Optional[Type[Any]] = None -Qgis: Optional[Type[Any]] = None -iface: Optional[Any] = None - - -# --------------------------------------------------------- -# Qt‑Versionserkennung -# --------------------------------------------------------- - -QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 - - -# --------------------------------------------------------- -# Versuch: PyQt6 importieren -# --------------------------------------------------------- - -try: - from PyQt6.QtWidgets import ( #type: ignore - QMessageBox as _QMessageBox, - QFileDialog as _QFileDialog, - QWidget as _QWidget, - QGridLayout as _QGridLayout, - QLabel as _QLabel, - QLineEdit as _QLineEdit, - QGroupBox as _QGroupBox, - QVBoxLayout as _QVBoxLayout, - QPushButton as _QPushButton, - ) - from PyQt6.QtCore import ( #type: ignore - Qt, - QEventLoop as _QEventLoop, - QUrl as _QUrl, - QCoreApplication as _QCoreApplication, - ) - from PyQt6.QtNetwork import ( #type: ignore - QNetworkRequest as _QNetworkRequest, - QNetworkReply as _QNetworkReply, - ) - - QMessageBox = _QMessageBox - QFileDialog = _QFileDialog - QEventLoop = _QEventLoop - QUrl = _QUrl - QNetworkRequest = _QNetworkRequest - QNetworkReply = _QNetworkReply - QCoreApplication = _QCoreApplication - - QWidget = _QWidget - QGridLayout = _QGridLayout - QLabel = _QLabel - QLineEdit = _QLineEdit - QGroupBox = _QGroupBox - QVBoxLayout = _QVBoxLayout - QPushButton = _QPushButton - - if QMessageBox is not None: - YES = QMessageBox.StandardButton.Yes - NO = QMessageBox.StandardButton.No - CANCEL = QMessageBox.StandardButton.Cancel - ICON_QUESTION = QMessageBox.Icon.Question - - QT_VERSION = 6 - - def exec_dialog(dialog: Any) -> Any: - return dialog.exec() - -# --------------------------------------------------------- -# Versuch: PyQt5 importieren -# --------------------------------------------------------- - -except Exception: - try: - from PyQt5.QtWidgets import ( - QMessageBox as _QMessageBox, - QFileDialog as _QFileDialog, - QWidget as _QWidget, - QGridLayout as _QGridLayout, - QLabel as _QLabel, - QLineEdit as _QLineEdit, - QGroupBox as _QGroupBox, - QVBoxLayout as _QVBoxLayout, - QPushButton as _QPushButton, - ) - from PyQt5.QtCore import ( - Qt, - QEventLoop as _QEventLoop, - QUrl as _QUrl, - QCoreApplication as _QCoreApplication, - ) - from PyQt5.QtNetwork import ( - QNetworkRequest as _QNetworkRequest, - QNetworkReply as _QNetworkReply, - ) - - QMessageBox = _QMessageBox - QFileDialog = _QFileDialog - QEventLoop = _QEventLoop - QUrl = _QUrl - QNetworkRequest = _QNetworkRequest - QNetworkReply = _QNetworkReply - QCoreApplication = _QCoreApplication - - QWidget = _QWidget - QGridLayout = _QGridLayout - QLabel = _QLabel - QLineEdit = _QLineEdit - QGroupBox = _QGroupBox - QVBoxLayout = _QVBoxLayout - QPushButton = _QPushButton - - if QMessageBox is not None: - YES = QMessageBox.Yes - NO = QMessageBox.No - CANCEL = QMessageBox.Cancel - ICON_QUESTION = QMessageBox.Question - - QT_VERSION = 5 - - def exec_dialog(dialog: Any) -> Any: - return dialog.exec_() - - # --------------------------------------------------------- - # Mock‑Modus (kein Qt verfügbar) - # --------------------------------------------------------- - - except Exception: - QT_VERSION = 0 - - class FakeEnum(int): - """OR‑fähiger Enum‑Ersatz für Mock‑Modus.""" - - def __new__(cls, value: int): - return int.__new__(cls, value) - - def __or__(self, other: "FakeEnum") -> "FakeEnum": - return FakeEnum(int(self) | int(other)) - - class _MockQMessageBox: - Yes = FakeEnum(1) - No = FakeEnum(2) - Cancel = FakeEnum(4) - Question = FakeEnum(8) - - QMessageBox = _MockQMessageBox - - class _MockQFileDialog: - @staticmethod - def getOpenFileName(*args, **kwargs): - return ("", "") - - @staticmethod - def getSaveFileName(*args, **kwargs): - return ("", "") - - QFileDialog = _MockQFileDialog - - class _MockQEventLoop: - def exec(self) -> int: - return 0 - - def quit(self) -> None: - pass - - QEventLoop = _MockQEventLoop - - class _MockQUrl(str): - def isValid(self) -> bool: - return True - - QUrl = _MockQUrl - - class _MockQNetworkRequest: - def __init__(self, url: Any): - self.url = url - - QNetworkRequest = _MockQNetworkRequest - - class _MockQNetworkReply: - class NetworkError: - NoError = 0 - - def __init__(self): - self._data = b"" - - def error(self) -> int: - return 0 - - def errorString(self) -> str: - return "" - - def attribute(self, *args, **kwargs) -> Any: - return 200 - - def readAll(self) -> bytes: - return self._data - - def deleteLater(self) -> None: - pass - - QNetworkReply = _MockQNetworkReply - - YES = FakeEnum(1) - NO = FakeEnum(2) - CANCEL = FakeEnum(4) - ICON_QUESTION = FakeEnum(8) - - def exec_dialog(dialog: Any) -> Any: - return YES - - class _MockWidget: - def __init__(self, *args, **kwargs): - pass - - class _MockLayout: - def __init__(self, *args, **kwargs): - pass - - def addWidget(self, *args, **kwargs): - pass - - def addLayout(self, *args, **kwargs): - pass - - def addStretch(self, *args, **kwargs): - pass - - def setLayout(self, *args, **kwargs): - pass - - class _MockLabel: - def __init__(self, text: str = ""): - self._text = text - - class _MockLineEdit: - def __init__(self, *args, **kwargs): - self._text = "" - - def text(self) -> str: - return self._text - - def setText(self, value: str) -> None: - self._text = value - - class _MockButton: - def __init__(self, *args, **kwargs): - # einfache Attr für Kompatibilität mit Qt-Signal-Syntax - self.clicked = lambda *a, **k: None - - def connect(self, *args, **kwargs): - pass - - QWidget = _MockWidget - QGridLayout = _MockLayout - QLabel = _MockLabel - QLineEdit = _MockLineEdit - QGroupBox = _MockWidget - QVBoxLayout = _MockLayout - QPushButton = _MockButton - - # Kein echtes QCoreApplication im Mock - QCoreApplication = None - - -# --------------------------------------------------------- -# QGIS‑Imports -# --------------------------------------------------------- - -try: - from qgis.core import ( - QgsProject as _QgsProject, - QgsVectorLayer as _QgsVectorLayer, - QgsNetworkAccessManager as _QgsNetworkAccessManager, - Qgis as _Qgis, - ) - from qgis.utils import iface as _iface - - QgsProject = _QgsProject - QgsVectorLayer = _QgsVectorLayer - QgsNetworkAccessManager = _QgsNetworkAccessManager - Qgis = _Qgis - iface = _iface - - QGIS_AVAILABLE = True - -except Exception: - QGIS_AVAILABLE = False - - class _MockQgsProject: - @staticmethod - def instance() -> "_MockQgsProject": - return _MockQgsProject() - - def __init__(self): - self._variables = {} - - def read(self) -> bool: - return True - - QgsProject = _MockQgsProject - - class _MockQgsVectorLayer: - def __init__(self, *args, **kwargs): - self._valid = True - - def isValid(self) -> bool: - return self._valid - - def loadNamedStyle(self, path: str): - return True, "" - - def triggerRepaint(self) -> None: - pass - - QgsVectorLayer = _MockQgsVectorLayer - - class _MockQgsNetworkAccessManager: - def head(self, request: Any) -> _MockQNetworkReply: - return _MockQNetworkReply() - - QgsNetworkAccessManager = _MockQgsNetworkAccessManager - - class _MockQgis: - class MessageLevel: - Success = 0 - Info = 1 - Warning = 2 - Critical = 3 - - Qgis = _MockQgis - - class FakeIface: - class FakeMessageBar: - def pushMessage(self, title, text, level=0, duration=5): - return {"title": title, "text": text, "level": level, "duration": duration} - - def messageBar(self): - return self.FakeMessageBar() - - def mainWindow(self): - return None - - iface = FakeIface() - - -# --------------------------------------------------------- -# Message‑Funktionen -# --------------------------------------------------------- - -def _get_message_bar(): - if iface is not None: - bar_attr = getattr_safe(iface, "messageBar") - if callable(bar_attr): - try: - return bar_attr() - except Exception: - pass - - class _MockMessageBar: - def pushMessage(self, title, text, level=0, duration=5): - return { - "title": title, - "text": text, - "level": level, - "duration": duration, - } - - return _MockMessageBar() - - -def push_message(level, title, text, duration=5, parent=None): - bar = _get_message_bar() - push = getattr_safe(bar, "pushMessage") - if callable(push): - return push(title, text, level=level, duration=duration) - return None - - -def info(title, text, duration=5): - level = Qgis.MessageLevel.Info if Qgis is not None else 1 - return push_message(level, title, text, duration) - - -def warning(title, text, duration=5): - level = Qgis.MessageLevel.Warning if Qgis is not None else 2 - return push_message(level, title, text, duration) - - -def error(title, text, duration=5): - level = Qgis.MessageLevel.Critical if Qgis is not None else 3 - return push_message(level, title, text, duration) - - -def success(title, text, duration=5): - level = Qgis.MessageLevel.Success if Qgis is not None else 0 - return push_message(level, title, text, duration) - -# --------------------------------------------------------- -# Dialog‑Interaktionen -# --------------------------------------------------------- - -def ask_yes_no( - title: str, - message: str, - default: bool = False, - parent: Any = None, -) -> bool: - """ - Fragt den Benutzer eine Ja/Nein‑Frage. - - - In QGIS/Qt: zeigt einen QMessageBox‑Dialog - - Im Mock/Test‑Modus: gibt default zurück - """ - if QMessageBox is None: - return default - - try: - buttons = YES | NO - result = QMessageBox.question( - parent, - title, - message, - buttons, - YES if default else NO, - ) - return result == YES - except Exception: - return default - - -# --------------------------------------------------------- -# Variablen‑Wrapper -# --------------------------------------------------------- - -try: - from qgis.core import QgsExpressionContextUtils - - _HAS_QGIS_VARIABLES = True -except Exception: - _HAS_QGIS_VARIABLES = False - - class _MockVariableStore: - global_vars: dict[str, str] = {} - project_vars: dict[str, str] = {} - - class QgsExpressionContextUtils: - @staticmethod - def setGlobalVariable(name: str, value: str) -> None: - _MockVariableStore.global_vars[name] = value - - @staticmethod - def globalScope(): - class _Scope: - def variable(self, name: str) -> str: - return _MockVariableStore.global_vars.get(name, "") - - return _Scope() - - @staticmethod - def setProjectVariable(project: Any, name: str, value: str) -> None: - _MockVariableStore.project_vars[name] = value - - @staticmethod - def projectScope(project: Any): - class _Scope: - def variable(self, name: str) -> str: - return _MockVariableStore.project_vars.get(name, "") - - return _Scope() - - -def get_variable(key: str, scope: str = "project") -> str: - var_name = f"sn_{key}" - - if scope == "project": - if QgsProject is not None: - projekt = QgsProject.instance() - else: - projekt = None # type: ignore[assignment] - return QgsExpressionContextUtils.projectScope(projekt).variable(var_name) or "" - - if scope == "global": - return QgsExpressionContextUtils.globalScope().variable(var_name) or "" - - raise ValueError("Scope muss 'project' oder 'global' sein.") - - -def set_variable(key: str, value: str, scope: str = "project") -> None: - var_name = f"sn_{key}" - - if scope == "project": - if QgsProject is not None: - projekt = QgsProject.instance() - else: - projekt = None # type: ignore[assignment] - QgsExpressionContextUtils.setProjectVariable(projekt, var_name, value) - return - - if scope == "global": - QgsExpressionContextUtils.setGlobalVariable(var_name, value) - return - - raise ValueError("Scope muss 'project' oder 'global' sein.") - - -# --------------------------------------------------------- -# syswrapper Lazy‑Import -# --------------------------------------------------------- - -def _sys(): - from sn_basis.functions import syswrapper - return syswrapper - - -# --------------------------------------------------------- -# Style‑Funktion -# --------------------------------------------------------- - -def apply_style(layer, style_name: str) -> bool: - if layer is None: - return False - - is_valid_attr = getattr_safe(layer, "isValid") - if not callable(is_valid_attr) or not is_valid_attr(): - return False - - sys = _sys() - base_dir = sys.get_plugin_root() - style_path = sys.join_path(base_dir, "styles", style_name) - - if not sys.file_exists(style_path): - return False - - try: - ok, error_msg = layer.loadNamedStyle(style_path) - except Exception: - return False - - if not ok: - return False - - try: - trigger = getattr_safe(layer, "triggerRepaint") - if callable(trigger): - trigger() - except Exception: - pass - - return True - - -# --------------------------------------------------------- -# Layer‑Wrapper -# --------------------------------------------------------- - -def layer_exists(layer) -> bool: - if layer is None: - return False - - # Mock/Wrapper-Attribut - is_valid_flag = getattr_safe(layer, "is_valid") - if is_valid_flag is not None: - try: - return bool(is_valid_flag) - except Exception: - return False - - try: - is_valid_attr = getattr_safe(layer, "isValid") - if callable(is_valid_attr): - return bool(is_valid_attr()) - return True - except Exception: - return False - - -def get_layer_geometry_type(layer) -> str: - if layer is None: - return "None" - - geometry_type_attr = getattr_safe(layer, "geometry_type") - if geometry_type_attr is not None: - return str(geometry_type_attr) - - try: - is_spatial_attr = getattr_safe(layer, "isSpatial") - if callable(is_spatial_attr) and not is_spatial_attr(): - return "None" - - geometry_type_qgis = getattr_safe(layer, "geometryType") - if callable(geometry_type_qgis): - gtype = geometry_type_qgis() - if gtype == 0: - return "Point" - if gtype == 1: - return "LineString" - if gtype == 2: - return "Polygon" - return "None" - - return "None" - except Exception: - return "None" - - -def get_layer_feature_count(layer) -> int: - if layer is None: - return 0 - - feature_count_attr = getattr_safe(layer, "feature_count") - if feature_count_attr is not None: - try: - return int(feature_count_attr) - except Exception: - return 0 - - try: - is_spatial_attr = getattr_safe(layer, "isSpatial") - if callable(is_spatial_attr) and not is_spatial_attr(): - return 0 - - feature_count_qgis = getattr_safe(layer, "featureCount") - if callable(feature_count_qgis): - return int(feature_count_qgis()) - - return 0 - except Exception: - return 0 - - -def is_layer_visible(layer) -> bool: - if layer is None: - return False - - visible_attr = getattr_safe(layer, "visible") - if visible_attr is not None: - try: - return bool(visible_attr) - except Exception: - return False - - try: - is_visible_attr = getattr_safe(layer, "isVisible") - if callable(is_visible_attr): - return bool(is_visible_attr()) - - tree_layer_attr = getattr_safe(layer, "treeLayer") - if callable(tree_layer_attr): - node = tree_layer_attr() - else: - node = tree_layer_attr - - if node is not None: - node_visible_attr = getattr_safe(node, "isVisible") - if callable(node_visible_attr): - return bool(node_visible_attr()) - - return False - except Exception: - return False - -def set_layer_visible(layer, visible: bool) -> bool: - """ - Setzt die Sichtbarkeit eines Layers. - - Unterstützt: - - Mock-/Wrapper-Attribute (layer.visible) - - QGIS-LayerTreeNode (treeLayer().setItemVisibilityChecked) - - Fallbacks ohne Exception-Wurf - - Gibt True zurück, wenn die Sichtbarkeit gesetzt werden konnte. - """ - if layer is None: - return False - - # 1️⃣ Mock / Wrapper-Attribut - try: - if hasattr(layer, "visible"): - layer.visible = bool(visible) - return True - except Exception: - pass - - # 2️⃣ QGIS: LayerTreeNode - try: - tree_layer_attr = getattr_safe(layer, "treeLayer") - node = tree_layer_attr() if callable(tree_layer_attr) else tree_layer_attr - - if node is not None: - set_visible = getattr_safe(node, "setItemVisibilityChecked") - if callable(set_visible): - set_visible(bool(visible)) - return True - except Exception: - pass - - # 3️⃣ QGIS-Fallback: setVisible (selten, aber vorhanden) - try: - set_visible_attr = getattr_safe(layer, "setVisible") - if callable(set_visible_attr): - set_visible_attr(bool(visible)) - return True - except Exception: - pass - - return False - - -def get_layer_type(layer) -> str: - if layer is None: - return "unknown" - - layer_type_attr = getattr_safe(layer, "layer_type") - if layer_type_attr is not None: - return str(layer_type_attr) - - try: - is_spatial_attr = getattr_safe(layer, "isSpatial") - if callable(is_spatial_attr): - return "vector" if is_spatial_attr() else "table" - - data_provider_attr = getattr_safe(layer, "dataProvider") - raster_type_attr = getattr_safe(layer, "rasterType") - if data_provider_attr is not None and raster_type_attr is not None: - return "raster" - - return "unknown" - except Exception: - return "unknown" - - -def get_layer_crs(layer) -> str: - if layer is None: - return "None" - - crs_attr_direct = getattr_safe(layer, "crs") - if crs_attr_direct is not None and not callable(crs_attr_direct): - # direkter Attributzugriff (z. B. im Mock) - return str(crs_attr_direct) - - try: - crs_callable = getattr_safe(layer, "crs") - if callable(crs_callable): - crs = crs_callable() - authid_attr = getattr_safe(crs, "authid") - if callable(authid_attr): - return authid_attr() or "None" - return "None" - except Exception: - return "None" - - -def get_layer_fields(layer) -> list[str]: - if layer is None: - return [] - - # direkter Attributzugriff (Mock / Wrapper) - fields_attr_direct = getattr_safe(layer, "fields") - if fields_attr_direct is not None and not callable(fields_attr_direct): - try: - # direkter Iterable oder Mapping von Namen - if hasattr(fields_attr_direct, "__iter__") and not isinstance( - fields_attr_direct, (str, bytes) - ): - return list(fields_attr_direct) - except Exception: - return [] - - try: - fields_callable = getattr_safe(layer, "fields") - if callable(fields_callable): - fields = fields_callable() - - # QGIS: QgsFields.names() - names_attr = getattr_safe(fields, "names") - if callable(names_attr): - return list(names_attr()) - - # Fallback: iterierbar? - if hasattr(fields, "__iter__") and not isinstance(fields, (str, bytes)): - return list(fields) - - return [] - except Exception: - return [] - - -def get_layer_source(layer) -> str: - if layer is None: - return "None" - - source_attr_direct = getattr_safe(layer, "source") - if source_attr_direct is not None and not callable(source_attr_direct): - return str(source_attr_direct) - - try: - source_callable = getattr_safe(layer, "source") - if callable(source_callable): - return source_callable() or "None" - return "None" - except Exception: - return "None" - - -def is_layer_editable(layer) -> bool: - if layer is None: - return False - - editable_attr = getattr_safe(layer, "editable") - if editable_attr is not None: - try: - return bool(editable_attr) - except Exception: - return False - - try: - editable_callable = getattr_safe(layer, "isEditable") - if callable(editable_callable): - return bool(editable_callable()) - return False - except Exception: - return False diff --git a/functions/qgisui_wrapper.py b/functions/qgisui_wrapper.py new file mode 100644 index 0000000..56e5775 --- /dev/null +++ b/functions/qgisui_wrapper.py @@ -0,0 +1,140 @@ +""" +sn_basis/functions/qgisui_wrapper.py – zentrale QGIS-UI-Abstraktion +""" + +from typing import Any, List + +from sn_basis.functions.qt_wrapper import QDockWidget + + +iface: Any +QGIS_UI_AVAILABLE = False + + +# --------------------------------------------------------- +# iface initialisieren (QGIS oder Mock) +# --------------------------------------------------------- + +try: + from qgis.utils import iface as _iface + iface = _iface + QGIS_UI_AVAILABLE = True + +except Exception: + + + class _MockMessageBar: + def pushMessage(self, title, text, level=0, duration=5): + return { + "title": title, + "text": text, + "level": level, + "duration": duration, + } + + class _MockIface: + def messageBar(self): + return _MockMessageBar() + + def mainWindow(self): + return None + + def addDockWidget(self, *args, **kwargs): + pass + + def removeDockWidget(self, *args, **kwargs): + pass + + def addToolBar(self, *args, **kwargs): + pass + + def removeToolBar(self, *args, **kwargs): + pass + + iface = _MockIface() + + +# --------------------------------------------------------- +# Main Window +# --------------------------------------------------------- + +def get_main_window(): + try: + return iface.mainWindow() + except Exception: + return None + + +# --------------------------------------------------------- +# Dock-Handling +# --------------------------------------------------------- + +def add_dock_widget(area, dock: Any) -> None: + try: + iface.addDockWidget(area, dock) + except Exception: + pass + + +def remove_dock_widget(dock: Any) -> None: + try: + iface.removeDockWidget(dock) + except Exception: + pass + + +def find_dock_widgets() -> List[Any]: + main_window = get_main_window() + if not main_window: + return [] + + try: + return main_window.findChildren(QDockWidget) + except Exception: + return [] + + +# --------------------------------------------------------- +# Menü-Handling +# --------------------------------------------------------- + +def add_menu(menu): + main_window = iface.mainWindow() + if not main_window: + return + + # Nur echte Qt-Menüs an Qt übergeben + if hasattr(menu, "menuAction"): + main_window.menuBar().addMenu(menu) + + + + +def remove_menu(menu): + main_window = iface.mainWindow() + if not main_window: + return + + if hasattr(menu, "menuAction"): + main_window.menuBar().removeAction(menu.menuAction()) + + + + + +# --------------------------------------------------------- +# Toolbar-Handling +# --------------------------------------------------------- + +def add_toolbar(toolbar: Any) -> None: + try: + iface.addToolBar(toolbar) + except Exception: + pass + + +def remove_toolbar(toolbar: Any) -> None: + try: + iface.removeToolBar(toolbar) + except Exception: + pass diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py new file mode 100644 index 0000000..08de053 --- /dev/null +++ b/functions/qt_wrapper.py @@ -0,0 +1,393 @@ +""" +sn_basis/functions/qt_wrapper.py – zentrale Qt-Abstraktion (PyQt5 / PyQt6 / Mock) +""" + +from typing import Optional, Type, Any + +# --------------------------------------------------------- +# Qt-Symbole (werden dynamisch gesetzt) +# --------------------------------------------------------- + +QDockWidget: Type[Any] +QMessageBox: Type[Any] +QFileDialog: Type[Any] +QEventLoop: Type[Any] +QUrl: Type[Any] +QNetworkRequest: Type[Any] +QNetworkReply: Type[Any] +QCoreApplication: Type[Any] + +QWidget: Type[Any] +QGridLayout: Type[Any] +QLabel: Type[Any] +QLineEdit: Type[Any] +QGroupBox: Type[Any] +QVBoxLayout: Type[Any] +QPushButton: Type[Any] +QAction: Type[Any] +QMenu: Type[Any] +QToolBar: Type[Any] +QActionGroup: Type[Any] +QTabWidget: type + + + +YES: Optional[Any] = None +NO: Optional[Any] = None +CANCEL: Optional[Any] = None +ICON_QUESTION: Optional[Any] = None + +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 + + +def exec_dialog(dialog: Any) -> Any: + raise NotImplementedError + + +# --------------------------------------------------------- +# Versuch: PyQt6 +# --------------------------------------------------------- + +try: + from qgis.PyQt.QtWidgets import ( # type: ignore + QMessageBox as _QMessageBox,# type: ignore + QFileDialog as _QFileDialog,# type: ignore + QWidget as _QWidget,# type: ignore + QGridLayout as _QGridLayout,# type: ignore + QLabel as _QLabel,# type: ignore + QLineEdit as _QLineEdit,# type: ignore + QGroupBox as _QGroupBox,# type: ignore + QVBoxLayout as _QVBoxLayout,# type: ignore + QPushButton as _QPushButton,# type: ignore + QAction as _QAction, + QMenu as _QMenu,# type: ignore + QToolBar as _QToolBar,# type: ignore + QActionGroup as _QActionGroup,# type: ignore + QDockWidget as _QDockWidget,# type: ignore + QTabWidget as _QTabWidget,# type: ignore +) + + + + from qgis.PyQt.QtCore import ( # type: ignore + QEventLoop as _QEventLoop,# type: ignore + QUrl as _QUrl,# type: ignore + QCoreApplication as _QCoreApplication,# type: ignore + ) + from qgis.PyQt.QtNetwork import ( # type: ignore + QNetworkRequest as _QNetworkRequest,# type: ignore + QNetworkReply as _QNetworkReply,# type: ignore + ) + QT_VERSION = 6 + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + QDockWidget = _QDockWidget + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + QAction = _QAction + QMenu = _QMenu + QToolBar = _QToolBar + QActionGroup = _QActionGroup + QTabWidget = _QTabWidget + + + + YES = QMessageBox.StandardButton.Yes + NO = QMessageBox.StandardButton.No + CANCEL = QMessageBox.StandardButton.Cancel + ICON_QUESTION = QMessageBox.Icon.Question + + + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec() + +# --------------------------------------------------------- +# Versuch: PyQt5 +# --------------------------------------------------------- + +except Exception: + try: + from PyQt5.QtWidgets import ( + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + QAction as _QAction, + QMenu as _QMenu, + QToolBar as _QToolBar, + QActionGroup as _QActionGroup, + QDockWidget as _QDockWidget, + QTabWidget as _QTabWidget, + + ) + from PyQt5.QtCore import ( + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + ) + from PyQt5.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + QMessageBox = _QMessageBox + QFileDialog = _QFileDialog + QEventLoop = _QEventLoop + QUrl = _QUrl + QNetworkRequest = _QNetworkRequest + QNetworkReply = _QNetworkReply + QCoreApplication = _QCoreApplication + QDockWidget = _QDockWidget + + + QWidget = _QWidget + QGridLayout = _QGridLayout + QLabel = _QLabel + QLineEdit = _QLineEdit + QGroupBox = _QGroupBox + QVBoxLayout = _QVBoxLayout + QPushButton = _QPushButton + QAction = _QAction + QMenu = _QMenu + QToolBar = _QToolBar + QActionGroup = _QActionGroup + QTabWidget = _QTabWidget + + + + YES = QMessageBox.Yes + NO = QMessageBox.No + CANCEL = QMessageBox.Cancel + ICON_QUESTION = QMessageBox.Question + + QT_VERSION = 5 + + def exec_dialog(dialog: Any) -> Any: + return dialog.exec_() + +# --------------------------------------------------------- +# Mock-Modus +# --------------------------------------------------------- + + except Exception: + QT_VERSION = 0 + + class FakeEnum(int): + def __or__(self, other: "FakeEnum") -> "FakeEnum": + return FakeEnum(int(self) | int(other)) + + YES = FakeEnum(1) + NO = FakeEnum(2) + CANCEL = FakeEnum(4) + ICON_QUESTION = FakeEnum(8) + + class _MockQMessageBox: + Yes = YES + No = NO + Cancel = CANCEL + Question = ICON_QUESTION + + QMessageBox = _MockQMessageBox + + class _MockQFileDialog: + @staticmethod + def getOpenFileName(*args, **kwargs): + return ("", "") + + @staticmethod + def getSaveFileName(*args, **kwargs): + return ("", "") + + QFileDialog = _MockQFileDialog + + class _MockQEventLoop: + def exec(self) -> int: + return 0 + + def quit(self) -> None: + pass + + QEventLoop = _MockQEventLoop + + class _MockQUrl(str): + def isValid(self) -> bool: + return True + + QUrl = _MockQUrl + + class _MockQNetworkRequest: + def __init__(self, url: Any): + self.url = url + + QNetworkRequest = _MockQNetworkRequest + + class _MockQNetworkReply: + def error(self) -> int: + return 0 + + def errorString(self) -> str: + return "" + + def readAll(self) -> bytes: + return b"" + + def deleteLater(self) -> None: + pass + + QNetworkReply = _MockQNetworkReply + + class _MockWidget: + def __init__(self, *args, **kwargs): + pass + + class _MockLayout: + def addWidget(self, *args, **kwargs): + pass + + def addLayout(self, *args, **kwargs): + pass + + def addStretch(self, *args, **kwargs): + pass + + class _MockLabel: + def __init__(self, text: str = ""): + self._text = text + + class _MockLineEdit: + def __init__(self, *args, **kwargs): + self._text = "" + + def text(self) -> str: + return self._text + + def setText(self, value: str) -> None: + self._text = value + + class _MockButton: + def __init__(self, *args, **kwargs): + self.clicked = lambda *a, **k: None + + QWidget = _MockWidget + QGridLayout = _MockLayout + QLabel = _MockLabel + QLineEdit = _MockLineEdit + QGroupBox = _MockWidget + QVBoxLayout = _MockLayout + QPushButton = _MockButton + + class _MockQCoreApplication: + pass + + QCoreApplication = _MockQCoreApplication + + + class _MockQDockWidget(_MockWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._object_name = "" + + def setObjectName(self, name: str) -> None: + self._object_name = name + + def objectName(self) -> str: + return self._object_name + + def show(self) -> None: + pass + + def deleteLater(self) -> None: + pass + QDockWidget = _MockQDockWidget + class _MockAction: + def __init__(self, *args, **kwargs): + self._checked = False + self.triggered = lambda *a, **k: None + + def setToolTip(self, text: str) -> None: + pass + + def setCheckable(self, value: bool) -> None: + pass + + def setChecked(self, value: bool) -> None: + self._checked = value + + + class _MockMenu: + def __init__(self, *args, **kwargs): + self._actions = [] + + def addAction(self, action): + self._actions.append(action) + + def removeAction(self, action): + if action in self._actions: + self._actions.remove(action) + + def clear(self): + self._actions.clear() + + def menuAction(self): + return self + + + class _MockToolBar: + def __init__(self, *args, **kwargs): + self._actions = [] + + def setObjectName(self, name: str) -> None: + pass + + def addAction(self, action): + self._actions.append(action) + + def removeAction(self, action): + if action in self._actions: + self._actions.remove(action) + + def clear(self): + self._actions.clear() + + + class _MockActionGroup: + def __init__(self, *args, **kwargs): + self._actions = [] + + def setExclusive(self, value: bool) -> None: + pass + + def addAction(self, action): + self._actions.append(action) + QAction = _MockAction + QMenu = _MockMenu + QToolBar = _MockToolBar + QActionGroup = _MockActionGroup + + + def exec_dialog(dialog: Any) -> Any: + return YES + class _MockTabWidget: + def __init__(self, *args, **kwargs): + self._tabs = [] + + def addTab(self, widget, title: str): + self._tabs.append((widget, title)) + QTabWidget = _MockTabWidget + diff --git a/functions/settings_logic.py b/functions/settings_logic.py index 77d049c..73543ce 100644 --- a/functions/settings_logic.py +++ b/functions/settings_logic.py @@ -1,9 +1,9 @@ """ -sn_basis/funktions/settings_logic.py – Logik zum Lesen und Schreiben der Plugin-Einstellungen -über den zentralen qgisqt_wrapper. +sn_basis/functions/settings_logic.py – Logik zum Lesen und Schreiben der Plugin-Einstellungen +über den zentralen variable_wrapper. """ -from sn_basis.functions.qgisqt_wrapper import ( +from sn_basis.functions.variable_wrapper import ( get_variable, set_variable, ) @@ -27,17 +27,17 @@ class SettingsLogic: "landkreise_proj", ] - def load(self) -> dict: + def load(self) -> dict[str, str]: """ Lädt alle Variablen aus dem Projekt. Rückgabe: dict mit allen Werten (leere Strings, wenn nicht gesetzt). """ - daten = {} + daten: dict[str, str] = {} for key in self.VARIABLEN: daten[key] = get_variable(key, scope="project") return daten - def save(self, daten: dict): + def save(self, daten: dict[str, str]) -> None: """ Speichert alle übergebenen Variablen im Projekt. daten: dict mit key → value diff --git a/functions/sys_wrapper.py b/functions/sys_wrapper.py new file mode 100644 index 0000000..75b1899 --- /dev/null +++ b/functions/sys_wrapper.py @@ -0,0 +1,104 @@ +""" +sn_basis/functions/sys_wrapper.py – System- und Pfad-Abstraktion +""" + +from pathlib import Path +from typing import Union +import sys + + +_PathLike = Union[str, Path] + + +# --------------------------------------------------------- +# Plugin Root +# --------------------------------------------------------- + +def get_plugin_root() -> Path: + """ + Liefert das Basisverzeichnis des Plugins. + """ + return Path(__file__).resolve().parents[2] + + +# --------------------------------------------------------- +# Pfad-Utilities +# --------------------------------------------------------- + +def join_path(*parts: _PathLike) -> Path: + """ + Verbindet Pfadbestandteile OS-sicher. + """ + path = Path(parts[0]) + for part in parts[1:]: + path /= part + return path + + +def file_exists(path: _PathLike) -> bool: + """ + Prüft, ob eine Datei existiert. + """ + try: + return Path(path).exists() + except Exception: + return False + + +def ensure_dir(path: _PathLike) -> Path: + """ + Stellt sicher, dass ein Verzeichnis existiert. + """ + p = Path(path) + p.mkdir(parents=True, exist_ok=True) + return p + + +# --------------------------------------------------------- +# Datei-IO +# --------------------------------------------------------- + +def read_text(path: _PathLike, encoding: str = "utf-8") -> str: + """ + Liest eine Textdatei. + """ + try: + return Path(path).read_text(encoding=encoding) + except Exception: + return "" + + +def write_text( + path: _PathLike, + content: str, + encoding: str = "utf-8", +) -> bool: + """ + Schreibt eine Textdatei. + """ + try: + Path(path).write_text(content, encoding=encoding) + return True + except Exception: + return False + + + +def add_to_sys_path(path: Union[str, Path]) -> None: + """ + Fügt einen Pfad zu sys.path hinzu, falls er noch nicht enthalten ist. + """ + p = str(path) + if p not in sys.path: + sys.path.insert(0, p) +def getattr_safe(obj, attr, default=None): + """ + Sicherer Zugriff auf ein Attribut. + + Gibt das Attribut zurück, wenn es existiert, + ansonsten den Default-Wert (None, wenn nicht angegeben). + """ + try: + return getattr(obj, attr) + except Exception: + return default diff --git a/functions/syswrapper.py b/functions/syswrapper.py deleted file mode 100644 index 2ab5a6b..0000000 --- a/functions/syswrapper.py +++ /dev/null @@ -1,185 +0,0 @@ -""" -snbasis/functions/syswrapper.py – zentrale OS-/Dateisystem-Abstraktion -Robust, testfreundlich, mock-fähig. -""" - -import os -import tempfile -import pathlib -import sys - - -# --------------------------------------------------------- -# Dateisystem‑Funktionen -# --------------------------------------------------------- - -def file_exists(path: str) -> bool: - """Prüft, ob eine Datei existiert.""" - try: - return os.path.exists(path) - except Exception: - return False - - -def is_file(path: str) -> bool: - """Prüft, ob ein Pfad eine Datei ist.""" - try: - return os.path.isfile(path) - except Exception: - return False - - -def is_dir(path: str) -> bool: - """Prüft, ob ein Pfad ein Verzeichnis ist.""" - try: - return os.path.isdir(path) - except Exception: - return False - - -def join_path(*parts) -> str: - """Verbindet Pfadbestandteile OS‑unabhängig.""" - try: - return os.path.join(*parts) - except Exception: - # Fallback: naive Verkettung - return "/".join(str(p) for p in parts) - - -# --------------------------------------------------------- -# Pfad‑ und Systemfunktionen -# --------------------------------------------------------- - -def get_temp_dir() -> str: - """Gibt das temporäre Verzeichnis zurück.""" - try: - return tempfile.gettempdir() - except Exception: - return "/tmp" - - -def get_plugin_root() -> str: - """ - Ermittelt den Plugin‑Root‑Pfad. - Annahme: syswrapper liegt in sn_basis/funktions/ - → also zwei Ebenen hoch. - """ - try: - here = pathlib.Path(__file__).resolve() - return str(here.parent.parent) - except Exception: - # Fallback: aktuelles Arbeitsverzeichnis - return os.getcwd() - - -# --------------------------------------------------------- -# Datei‑I/O (optional, aber nützlich) -# --------------------------------------------------------- - -def read_file(path: str, mode="r"): - """Liest eine Datei ein. Gibt None zurück, wenn Fehler auftreten.""" - try: - with open(path, mode) as f: - return f.read() - except Exception: - return None - - -def write_file(path: str, data, mode="w"): - """Schreibt Daten in eine Datei. Gibt True/False zurück.""" - try: - with open(path, mode) as f: - f.write(data) - return True - except Exception: - return False - - -# --------------------------------------------------------- -# Mock‑Modus (optional erweiterbar) -# --------------------------------------------------------- - -class FakeFileSystem: - """ - Minimaler Mock‑Dateisystem‑Ersatz. - Wird nicht automatisch aktiviert, aber kann in Tests gepatcht werden. - """ - files = {} - - @classmethod - def add_file(cls, path, content=""): - cls.files[path] = content - - @classmethod - def exists(cls, path): - return path in cls.files - - @classmethod - def read(cls, path): - return cls.files.get(path, None) - -# --------------------------------------------------------- -# Betriebssystem‑Erkennung -# --------------------------------------------------------- - -import platform - -def get_os() -> str: - """ - Gibt das Betriebssystem zurück: - - 'windows' - - 'linux' - - 'mac' - """ - system = platform.system().lower() - - if "windows" in system: - return "windows" - if "darwin" in system: - return "mac" - if "linux" in system: - return "linux" - - return "unknown" - - -def is_windows() -> bool: - return get_os() == "windows" - - -def is_linux() -> bool: - return get_os() == "linux" - - -def is_mac() -> bool: - return get_os() == "mac" - - -# --------------------------------------------------------- -# Pfad‑Normalisierung -# --------------------------------------------------------- - -def normalize_path(path: str) -> str: - """ - Normalisiert Pfade OS‑unabhängig: - - ersetzt Backslashes durch Slashes - - entfernt doppelte Slashes - - löst relative Pfade auf - """ - try: - p = pathlib.Path(path).resolve() - return str(p) - except Exception: - # Fallback: einfache Normalisierung - return path.replace("\\", "/").replace("//", "/") - -def add_to_sys_path(path: str) -> None: - """ - Fügt einen Pfad sicher zum Python-Importpfad hinzu. - """ - try: - if path not in sys.path: - sys.path.insert(0, path) - except Exception: - pass - diff --git a/functions/variable_wrapper.py b/functions/variable_wrapper.py new file mode 100644 index 0000000..416e864 --- /dev/null +++ b/functions/variable_wrapper.py @@ -0,0 +1,115 @@ +""" +variable_wrapper.py – QGIS-Variablen-Abstraktion +""" + +from typing import Any + +from sn_basis.functions.qgiscore_wrapper import QgsProject + + +# --------------------------------------------------------- +# Versuch: QgsExpressionContextUtils importieren +# --------------------------------------------------------- + +try: + from qgis.core import QgsExpressionContextUtils + + _HAS_QGIS_VARIABLES = True + +# --------------------------------------------------------- +# Mock-Modus +# --------------------------------------------------------- + +except Exception: + _HAS_QGIS_VARIABLES = False + + class _MockVariableStore: + global_vars: dict[str, str] = {} + project_vars: dict[str, str] = {} + + class QgsExpressionContextUtils: + @staticmethod + def setGlobalVariable(name: str, value: str) -> None: + _MockVariableStore.global_vars[name] = value + + @staticmethod + def globalScope(): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.global_vars.get(name, "") + + return _Scope() + + @staticmethod + def setProjectVariable(project: Any, name: str, value: str) -> None: + _MockVariableStore.project_vars[name] = value + + @staticmethod + def projectScope(project: Any): + class _Scope: + def variable(self, name: str) -> str: + return _MockVariableStore.project_vars.get(name, "") + + return _Scope() + + +# --------------------------------------------------------- +# Öffentliche API +# --------------------------------------------------------- + +def get_variable(key: str, scope: str = "project") -> str: + """ + Liest eine QGIS-Variable. + + :param key: Variablenname ohne Prefix + :param scope: 'project' oder 'global' + """ + var_name = f"sn_{key}" + + if scope == "project": + project = QgsProject.instance() + return ( + QgsExpressionContextUtils + .projectScope(project) + .variable(var_name) + or "" + ) + + if scope == "global": + return ( + QgsExpressionContextUtils + .globalScope() + .variable(var_name) + or "" + ) + + raise ValueError("Scope muss 'project' oder 'global' sein.") + + +def set_variable(key: str, value: str, scope: str = "project") -> None: + """ + Setzt eine QGIS-Variable. + + :param key: Variablenname ohne Prefix + :param value: Wert + :param scope: 'project' oder 'global' + """ + var_name = f"sn_{key}" + + if scope == "project": + project = QgsProject.instance() + QgsExpressionContextUtils.setProjectVariable( + project, + var_name, + value, + ) + return + + if scope == "global": + QgsExpressionContextUtils.setGlobalVariable( + var_name, + value, + ) + return + + raise ValueError("Scope muss 'project' oder 'global' sein.") diff --git a/main.py b/main.py index d71c114..414820f 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,20 @@ # sn_basis/main.py -from sn_basis.functions.qgisqt_wrapper import QCoreApplication, getattr_safe from qgis.utils import plugins + +from sn_basis.functions.qt_wrapper import QCoreApplication +from sn_basis.functions.sys_wrapper import getattr_safe from sn_basis.ui.navigation import Navigation class BasisPlugin: + """ + Einstiegspunkt des sn_basis-Plugins. + Orchestriert UI und Fachmodule – keine UI-Logik. + """ + def __init__(self, iface): - self.iface = iface + # iface wird von QGIS übergeben, aber nicht direkt verwendet self.ui = None # QCoreApplication kann im Mock-Modus None sein @@ -21,10 +28,12 @@ class BasisPlugin: connect(self.unload) def initGui(self): - # Basis-Navigation neu aufbauen - self.ui = Navigation(self.iface) - - # Alle Fachplugins mit "sn_" prüfen und neu initialisieren + """ + Initialisiert die Basis-Navigation und triggert initGui + aller abhängigen sn_-Plugins. + """ + self.ui = Navigation() + self.ui.init_ui() for name, plugin in plugins.items(): if name.startswith("sn_") and name != "sn_basis": try: @@ -33,10 +42,12 @@ class BasisPlugin: init_gui() except Exception as e: print(f"Fehler beim Neuinitialisieren von {name}: {e}") + self.ui.finalize_menu_and_toolbar() def unload(self): + """ + Räumt UI-Komponenten sauber auf. + """ if self.ui: - remove_all = getattr_safe(self.ui, "remove_all") - if callable(remove_all): - remove_all() + self.ui.remove_all() self.ui = None diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index a2fd17e..5564654 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,12 +1,13 @@ """ -sn_basis/modulesdateipruefer.py – Prüfung von Dateieingaben für das Plugin. -Verwendet syswrapper und gibt pruef_ergebnis an den Pruefmanager zurück. +sn_basis/modules/Dateipruefer.py – Prüfung von Dateieingaben für das Plugin. +Verwendet sys_wrapper und gibt pruef_ergebnis an den Pruefmanager zurück. """ -from sn_basis.functions.syswrapper import ( - file_exists, - is_file, +from pathlib import Path + +from sn_basis.functions import ( join_path, + file_exists, ) from sn_basis.modules.Pruefmanager import pruef_ergebnis @@ -32,13 +33,14 @@ class Dateipruefer: self.standarddatei = standarddatei self.temporaer_erlaubt = temporaer_erlaubt - # --------------------------------------------------------- # Hilfsfunktion # --------------------------------------------------------- - def _pfad(self, relativer_pfad: str) -> str: - """Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis.""" + def _pfad(self, relativer_pfad: str) -> Path: + """ + Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis. + """ return join_path(self.basis_pfad, relativer_pfad) # --------------------------------------------------------- @@ -62,12 +64,12 @@ class Dateipruefer: # ----------------------------------------------------- pfad = self._pfad(self.pfad) - if not file_exists(pfad) or not is_file(pfad): + if not file_exists(pfad): return pruef_ergebnis( ok=False, meldung=f"Die Datei '{self.pfad}' wurde nicht gefunden.", aktion="datei_nicht_gefunden", - pfad=pfad, + kontext=pfad, ) # ----------------------------------------------------- @@ -77,7 +79,7 @@ class Dateipruefer: ok=True, meldung="Datei gefunden.", aktion="ok", - pfad=pfad, + kontext=pfad, ) # --------------------------------------------------------- @@ -96,25 +98,31 @@ class Dateipruefer: ok=False, meldung="Das Dateifeld ist leer. Soll ohne Datei fortgefahren werden?", aktion="leereingabe_erlaubt", - pfad=None, + kontext=None, ) # 2. Standarddatei verfügbar → Nutzer fragen, ob sie verwendet werden soll if self.standarddatei: return pruef_ergebnis( ok=False, - meldung=f"Es wurde keine Datei angegeben. Soll die Standarddatei '{self.standarddatei}' verwendet werden?", + meldung=( + f"Es wurde keine Datei angegeben. " + f"Soll die Standarddatei '{self.standarddatei}' verwendet werden?" + ), aktion="standarddatei_vorschlagen", - pfad=self._pfad(self.standarddatei), + kontext=self._pfad(self.standarddatei), ) # 3. Temporäre Datei erlaubt → Nutzer fragen, ob temporär gearbeitet werden soll if self.temporaer_erlaubt: return pruef_ergebnis( ok=False, - meldung="Es wurde keine Datei angegeben. Soll eine temporäre Datei erzeugt werden?", + meldung=( + "Es wurde keine Datei angegeben. " + "Soll eine temporäre Datei erzeugt werden?" + ), aktion="temporaer_erlaubt", - pfad=None, + kontext=None, ) # 4. Leereingabe nicht erlaubt → Fehler @@ -122,5 +130,5 @@ class Dateipruefer: ok=False, meldung="Es wurde keine Datei angegeben.", aktion="leereingabe_nicht_erlaubt", - pfad=None, + kontext=None, ) diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index 5eea743..4a0a85c 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,14 +1,14 @@ """ -sn_basis/modules/pruefmanager.py – zentrale Verarbeitung von pruef_ergebnis-Objekten. -Steuert die Nutzerinteraktion über qgisqt_wrapper. +sn_basis/modules/Pruefmanager.py – zentrale Verarbeitung von pruef_ergebnis-Objekten. +Steuert die Nutzerinteraktion über Wrapper. """ -from sn_basis.functions.qgisqt_wrapper import ( +from sn_basis.functions import ( ask_yes_no, info, warning, error, - set_layer_visible, # optional, falls implementiert + set_layer_visible, ) from sn_basis.modules.pruef_ergebnis import pruef_ergebnis @@ -36,6 +36,7 @@ class Pruefmanager: return ergebnis aktion = ergebnis.aktion + kontext = ergebnis.kontext # ----------------------------------------------------- # Allgemeine Aktionen @@ -47,7 +48,12 @@ class Pruefmanager: if aktion == "leereingabe_erlaubt": if ask_yes_no("Ohne Eingabe fortfahren", ergebnis.meldung): - return pruef_ergebnis(True, "Ohne Eingabe fortgefahren.", "ok", None) + return pruef_ergebnis( + ok=True, + meldung="Ohne Eingabe fortgefahren.", + aktion="ok", + kontext=None, + ) return ergebnis if aktion == "leereingabe_nicht_erlaubt": @@ -56,12 +62,22 @@ class Pruefmanager: if aktion == "standarddatei_vorschlagen": if ask_yes_no("Standarddatei verwenden", ergebnis.meldung): - return pruef_ergebnis(True, "Standarddatei wird verwendet.", "ok", ergebnis.pfad) + return pruef_ergebnis( + ok=True, + meldung="Standarddatei wird verwendet.", + aktion="ok", + kontext=kontext, + ) return ergebnis if aktion == "temporaer_erlaubt": if ask_yes_no("Temporäre Datei erzeugen", ergebnis.meldung): - return pruef_ergebnis(True, "Temporäre Datei soll erzeugt werden.", "temporaer_erzeugen", None) + return pruef_ergebnis( + ok=True, + meldung="Temporäre Datei soll erzeugt werden.", + aktion="temporaer_erzeugen", + kontext=None, + ) return ergebnis if aktion == "datei_nicht_gefunden": @@ -94,12 +110,18 @@ class Pruefmanager: if aktion == "layer_unsichtbar": if ask_yes_no("Layer einblenden", ergebnis.meldung): - # Falls set_layer_visible implementiert ist - try: - set_layer_visible(ergebnis.pfad, True) - except Exception: - pass - return pruef_ergebnis(True, "Layer wurde eingeblendet.", "ok", ergebnis.pfad) + if kontext is not None: + try: + set_layer_visible(kontext, True) + except Exception: + pass + + return pruef_ergebnis( + ok=True, + meldung="Layer wurde eingeblendet.", + aktion="ok", + kontext=kontext, + ) return ergebnis if aktion == "falscher_geotyp": diff --git a/modules/layerpruefer.py b/modules/layerpruefer.py index b0d5c56..164f9cf 100644 --- a/modules/layerpruefer.py +++ b/modules/layerpruefer.py @@ -1,9 +1,9 @@ """ sn_basis/modules/layerpruefer.py – Prüfung von QGIS-Layern. -Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +Verwendet ausschließlich Wrapper und gibt pruef_ergebnis zurück. """ -from sn_basis.functions.qgisqt_wrapper import ( +from sn_basis.functions import ( layer_exists, get_layer_geometry_type, get_layer_feature_count, @@ -15,7 +15,7 @@ from sn_basis.functions.qgisqt_wrapper import ( is_layer_editable, ) -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion class Layerpruefer: @@ -57,8 +57,8 @@ class Layerpruefer: return pruef_ergebnis( ok=False, meldung="Der Layer existiert nicht oder wurde nicht geladen.", - aktion="layer_nicht_gefunden", # type: ignore - pfad=None, + aktion="layer_nicht_gefunden", + kontext=None, ) # ----------------------------------------------------- @@ -69,8 +69,8 @@ class Layerpruefer: return pruef_ergebnis( ok=False, meldung="Der Layer ist unsichtbar. Soll er eingeblendet werden?", - aktion="layer_unsichtbar", # type: ignore - pfad=self.layer, # Layerobjekt wird übergeben + aktion="layer_unsichtbar", + kontext=self.layer, # Layerobjekt als Kontext ) # ----------------------------------------------------- @@ -80,9 +80,12 @@ class Layerpruefer: if self.erwarteter_layertyp and layertyp != self.erwarteter_layertyp: return pruef_ergebnis( ok=False, - meldung=f"Der Layer hat den Typ '{layertyp}', erwartet wurde '{self.erwarteter_layertyp}'.", + meldung=( + f"Der Layer hat den Typ '{layertyp}', " + f"erwartet wurde '{self.erwarteter_layertyp}'." + ), aktion="falscher_layertyp", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -92,9 +95,12 @@ class Layerpruefer: if self.erwarteter_geotyp and geotyp != self.erwarteter_geotyp: return pruef_ergebnis( ok=False, - meldung=f"Der Layer hat den Geometrietyp '{geotyp}', erwartet wurde '{self.erwarteter_geotyp}'.", + meldung=( + f"Der Layer hat den Geometrietyp '{geotyp}', " + f"erwartet wurde '{self.erwarteter_geotyp}'." + ), aktion="falscher_geotyp", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -106,7 +112,7 @@ class Layerpruefer: ok=False, meldung="Der Layer enthält keine Objekte.", aktion="layer_leer", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -116,9 +122,12 @@ class Layerpruefer: if self.erwartetes_crs and crs != self.erwartetes_crs: return pruef_ergebnis( ok=False, - meldung=f"Der Layer hat das CRS '{crs}', erwartet wurde '{self.erwartetes_crs}'.", + meldung=( + f"Der Layer hat das CRS '{crs}', " + f"erwartet wurde '{self.erwartetes_crs}'." + ), aktion="falsches_crs", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -130,9 +139,12 @@ class Layerpruefer: if fehlende: return pruef_ergebnis( ok=False, - meldung=f"Der Layer enthält nicht alle erforderlichen Felder: {', '.join(fehlende)}", + meldung=( + "Der Layer enthält nicht alle erforderlichen Felder: " + + ", ".join(fehlende) + ), aktion="felder_fehlen", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -144,7 +156,7 @@ class Layerpruefer: ok=False, meldung=f"Die Datenquelle '{quelle}' ist nicht erlaubt.", aktion="datenquelle_unerwartet", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -156,7 +168,7 @@ class Layerpruefer: ok=False, meldung="Der Layer ist nicht editierbar.", aktion="layer_nicht_editierbar", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -166,5 +178,5 @@ class Layerpruefer: ok=True, meldung="Layerprüfung erfolgreich.", aktion="ok", - pfad=None, + kontext=None, ) diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index 0b15889..6f59306 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -1,20 +1,17 @@ """ sn_basis/modules/linkpruefer.py – Prüfung von URLs und lokalen Links. -Verwendet syswrapper und qgisqt_wrapper. -Gibt pruef_ergebnis an den Pruefmanager zurück. +Verwendet Wrapper und gibt pruef_ergebnis an den Pruefmanager zurück. """ -from sn_basis.functions.syswrapper import ( - file_exists, - is_file, - join_path, -) +from pathlib import Path -from sn_basis.functions.qgisqt_wrapper import ( +from sn_basis.functions import ( + file_exists, + join_path, network_head, ) -from sn_basis.modules.Pruefmanager import pruef_ergebnis +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion class Linkpruefer: @@ -33,14 +30,18 @@ class Linkpruefer: # Hilfsfunktionen # --------------------------------------------------------- - def _pfad(self, relativer_pfad: str) -> str: - """Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis.""" + def _pfad(self, relativer_pfad: str) -> Path: + """ + Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis. + """ if not self.basis: - return relativer_pfad + return Path(relativer_pfad) return join_path(self.basis, relativer_pfad) def _ist_url(self, text: str) -> bool: - """Einfache URL-Erkennung.""" + """ + Einfache URL-Erkennung. + """ return text.startswith("http://") or text.startswith("https://") # --------------------------------------------------------- @@ -58,7 +59,7 @@ class Linkpruefer: ok=False, meldung="Es wurde kein Link angegeben.", aktion="leer", - pfad=None, + kontext=None, ) # ----------------------------------------------------- @@ -88,7 +89,7 @@ class Linkpruefer: ok=False, meldung=f"Die URL '{url}' konnte nicht geprüft werden.", aktion="netzwerkfehler", - pfad=url, + kontext=url, ) if reply.error != 0: @@ -96,14 +97,14 @@ class Linkpruefer: ok=False, meldung=f"Die URL '{url}' ist nicht erreichbar.", aktion="url_nicht_erreichbar", - pfad=url, + kontext=url, ) return pruef_ergebnis( ok=True, meldung="URL ist erreichbar.", aktion="ok", - pfad=url, + kontext=url, ) # --------------------------------------------------------- @@ -122,20 +123,12 @@ class Linkpruefer: ok=False, meldung=f"Der Pfad '{eingabe}' wurde nicht gefunden.", aktion="pfad_nicht_gefunden", - pfad=pfad, - ) - - if not is_file(pfad): - return pruef_ergebnis( - ok=False, - meldung=f"Der Pfad '{eingabe}' ist keine Datei.", - aktion="kein_dateipfad", - pfad=pfad, + kontext=pfad, ) return pruef_ergebnis( ok=True, meldung="Dateipfad ist gültig.", aktion="ok", - pfad=pfad, + kontext=pfad, ) diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index e15d8ad..084f314 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,10 +1,10 @@ """ sn_basis/modules/pruef_ergebnis.py – Ergebnisobjekt für alle Prüfer. - """ from dataclasses import dataclass -from typing import Optional, Literal +from pathlib import Path +from typing import Any, Optional, Literal # Alle möglichen Aktionen, die ein Prüfer auslösen kann. @@ -31,28 +31,19 @@ PruefAktion = Literal[ "temporaer_erzeugen", "stil_nicht_anwendbar", "layer_unsichtbar", + "layer_nicht_gefunden", "unbekannt", + "stil_anwendbar", + "falsche_endung", ] -@dataclass + +@dataclass(slots=True) class pruef_ergebnis: - """ - Reines Datenobjekt, das das Ergebnis einer Prüfung beschreibt. - - ok: True → Prüfung erfolgreich - False → Nutzerinteraktion oder Fehler nötig - - meldung: Text, der dem Nutzer angezeigt werden soll - - aktion: Maschinenlesbarer Code, der dem Pruefmanager sagt, - wie er weiter verfahren soll - - pfad: Optionaler Pfad oder URL, die geprüft wurde oder - verwendet werden soll - """ - ok: bool meldung: str aktion: PruefAktion - pfad: Optional[str] = None + kontext: Optional[Any] = None + + diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py index 43734f2..db9312a 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -1,59 +1,75 @@ """ -sn_basis/modules/stilpruefer.py – Prüfung und Anwendung von Layerstilen. -Verwendet ausschließlich qgisqt_wrapper und gibt pruef_ergebnis zurück. +sn_basis/modules/stilpruefer.py – Prüfung von Layerstilen. +Prüft ausschließlich, ob ein Stilpfad gültig ist. +Die Anwendung erfolgt später über eine Aktion. """ -from sn_basis.functions.qgisqt_wrapper import ( - apply_style, -) +from pathlib import Path +from sn_basis.functions import file_exists from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class Stilpruefer: """ - Prüft, ob ein Stil auf einen Layer angewendet werden kann. - Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + Prüft, ob ein Stilpfad gültig ist und angewendet werden kann. + Keine Seiteneffekte, keine QGIS-Aufrufe. """ - def __init__(self, layer, stil_pfad: str): - """ - layer: QGIS-Layer oder Mock-Layer - stil_pfad: relativer oder absoluter Pfad zum .qml-Stil - """ - self.layer = layer - self.stil_pfad = stil_pfad + def __init__(self): + pass # --------------------------------------------------------- # Hauptfunktion # --------------------------------------------------------- - def pruefe(self) -> pruef_ergebnis: + def pruefe(self, stil_pfad: str) -> pruef_ergebnis: """ - Versucht, den Stil anzuwenden. + Prüft einen Stilpfad. Rückgabe: pruef_ergebnis """ - # Wrapper übernimmt: - # - Pfadberechnung - # - Existenzprüfung - # - loadNamedStyle - # - Fehlerbehandlung - # - Mock-Modus - erfolg, meldung = apply_style(self.layer, self.stil_pfad) - - if erfolg: + # ----------------------------------------------------- + # 1. Kein Stil angegeben → OK + # ----------------------------------------------------- + if not stil_pfad: return pruef_ergebnis( ok=True, - meldung=f"Stil erfolgreich angewendet: {self.stil_pfad}", + meldung="Kein Stil angegeben.", aktion="ok", - pfad=self.stil_pfad, + kontext=None, ) - # Fehlerfall → Nutzerinteraktion nötig + pfad = Path(stil_pfad) + + # ----------------------------------------------------- + # 2. Datei existiert nicht + # ----------------------------------------------------- + if not file_exists(pfad): + return pruef_ergebnis( + ok=False, + meldung=f"Die Stil-Datei '{stil_pfad}' wurde nicht gefunden.", + aktion="datei_nicht_gefunden", + kontext=pfad, + ) + + # ----------------------------------------------------- + # 3. Falsche Endung + # ----------------------------------------------------- + if pfad.suffix.lower() != ".qml": + return pruef_ergebnis( + ok=False, + meldung="Die Stil-Datei muss die Endung '.qml' haben.", + aktion="falsche_endung", + kontext=pfad, + ) + + # ----------------------------------------------------- + # 4. Stil ist gültig → Anwendung später + # ----------------------------------------------------- return pruef_ergebnis( - ok=False, - meldung=meldung, - aktion="stil_nicht_anwendbar", - pfad=self.stil_pfad, + ok=True, + meldung="Stil-Datei ist gültig.", + aktion="stil_anwendbar", + kontext=pfad, ) diff --git a/test/run_tests.py b/test/run_tests.py index 7c2b03a..7518bd5 100644 --- a/test/run_tests.py +++ b/test/run_tests.py @@ -11,18 +11,22 @@ import inspect import os import sys +from pathlib import Path +# --------------------------------------------------------- +# Pre-Bootstrap: Plugin-Root in sys.path eintragen +# --------------------------------------------------------- +THIS_FILE = Path(__file__).resolve() +PLUGIN_ROOT = THIS_FILE.parents[2] -# Minimaler Bootstrap, um sn_basis importierbar zu machen -TEST_DIR = os.path.dirname(__file__) -PLUGIN_ROOT = os.path.abspath(os.path.join(TEST_DIR, "..", "..")) +if str(PLUGIN_ROOT) not in sys.path: + sys.path.insert(0, str(PLUGIN_ROOT)) -if PLUGIN_ROOT not in sys.path: - sys.path.insert(0, PLUGIN_ROOT) - - -from sn_basis.functions import syswrapper +from sn_basis.functions import ( + get_plugin_root, + add_to_sys_path, +) # --------------------------------------------------------- # Bootstrap: Plugin-Root in sys.path eintragen @@ -33,13 +37,12 @@ def bootstrap(): Simuliert das QGIS-Plugin-Startverhalten: stellt sicher, dass sn_basis importierbar ist. """ - plugin_root = syswrapper.get_plugin_root() - syswrapper.add_to_sys_path(plugin_root) + plugin_root = get_plugin_root() + add_to_sys_path(plugin_root) bootstrap() - # --------------------------------------------------------- # Farben # --------------------------------------------------------- @@ -53,12 +56,14 @@ RESET = "\033[0m" GLOBAL_TEST_COUNTER = 0 - # --------------------------------------------------------- # Farbige TestResult-Klasse # --------------------------------------------------------- class ColoredTestResult(unittest.TextTestResult): + + _last_test_class: type | None = None + def startTest(self, test): global GLOBAL_TEST_COUNTER @@ -93,16 +98,19 @@ class ColoredTestResult(unittest.TextTestResult): super().addSuccess(test) self.stream.write(f"{GREEN}OK{RESET}\n") - # --------------------------------------------------------- # Farbiger TestRunner # --------------------------------------------------------- class ColoredTestRunner(unittest.TextTestRunner): - resultclass = ColoredTestResult def _makeResult(self): - result = super()._makeResult() + result = ColoredTestResult( + self.stream, + self.descriptions, + self.verbosity, + ) + original_start_test = result.startTest def patched_start_test(test): @@ -127,7 +135,7 @@ def main(): f"{datetime.datetime.now():%Y-%m-%d %H:%M:%S}{RESET}" ) print("=" * 70 + "\n") - + loader = unittest.TestLoader() suite = loader.discover( diff --git a/test/test_bootstrap.py b/test/test_bootstrap.py index 10db7cd..f87d84d 100644 --- a/test/test_bootstrap.py +++ b/test/test_bootstrap.py @@ -1,2 +1,2 @@ -from sn_basis.functions import syswrapper -syswrapper.add_to_sys_path(syswrapper.get_plugin_root()) +from sn_basis.functions import sys_wrapper +sys_wrapper.add_to_sys_path(sys_wrapper.get_plugin_root()) diff --git a/test/test_dateipruefer.py b/test/test_dateipruefer.py index d61ac70..84cd127 100644 --- a/test/test_dateipruefer.py +++ b/test/test_dateipruefer.py @@ -1,12 +1,10 @@ # sn_basis/test/test_dateipruefer.py import unittest +from pathlib import Path from unittest.mock import patch from sn_basis.modules.Dateipruefer import Dateipruefer -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis - - class TestDateipruefer(unittest.TestCase): @@ -24,6 +22,7 @@ class TestDateipruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "leereingabe_erlaubt") + self.assertIsNone(result.kontext) # ----------------------------------------------------- # 2. Leere Eingabe nicht erlaubt @@ -38,6 +37,7 @@ class TestDateipruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "leereingabe_nicht_erlaubt") + self.assertIsNone(result.kontext) # ----------------------------------------------------- # 3. Standarddatei vorschlagen @@ -52,7 +52,7 @@ class TestDateipruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "standarddatei_vorschlagen") - self.assertEqual(result.pfad, "/tmp/std.txt") + self.assertEqual(result.kontext, Path("/tmp/std.txt")) # ----------------------------------------------------- # 4. Temporäre Datei erlaubt @@ -67,11 +67,12 @@ class TestDateipruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "temporaer_erlaubt") + self.assertIsNone(result.kontext) # ----------------------------------------------------- # 5. Datei existiert nicht # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + @patch("sn_basis.modules.Dateipruefer.file_exists", return_value=False) def test_datei_nicht_gefunden(self, mock_exists): pruefer = Dateipruefer( pfad="/tmp/nichtvorhanden.txt" @@ -81,13 +82,13 @@ class TestDateipruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "datei_nicht_gefunden") + self.assertEqual(result.kontext, Path("/tmp/nichtvorhanden.txt")) # ----------------------------------------------------- # 6. Datei existiert # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) - @patch("sn_basis.functions.syswrapper.is_file", return_value=True) - def test_datei_ok(self, mock_isfile, mock_exists): + @patch("sn_basis.modules.Dateipruefer.file_exists", return_value=True) + def test_datei_ok(self, mock_exists): pruefer = Dateipruefer( pfad="/tmp/test.txt" ) @@ -96,7 +97,7 @@ class TestDateipruefer(unittest.TestCase): self.assertTrue(result.ok) self.assertEqual(result.aktion, "ok") - self.assertEqual(result.pfad, "/tmp/test.txt") + self.assertEqual(result.kontext, Path("/tmp/test.txt")) if __name__ == "__main__": diff --git a/test/test_layerpruefer.py b/test/test_layerpruefer.py index 46bde78..9bff1ad 100644 --- a/test/test_layerpruefer.py +++ b/test/test_layerpruefer.py @@ -78,18 +78,19 @@ def mock_is_layer_editable(layer): class TestLayerpruefer(unittest.TestCase): def setUp(self): - # Monkeypatching der Wrapper-Funktionen - import sn_basis.functions.qgisqt_wrapper as wrapper + # Monkeypatching der im Layerpruefer verwendeten Wrapper-Funktionen + import sn_basis.modules.layerpruefer as module + + module.layer_exists = mock_layer_exists + module.is_layer_visible = mock_is_layer_visible + module.get_layer_type = mock_get_layer_type + module.get_layer_geometry_type = mock_get_layer_geometry_type + module.get_layer_feature_count = mock_get_layer_feature_count + module.get_layer_crs = mock_get_layer_crs + module.get_layer_fields = mock_get_layer_fields + module.get_layer_source = mock_get_layer_source + module.is_layer_editable = mock_is_layer_editable - wrapper.layer_exists = mock_layer_exists - wrapper.is_layer_visible = mock_is_layer_visible - wrapper.get_layer_type = mock_get_layer_type - wrapper.get_layer_geometry_type = mock_get_layer_geometry_type - wrapper.get_layer_feature_count = mock_get_layer_feature_count - wrapper.get_layer_crs = mock_get_layer_crs - wrapper.get_layer_fields = mock_get_layer_fields - wrapper.get_layer_source = mock_get_layer_source - wrapper.is_layer_editable = mock_is_layer_editable # ----------------------------------------------------- # Tests diff --git a/test/test_linkpruefer.py b/test/test_linkpruefer.py index 89ea0e3..07c0fff 100644 --- a/test/test_linkpruefer.py +++ b/test/test_linkpruefer.py @@ -1,107 +1,78 @@ # sn_basis/test/test_linkpruefer.py import unittest +from pathlib import Path from unittest.mock import patch from sn_basis.modules.linkpruefer import Linkpruefer -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.functions.qgiscore_wrapper import NetworkReply -# --------------------------------------------------------- -# Mock-Ergebnisse für network_head() -# --------------------------------------------------------- - -class MockResponseOK: - ok = True - status = 200 - error = None - - -class MockResponseNotFound: - ok = False - status = 404 - error = "Not Found" - - -class MockResponseConnectionError: - ok = False - status = None - error = "Connection refused" - - -# --------------------------------------------------------- -# Testklasse -# --------------------------------------------------------- - class TestLinkpruefer(unittest.TestCase): # ----------------------------------------------------- # 1. Remote-Link erreichbar # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.network_head") + @patch("sn_basis.modules.linkpruefer.network_head") def test_remote_link_ok(self, mock_head): - mock_head.return_value = MockResponseOK() + mock_head.return_value = NetworkReply(error=0) - lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe() + lp = Linkpruefer() + result = lp.pruefe("http://example.com") self.assertTrue(result.ok) self.assertEqual(result.aktion, "ok") + self.assertEqual(result.kontext, "http://example.com") # ----------------------------------------------------- # 2. Remote-Link nicht erreichbar # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.network_head") + @patch("sn_basis.modules.linkpruefer.network_head") def test_remote_link_error(self, mock_head): - mock_head.return_value = MockResponseConnectionError() + mock_head.return_value = NetworkReply(error=1) - lp = Linkpruefer("http://example.com", "REST") - result = lp.pruefe() + lp = Linkpruefer() + result = lp.pruefe("http://example.com") self.assertFalse(result.ok) self.assertEqual(result.aktion, "url_nicht_erreichbar") - self.assertIn("Connection refused", result.meldung) + self.assertEqual(result.kontext, "http://example.com") # ----------------------------------------------------- - # 3. Remote-Link 404 + # 3. Netzwerkfehler (None) # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.network_head") - def test_remote_link_404(self, mock_head): - mock_head.return_value = MockResponseNotFound() - - lp = Linkpruefer("http://example.com/missing", "REST") - result = lp.pruefe() + @patch("sn_basis.modules.linkpruefer.network_head", return_value=None) + def test_remote_link_network_error(self, mock_head): + lp = Linkpruefer() + result = lp.pruefe("http://example.com") self.assertFalse(result.ok) - self.assertEqual(result.aktion, "url_nicht_erreichbar") - self.assertIn("404", result.meldung) + self.assertEqual(result.aktion, "netzwerkfehler") + self.assertEqual(result.kontext, "http://example.com") # ----------------------------------------------------- # 4. Lokaler Pfad existiert nicht # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists") + @patch("sn_basis.modules.linkpruefer.file_exists", return_value=False) def test_local_link_not_found(self, mock_exists): - mock_exists.return_value = False - - lp = Linkpruefer("/path/to/missing/file.shp", "OGR") - result = lp.pruefe() + lp = Linkpruefer() + result = lp.pruefe("/path/to/missing/file.shp") self.assertFalse(result.ok) self.assertEqual(result.aktion, "pfad_nicht_gefunden") + self.assertEqual(result.kontext, Path("/path/to/missing/file.shp")) # ----------------------------------------------------- - # 5. Lokaler Pfad existiert, aber ungewöhnlich + # 5. Lokaler Pfad existiert # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists") - def test_local_link_warning(self, mock_exists): - mock_exists.return_value = True - - lp = Linkpruefer("/path/to/file_without_extension", "OGR") - result = lp.pruefe() + @patch("sn_basis.modules.linkpruefer.file_exists", return_value=True) + def test_local_link_ok(self, mock_exists): + lp = Linkpruefer() + result = lp.pruefe("/path/to/file.shp") self.assertTrue(result.ok) self.assertEqual(result.aktion, "ok") - self.assertIn("ungewöhnlich", result.meldung) + self.assertEqual(result.kontext, Path("/path/to/file.shp")) if __name__ == "__main__": diff --git a/test/test_pruefmanager.py b/test/test_pruefmanager.py index 3a0cfe6..ef8d95b 100644 --- a/test/test_pruefmanager.py +++ b/test/test_pruefmanager.py @@ -25,7 +25,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 2. Leere Eingabe erlaubt → Nutzer sagt JA # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True) def test_leereingabe_erlaubt_ja(self, mock_ask): ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) entscheidung = self.manager.verarbeite(ergebnis) @@ -36,7 +36,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 3. Leere Eingabe erlaubt → Nutzer sagt NEIN # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False) def test_leereingabe_erlaubt_nein(self, mock_ask): ergebnis = pruef_ergebnis(False, "Leer?", "leereingabe_erlaubt", None) entscheidung = self.manager.verarbeite(ergebnis) @@ -47,21 +47,33 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 4. Standarddatei vorschlagen → Nutzer sagt JA # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True) def test_standarddatei_vorschlagen_ja(self, mock_ask): - ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + ergebnis = pruef_ergebnis( + False, + "Standarddatei verwenden?", + "standarddatei_vorschlagen", + "/tmp/std.txt", + ) + entscheidung = self.manager.verarbeite(ergebnis) self.assertTrue(entscheidung.ok) self.assertEqual(entscheidung.aktion, "ok") - self.assertEqual(entscheidung.pfad, "/tmp/std.txt") + self.assertEqual(entscheidung.kontext, "/tmp/std.txt") # ----------------------------------------------------- # 5. Standarddatei vorschlagen → Nutzer sagt NEIN # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False) def test_standarddatei_vorschlagen_nein(self, mock_ask): - ergebnis = pruef_ergebnis(False, "Standarddatei verwenden?", "standarddatei_vorschlagen", "/tmp/std.txt") + ergebnis = pruef_ergebnis( + False, + "Standarddatei verwenden?", + "standarddatei_vorschlagen", + "/tmp/std.txt", + ) + entscheidung = self.manager.verarbeite(ergebnis) self.assertFalse(entscheidung.ok) @@ -70,7 +82,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 6. Temporäre Datei erzeugen → Nutzer sagt JA # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True) def test_temporaer_erlaubt_ja(self, mock_ask): ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) entscheidung = self.manager.verarbeite(ergebnis) @@ -81,7 +93,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 7. Temporäre Datei erzeugen → Nutzer sagt NEIN # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False) def test_temporaer_erlaubt_nein(self, mock_ask): ergebnis = pruef_ergebnis(False, "Temporär?", "temporaer_erlaubt", None) entscheidung = self.manager.verarbeite(ergebnis) @@ -92,8 +104,8 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 8. Layer unsichtbar → Nutzer sagt JA # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=True) - @patch("sn_basis.functions.qgisqt_wrapper.set_layer_visible") + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=True) + @patch("sn_basis.modules.Pruefmanager.set_layer_visible") def test_layer_unsichtbar_ja(self, mock_set, mock_ask): fake_layer = object() ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) @@ -107,7 +119,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 9. Layer unsichtbar → Nutzer sagt NEIN # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.ask_yes_no", return_value=False) + @patch("sn_basis.modules.Pruefmanager.ask_yes_no", return_value=False) def test_layer_unsichtbar_nein(self, mock_ask): fake_layer = object() ergebnis = pruef_ergebnis(False, "Layer unsichtbar", "layer_unsichtbar", fake_layer) @@ -120,7 +132,7 @@ class TestPruefmanager(unittest.TestCase): # ----------------------------------------------------- # 10. Fehlerhafte Aktion → Fallback # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.warning") + @patch("sn_basis.modules.Pruefmanager.warning") def test_unbekannte_aktion(self, mock_warn): ergebnis = pruef_ergebnis(False, "???", "unbekannt", None) entscheidung = self.manager.verarbeite(ergebnis) diff --git a/test/test_settings_logic.py b/test/test_settings_logic.py index b360bb1..6296e9f 100644 --- a/test/test_settings_logic.py +++ b/test/test_settings_logic.py @@ -11,7 +11,7 @@ class TestSettingsLogic(unittest.TestCase): # ----------------------------------------------------- # Test: load() liest alle Variablen über get_variable() # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.get_variable") + @patch("sn_basis.functions.settings_logic.get_variable") def test_load(self, mock_get): # Mock-Rückgabe für jede Variable mock_get.side_effect = lambda key, scope="project": f"wert_{key}" @@ -30,7 +30,7 @@ class TestSettingsLogic(unittest.TestCase): # ----------------------------------------------------- # Test: save() ruft set_variable() nur für bekannte Keys auf # ----------------------------------------------------- - @patch("sn_basis.functions.qgisqt_wrapper.set_variable") + @patch("sn_basis.functions.settings_logic.set_variable") def test_save(self, mock_set): logic = SettingsLogic() diff --git a/test/test_stilpruefer.py b/test/test_stilpruefer.py index 28ec3e8..06c2fca 100644 --- a/test/test_stilpruefer.py +++ b/test/test_stilpruefer.py @@ -3,10 +3,10 @@ import unittest import tempfile import os +from pathlib import Path from unittest.mock import patch from sn_basis.modules.stilpruefer import Stilpruefer -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis class TestStilpruefer(unittest.TestCase): @@ -23,13 +23,13 @@ class TestStilpruefer(unittest.TestCase): self.assertTrue(result.ok) self.assertEqual(result.aktion, "ok") self.assertIn("Kein Stil angegeben", result.meldung) + self.assertIsNone(result.kontext) # ----------------------------------------------------- # 2. Datei existiert und ist .qml # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) - @patch("sn_basis.functions.syswrapper.is_file", return_value=True) - def test_datei_existiert_mit_qml(self, mock_isfile, mock_exists): + @patch("sn_basis.modules.stilpruefer.file_exists", return_value=True) + def test_datei_existiert_mit_qml(self, mock_exists): with tempfile.NamedTemporaryFile(suffix=".qml", delete=False) as tmp: tmp_path = tmp.name @@ -37,8 +37,8 @@ class TestStilpruefer(unittest.TestCase): result = self.pruefer.pruefe(tmp_path) self.assertTrue(result.ok) - self.assertEqual(result.aktion, "ok") - self.assertEqual(result.pfad, tmp_path) + self.assertEqual(result.aktion, "stil_anwendbar") + self.assertEqual(result.kontext, Path(tmp_path)) finally: os.remove(tmp_path) @@ -46,9 +46,8 @@ class TestStilpruefer(unittest.TestCase): # ----------------------------------------------------- # 3. Datei existiert, aber falsche Endung # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists", return_value=True) - @patch("sn_basis.functions.syswrapper.is_file", return_value=True) - def test_datei_existiert_falsche_endung(self, mock_isfile, mock_exists): + @patch("sn_basis.modules.stilpruefer.file_exists", return_value=True) + def test_datei_existiert_falsche_endung(self, mock_exists): with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: tmp_path = tmp.name @@ -58,6 +57,7 @@ class TestStilpruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "falsche_endung") self.assertIn(".qml", result.meldung) + self.assertEqual(result.kontext, Path(tmp_path)) finally: os.remove(tmp_path) @@ -65,7 +65,7 @@ class TestStilpruefer(unittest.TestCase): # ----------------------------------------------------- # 4. Datei existiert nicht # ----------------------------------------------------- - @patch("sn_basis.functions.syswrapper.file_exists", return_value=False) + @patch("sn_basis.modules.stilpruefer.file_exists", return_value=False) def test_datei_existiert_nicht(self, mock_exists): fake_path = "/tmp/nichtvorhanden.qml" @@ -74,6 +74,7 @@ class TestStilpruefer(unittest.TestCase): self.assertFalse(result.ok) self.assertEqual(result.aktion, "datei_nicht_gefunden") self.assertIn("nicht gefunden", result.meldung) + self.assertEqual(result.kontext, Path(fake_path)) if __name__ == "__main__": diff --git a/test/test_wrapper.py b/test/test_wrapper.py deleted file mode 100644 index f57b5bb..0000000 --- a/test/test_wrapper.py +++ /dev/null @@ -1,164 +0,0 @@ -# sn_basis/test/test_wrapper.py - -import unittest -import os -import tempfile - -# Wrapper importieren -import sn_basis.functions.syswrapper as syswrapper -import sn_basis.functions.qgisqt_wrapper as qgisqt - - -# --------------------------------------------------------- -# Mock-Layer für qgisqt_wrapper -# --------------------------------------------------------- -class MockLayer: - def __init__( - self, - exists=True, - visible=True, - layer_type="vector", - geometry_type="Polygon", - feature_count=10, - crs="EPSG:25833", - fields=None, - source="/tmp/test.shp", - editable=True, - ): - self.exists = exists - self.visible = visible - self.layer_type = layer_type - self.geometry_type = geometry_type - self.feature_count = feature_count - self.crs = crs - self.fields = fields or [] - self.source = source - self.editable = editable - - -# --------------------------------------------------------- -# Monkeypatching für qgisqt_wrapper -# --------------------------------------------------------- -def mock_layer_exists(layer): - return layer is not None and layer.exists - - -def mock_is_layer_visible(layer): - return layer.visible - - -def mock_get_layer_type(layer): - return layer.layer_type - - -def mock_get_layer_geometry_type(layer): - return layer.geometry_type - - -def mock_get_layer_feature_count(layer): - return layer.feature_count - - -def mock_get_layer_crs(layer): - return layer.crs - - -def mock_get_layer_fields(layer): - return layer.fields - - -def mock_get_layer_source(layer): - return layer.source - - -def mock_is_layer_editable(layer): - return layer.editable - - -# --------------------------------------------------------- -# Testklasse -# --------------------------------------------------------- -class TestWrapper(unittest.TestCase): - - def setUp(self): - # qgisqt_wrapper monkeypatchen - qgisqt.layer_exists = mock_layer_exists - qgisqt.is_layer_visible = mock_is_layer_visible - qgisqt.get_layer_type = mock_get_layer_type - qgisqt.get_layer_geometry_type = mock_get_layer_geometry_type - qgisqt.get_layer_feature_count = mock_get_layer_feature_count - qgisqt.get_layer_crs = mock_get_layer_crs - qgisqt.get_layer_fields = mock_get_layer_fields - qgisqt.get_layer_source = mock_get_layer_source - qgisqt.is_layer_editable = mock_is_layer_editable - - # ----------------------------------------------------- - # syswrapper Tests - # ----------------------------------------------------- - - def test_syswrapper_file_exists(self): - with tempfile.NamedTemporaryFile(delete=True) as tmp: - self.assertTrue(syswrapper.file_exists(tmp.name)) - self.assertFalse(syswrapper.file_exists("/path/does/not/exist")) - - def test_syswrapper_is_file(self): - with tempfile.NamedTemporaryFile(delete=True) as tmp: - self.assertTrue(syswrapper.is_file(tmp.name)) - self.assertFalse(syswrapper.is_file("/path/does/not/exist")) - - def test_syswrapper_join_path(self): - result = syswrapper.join_path("/tmp", "test.txt") - self.assertEqual(result, "/tmp/test.txt") - - # ----------------------------------------------------- - # qgisqt_wrapper Tests (Mock-Modus) - # ----------------------------------------------------- - - def test_qgisqt_layer_exists(self): - layer = MockLayer(exists=True) - self.assertTrue(qgisqt.layer_exists(layer)) - - layer = MockLayer(exists=False) - self.assertFalse(qgisqt.layer_exists(layer)) - - def test_qgisqt_layer_visible(self): - layer = MockLayer(visible=True) - self.assertTrue(qgisqt.is_layer_visible(layer)) - - layer = MockLayer(visible=False) - self.assertFalse(qgisqt.is_layer_visible(layer)) - - def test_qgisqt_layer_type(self): - layer = MockLayer(layer_type="vector") - self.assertEqual(qgisqt.get_layer_type(layer), "vector") - - def test_qgisqt_geometry_type(self): - layer = MockLayer(geometry_type="Polygon") - self.assertEqual(qgisqt.get_layer_geometry_type(layer), "Polygon") - - def test_qgisqt_feature_count(self): - layer = MockLayer(feature_count=12) - self.assertEqual(qgisqt.get_layer_feature_count(layer), 12) - - def test_qgisqt_crs(self): - layer = MockLayer(crs="EPSG:4326") - self.assertEqual(qgisqt.get_layer_crs(layer), "EPSG:4326") - - def test_qgisqt_fields(self): - layer = MockLayer(fields=["id", "name"]) - self.assertEqual(qgisqt.get_layer_fields(layer), ["id", "name"]) - - def test_qgisqt_source(self): - layer = MockLayer(source="/tmp/test.shp") - self.assertEqual(qgisqt.get_layer_source(layer), "/tmp/test.shp") - - def test_qgisqt_editable(self): - layer = MockLayer(editable=True) - self.assertTrue(qgisqt.is_layer_editable(layer)) - - layer = MockLayer(editable=False) - self.assertFalse(qgisqt.is_layer_editable(layer)) - - -if __name__ == "__main__": - unittest.main() diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py index 9ee75af..e0ce1f6 100644 --- a/ui/base_dockwidget.py +++ b/ui/base_dockwidget.py @@ -1,12 +1,17 @@ -# sn_basis/ui/base_dockwidget.py +""" +sn_basis/ui/base_dockwidget.py -from qgis.PyQt.QtWidgets import QDockWidget, QTabWidget -from sn_basis.functions.qgisqt_wrapper import warning, error +Basis-Dockwidget für alle LNO-Module. +""" + +from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget +from sn_basis.functions.message_wrapper import warning, error class BaseDockWidget(QDockWidget): """ Basis-Dockwidget für alle LNO-Module. + - Titel wird automatisch aus base_title + subtitle erzeugt - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt @@ -23,19 +28,15 @@ class BaseDockWidget(QDockWidget): # Titel setzen # ----------------------------------------------------- try: - title = self.base_title if not subtitle else f"{self.base_title} | {subtitle}" + title = ( + self.base_title + if not subtitle + else f"{self.base_title} | {subtitle}" + ) self.setWindowTitle(title) except Exception as e: warning("Titel konnte nicht gesetzt werden", str(e)) - # ----------------------------------------------------- - # Dock-Features - # ----------------------------------------------------- - try: - self.setFeatures(QDockWidget.DockWidgetFeature.DockWidgetClosable) - except Exception as e: - warning("Dock-Features konnten nicht gesetzt werden", str(e)) - # ----------------------------------------------------- # Tabs erzeugen # ----------------------------------------------------- @@ -45,15 +46,25 @@ class BaseDockWidget(QDockWidget): for tab_class in self.tabs: try: tab_instance = tab_class() - tab_title = getattr(tab_class, "tab_title", tab_class.__name__) + tab_title = getattr( + tab_class, + "tab_title", + tab_class.__name__, + ) tab_widget.addTab(tab_instance, tab_title) except Exception as e: - error("Tab konnte nicht geladen werden", f"{tab_class}: {e}") + error( + "Tab konnte nicht geladen werden", + f"{tab_class}: {e}", + ) self.setWidget(tab_widget) except Exception as e: - error("Tab-Widget konnte nicht initialisiert werden", str(e)) + error( + "Tab-Widget konnte nicht initialisiert werden", + str(e), + ) # --------------------------------------------------------- # Dock schließen @@ -68,6 +79,9 @@ class BaseDockWidget(QDockWidget): if self.action: self.action.setChecked(False) except Exception as e: - warning("Toolbar-Status konnte nicht zurückgesetzt werden", str(e)) + warning( + "Toolbar-Status konnte nicht zurückgesetzt werden", + str(e), + ) super().closeEvent(event) diff --git a/ui/dockmanager.py b/ui/dockmanager.py index 8830e60..e8f0393 100644 --- a/ui/dockmanager.py +++ b/ui/dockmanager.py @@ -1,53 +1,69 @@ -# sn_basis/ui/dockmanager.py +""" +sn_basis/ui/dockmanager.py -from qgis.PyQt.QtCore import Qt -from qgis.PyQt.QtWidgets import QDockWidget -from qgis.utils import iface +Verwaltet das Anzeigen und Ersetzen von DockWidgets. +Stellt sicher, dass immer nur ein sn_basis-Dock gleichzeitig sichtbar ist. +""" -from sn_basis.functions.qgisqt_wrapper import warning, error +from typing import Any + +from sn_basis.functions import ( + add_dock_widget, + remove_dock_widget, + find_dock_widgets, + warning, + error, +) class DockManager: """ Verwaltet das Anzeigen und Ersetzen von DockWidgets. - Stellt sicher, dass immer nur ein LNO-Dock gleichzeitig sichtbar ist. """ - default_area = Qt.DockWidgetArea.RightDockWidgetArea dock_prefix = "sn_dock_" @classmethod - def show(cls, dock_widget, area=None): + def show(cls, dock_widget: Any, area=None) -> None: """ Zeigt ein DockWidget an und entfernt vorher alle anderen - LNO-Docks (erkennbar am Prefix 'sn_dock_'). + sn_basis-Docks (erkennbar am Prefix 'sn_dock_'). """ + if dock_widget is None: error("Dock konnte nicht angezeigt werden", "Dock-Widget ist None.") return try: - area = area or cls.default_area - - # Prüfen, ob das Dock einen gültigen Namen hat + # Sicherstellen, dass das Dock einen Namen hat if not dock_widget.objectName(): dock_widget.setObjectName(f"{cls.dock_prefix}{id(dock_widget)}") - # Bestehende Plugin-Docks schließen + # Vorhandene Plugin-Docks entfernen try: - for widget in iface.mainWindow().findChildren(QDockWidget): - if widget is not dock_widget and widget.objectName().startswith(cls.dock_prefix): - iface.removeDockWidget(widget) + for widget in find_dock_widgets(): + if ( + widget is not dock_widget + and widget.objectName().startswith(cls.dock_prefix) + ): + remove_dock_widget(widget) widget.deleteLater() except Exception as e: - warning("Vorherige Docks konnten nicht entfernt werden", str(e)) + warning( + "Vorherige Docks konnten nicht entfernt werden", + str(e), + ) # Neues Dock anzeigen try: - iface.addDockWidget(area, dock_widget) + add_dock_widget(area, dock_widget) dock_widget.show() except Exception as e: - error("Dock konnte nicht angezeigt werden", str(e)) + error( + "Dock konnte nicht angezeigt werden", + str(e), + ) except Exception as e: error("DockManager-Fehler", str(e)) + diff --git a/ui/navigation.py b/ui/navigation.py index 36c786c..c621b7c 100644 --- a/ui/navigation.py +++ b/ui/navigation.py @@ -1,84 +1,115 @@ -#sn_basis/ui/navigation.py -from qgis.PyQt.QtWidgets import QAction, QMenu, QToolBar, QActionGroup +""" +sn_basis/ui/navigation.py + +Zentrale Navigation (Menü + Toolbar) für sn_basis. +""" + +from typing import Any, List, Tuple + +from sn_basis.functions.qt_wrapper import ( + QAction, + QMenu, + QToolBar, + QActionGroup, +) +from sn_basis.functions import ( + get_main_window, + add_toolbar, + remove_toolbar, + add_menu, + remove_menu, +) + class Navigation: - def __init__(self, iface): - self.iface = iface + def __init__(self): self.actions = [] - - # Menü und Toolbar einmalig anlegen - self.menu = QMenu("LNO Sachsen", iface.mainWindow()) - iface.mainWindow().menuBar().addMenu(self.menu) + self.menu = None + self.toolbar = None + self.plugin_group = None + - self.toolbar = QToolBar("LNO Sachsen") + + def init_ui(self): + print(">>> Navigation.init_ui() CALLED") + + main_window = get_main_window() + if not main_window: + return + + self.menu = QMenu("LNO Sachsen", main_window) + add_menu(self.menu) + + self.toolbar = QToolBar("LNO Sachsen", main_window) self.toolbar.setObjectName("LnoSachsenToolbar") - iface.addToolBar(self.toolbar) + add_toolbar(self.toolbar) - # Gruppe für exklusive Auswahl (nur ein Plugin aktiv) - self.plugin_group = QActionGroup(iface.mainWindow()) - self.plugin_group.setExclusive(True) + test_action = QAction("TEST ACTION", main_window) + self.menu.addAction(test_action) + self.toolbar.addAction(test_action) + + + + # ----------------------------------------------------- + # Actions + # ----------------------------------------------------- def add_action(self, text, callback, tooltip="", priority=100): - action = QAction(text, self.iface.mainWindow()) + if not self.plugin_group: + return None + + action = QAction(text, get_main_window()) action.setToolTip(tooltip) - action.setCheckable(True) # Button kann aktiv sein + action.setCheckable(True) action.triggered.connect(callback) - # Action in Gruppe aufnehmen self.plugin_group.addAction(action) - - # Action mit Priority speichern self.actions.append((priority, action)) return action - + def finalize_menu_and_toolbar(self): - # Sortieren nach Priority + if not self.menu or not self.toolbar: + return + self.actions.sort(key=lambda x: x[0]) - # Menüeinträge self.menu.clear() + self.toolbar.clear() + for _, action in self.actions: self.menu.addAction(action) - - # Toolbar-Einträge - self.toolbar.clear() - for _, action in self.actions: self.toolbar.addAction(action) def set_active_plugin(self, active_action): - # Alle zurücksetzen, dann aktives Plugin markieren for _, action in self.actions: action.setChecked(False) if active_action: active_action.setChecked(True) - def remove_all(self): - """Alles entfernen beim Entladen des Basisplugins""" - # Menü entfernen - if self.menu: - self.iface.mainWindow().menuBar().removeAction(self.menu.menuAction()) - self.menu = None - - # Toolbar entfernen - if self.toolbar: - self.iface.mainWindow().removeToolBar(self.toolbar) - self.toolbar = None - - # Actions zurücksetzen - self.actions.clear() - - # Gruppe leeren - self.plugin_group = None + # ----------------------------------------------------- + # Cleanup + # ----------------------------------------------------- def remove_action(self, action): - """Entfernt eine einzelne Action aus Menü und Toolbar""" if not action: return - # Menüeintrag entfernen + if self.menu: self.menu.removeAction(action) - # Toolbar-Eintrag entfernen if self.toolbar: self.toolbar.removeAction(action) - # Aus der internen Liste löschen + self.actions = [(p, a) for p, a in self.actions if a != action] + + def remove_all(self): + if self.menu: + remove_menu(self.menu) + self.menu = None + + if self.toolbar: + remove_toolbar(self.toolbar) + self.toolbar = None + + self.actions.clear() + self.plugin_group = None + diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py index ae164f3..e0ce1f6 100644 --- a/ui/tabs/settings_tab.py +++ b/ui/tabs/settings_tab.py @@ -1,129 +1,87 @@ -# sn_basis/ui/tabs/settings_tab.py +""" +sn_basis/ui/base_dockwidget.py -from sn_basis.functions.qgisqt_wrapper import ( - QWidget, QGridLayout, QLabel, QLineEdit, - QGroupBox, QVBoxLayout, QPushButton, - info, warning, error -) +Basis-Dockwidget für alle LNO-Module. +""" -from sn_basis.functions.settings_logic import SettingsLogic +from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget +from sn_basis.functions.message_wrapper import warning, error -class SettingsTab(QWidget): +class BaseDockWidget(QDockWidget): """ - Tab für benutzer- und projektspezifische Einstellungen. - Nutzt SettingsLogic für das Laden/Speichern und den Wrapper für Meldungen. + Basis-Dockwidget für alle LNO-Module. + + - Titel wird automatisch aus base_title + subtitle erzeugt + - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt + - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt """ - tab_title = "Projekteigenschaften" + base_title = "LNO Sachsen" + tabs = [] # Liste von Tab-Klassen + action = None # Referenz auf die Toolbar-Action - def __init__(self, parent=None): + def __init__(self, parent=None, subtitle=""): super().__init__(parent) - self.logic = SettingsLogic() - - main_layout = QVBoxLayout() # ----------------------------------------------------- - # Definition der Felder + # Titel setzen # ----------------------------------------------------- - self.user_fields = { - "amt": "Amt:", - "behoerde": "Behörde:", - "landkreis_user": "Landkreis:", - "sachgebiet": "Sachgebiet:" - } - - self.project_fields = { - "bezeichnung": "Bezeichnung:", - "verfahrensnummer": "Verfahrensnummer:", - "gemeinden": "Gemeinde(n):", - "landkreise_proj": "Landkreis(e):" - } - - # ----------------------------------------------------- - # Benutzer-Felder - # ----------------------------------------------------- - user_group = QGroupBox("Benutzerspezifische Festlegungen") - user_layout = QGridLayout() - self.user_inputs = {} - - for row, (key, label) in enumerate(self.user_fields.items()): - line_edit = QLineEdit() - self.user_inputs[key] = line_edit - user_layout.addWidget(QLabel(label), row, 0) - user_layout.addWidget(line_edit, row, 1) - - user_group.setLayout(user_layout) - - # ----------------------------------------------------- - # Projekt-Felder - # ----------------------------------------------------- - project_group = QGroupBox("Projektspezifische Festlegungen") - project_layout = QGridLayout() - self.project_inputs = {} - - for row, (key, label) in enumerate(self.project_fields.items()): - line_edit = QLineEdit() - self.project_inputs[key] = line_edit - project_layout.addWidget(QLabel(label), row, 0) - project_layout.addWidget(line_edit, row, 1) - - project_group.setLayout(project_layout) - - # ----------------------------------------------------- - # Speichern-Button - # ----------------------------------------------------- - save_button = QPushButton("Speichern") - save_button.clicked.connect(self.save_data) - - # ----------------------------------------------------- - # Layout zusammenfügen - # ----------------------------------------------------- - main_layout.addWidget(user_group) - main_layout.addWidget(project_group) - main_layout.addStretch() - main_layout.addWidget(save_button) - - self.setLayout(main_layout) - - # Daten laden - self.load_data() - - # --------------------------------------------------------- - # Speichern - # --------------------------------------------------------- - - def save_data(self): - """ - Speichert alle Eingaben über SettingsLogic. - Fehler werden über den Wrapper gemeldet. - """ try: - fields = { - key: widget.text() - for key, widget in {**self.user_inputs, **self.project_inputs}.items() - } + title = ( + self.base_title + if not subtitle + else f"{self.base_title} | {subtitle}" + ) + self.setWindowTitle(title) + except Exception as e: + warning("Titel konnte nicht gesetzt werden", str(e)) - self.logic.save(fields) - info("Gespeichert", "Die Einstellungen wurden erfolgreich gespeichert.") + # ----------------------------------------------------- + # Tabs erzeugen + # ----------------------------------------------------- + try: + tab_widget = QTabWidget() + + for tab_class in self.tabs: + try: + tab_instance = tab_class() + tab_title = getattr( + tab_class, + "tab_title", + tab_class.__name__, + ) + tab_widget.addTab(tab_instance, tab_title) + except Exception as e: + error( + "Tab konnte nicht geladen werden", + f"{tab_class}: {e}", + ) + + self.setWidget(tab_widget) except Exception as e: - error("Fehler beim Speichern", str(e)) + error( + "Tab-Widget konnte nicht initialisiert werden", + str(e), + ) # --------------------------------------------------------- - # Laden + # Dock schließen # --------------------------------------------------------- - def load_data(self): + def closeEvent(self, event): """ - Lädt gespeicherte Einstellungen und füllt die Felder. - Fehler werden über den Wrapper gemeldet. + Wird aufgerufen, wenn das Dock geschlossen wird. + Setzt die zugehörige Toolbar-Action zurück. """ try: - data = self.logic.load() - - for key, widget in {**self.user_inputs, **self.project_inputs}.items(): - widget.setText(data.get(key, "")) - + if self.action: + self.action.setChecked(False) except Exception as e: - warning("Einstellungen konnten nicht geladen werden", str(e)) + warning( + "Toolbar-Status konnte nicht zurückgesetzt werden", + str(e), + ) + + super().closeEvent(event) From b805f78f02ee361c280348fb142c9121ceb66916 Mon Sep 17 00:00:00 2001 From: daniel Date: Thu, 8 Jan 2026 17:13:51 +0100 Subject: [PATCH 06/11] =?UTF-8?q?Anpassung=20an=20den=20Wrappern=20f=C3=BC?= =?UTF-8?q?r=20sn=5Fplan41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/ly_existence_wrapper.py | 31 ++++-- functions/ly_geometry_wrapper.py | 62 ++++++----- functions/ly_metadata_wrapper.py | 93 ++++++++-------- functions/ly_style_wrapper.py | 2 +- functions/ly_visibility_wrapper.py | 37 ++++--- functions/qgiscore_wrapper.py | 17 +++ functions/qgisui_wrapper.py | 77 +++++++++++-- functions/qt_wrapper.py | 147 +++++++++++++++++++++++-- functions/variable_wrapper.py | 2 +- modules/Dateipruefer.py | 2 +- pyrightconfig.json | 3 + {test => tests}/__init__.py | 0 {test => tests}/run_tests.py | 0 {test => tests}/start_osgeo4w_qgis.bat | 0 {test => tests}/test_bootstrap.py | 0 {test => tests}/test_dateipruefer.py | 0 {test => tests}/test_layerpruefer.py | 0 {test => tests}/test_linkpruefer.py | 0 {test => tests}/test_pruefmanager.py | 0 {test => tests}/test_qgis.bat | 0 {test => tests}/test_settings_logic.py | 0 {test => tests}/test_stilpruefer.py | 0 ui/base_dockwidget.py | 24 ++++ ui/dockmanager.py | 24 +++- ui/navigation.py | 3 +- ui/tabs/settings_tab.py | 2 +- 26 files changed, 401 insertions(+), 125 deletions(-) create mode 100644 pyrightconfig.json rename {test => tests}/__init__.py (100%) rename {test => tests}/run_tests.py (100%) rename {test => tests}/start_osgeo4w_qgis.bat (100%) rename {test => tests}/test_bootstrap.py (100%) rename {test => tests}/test_dateipruefer.py (100%) rename {test => tests}/test_layerpruefer.py (100%) rename {test => tests}/test_linkpruefer.py (100%) rename {test => tests}/test_pruefmanager.py (100%) rename {test => tests}/test_qgis.bat (100%) rename {test => tests}/test_settings_logic.py (100%) rename {test => tests}/test_stilpruefer.py (100%) diff --git a/functions/ly_existence_wrapper.py b/functions/ly_existence_wrapper.py index d39e000..08ded40 100644 --- a/functions/ly_existence_wrapper.py +++ b/functions/ly_existence_wrapper.py @@ -1,20 +1,31 @@ # sn_basis/functions/ly_existence_wrapper.py def layer_exists(layer) -> bool: + """ + Prüft, ob ein Layer-Objekt existiert (nicht None). + """ + return layer is not None + + +def layer_is_valid(layer) -> bool: + """ + Prüft, ob ein Layer gültig ist (QGIS-konform). + """ if layer is None: return False - is_valid_flag = getattr(layer, "is_valid", None) - if is_valid_flag is not None: + is_valid = getattr(layer, "isValid", None) + if callable(is_valid): try: - return bool(is_valid_flag) + return bool(is_valid()) except Exception: return False - try: - is_valid = getattr(layer, "isValid", None) - if callable(is_valid): - return bool(is_valid()) - return True - except Exception: - return False + return False + + +def layer_is_usable(layer) -> bool: + """ + Prüft, ob ein Layer existiert und gültig ist. + """ + return layer_exists(layer) and layer_is_valid(layer) diff --git a/functions/ly_geometry_wrapper.py b/functions/ly_geometry_wrapper.py index 97c2b6a..03a33de 100644 --- a/functions/ly_geometry_wrapper.py +++ b/functions/ly_geometry_wrapper.py @@ -1,48 +1,57 @@ # sn_basis/functions/ly_geometry_wrapper.py -def get_layer_geometry_type(layer) -> str: - if layer is None: - return "None" +from typing import Optional - geometry_type = getattr(layer, "geometry_type", None) - if geometry_type is not None: - return str(geometry_type) + +GEOM_NONE = None +GEOM_POINT = "Point" +GEOM_LINE = "LineString" +GEOM_POLYGON = "Polygon" + + +def get_layer_geometry_type(layer) -> Optional[str]: + """ + Gibt den Geometrietyp eines Layers zurück. + + Rückgabewerte: + - "Point" + - "LineString" + - "Polygon" + - None (nicht räumlich / ungültig / unbekannt) + """ + if layer is None: + return None try: - if callable(getattr(layer, "isSpatial", None)) and not layer.isSpatial(): - return "None" + is_spatial = getattr(layer, "isSpatial", None) + if callable(is_spatial) and not is_spatial(): + return None gtype = getattr(layer, "geometryType", None) if callable(gtype): value = gtype() - if not isinstance(value, int): - return "None" - - return { - 0: "Point", - 1: "LineString", - 2: "Polygon", - }.get(value, "None") + if value == 0: + return GEOM_POINT + if value == 1: + return GEOM_LINE + if value == 2: + return GEOM_POLYGON except Exception: pass - return "None" - - + return None def get_layer_feature_count(layer) -> int: + """ + Gibt die Anzahl der Features eines Layers zurück. + """ if layer is None: return 0 - count = getattr(layer, "feature_count", None) - if count is not None: - if isinstance(count, int): - return count - return 0 - try: - if callable(getattr(layer, "isSpatial", None)) and not layer.isSpatial(): + is_spatial = getattr(layer, "isSpatial", None) + if callable(is_spatial) and not is_spatial(): return 0 fc = getattr(layer, "featureCount", None) @@ -54,4 +63,3 @@ def get_layer_feature_count(layer) -> int: pass return 0 - diff --git a/functions/ly_metadata_wrapper.py b/functions/ly_metadata_wrapper.py index 48c4d19..dde7a9c 100644 --- a/functions/ly_metadata_wrapper.py +++ b/functions/ly_metadata_wrapper.py @@ -1,35 +1,44 @@ -# layer/metadata.py +# sn_basis/functions/ly_metadata_wrapper.py -def get_layer_type(layer) -> str: +from typing import Optional, List + + +LAYER_TYPE_VECTOR = "vector" +LAYER_TYPE_TABLE = "table" + + +def get_layer_type(layer) -> Optional[str]: + """ + Gibt den Layer-Typ zurück. + + Rückgabewerte: + - "vector" + - "table" + - None (unbekannt / nicht bestimmbar) + """ if layer is None: - return "unknown" - - layer_type = getattr(layer, "layer_type", None) - if layer_type is not None: - return str(layer_type) + return None try: - if callable(getattr(layer, "isSpatial", None)): - return "vector" if layer.isSpatial() else "table" + is_spatial = getattr(layer, "isSpatial", None) + if callable(is_spatial): + return LAYER_TYPE_VECTOR if is_spatial() else LAYER_TYPE_TABLE except Exception: pass - return "unknown" + return None -def get_layer_crs(layer) -> str: +def get_layer_crs(layer) -> Optional[str]: + """ + Gibt das CRS als AuthID zurück (z. B. 'EPSG:25833'). + """ if layer is None: - return "None" - - crs = getattr(layer, "crs", None) - if crs is not None and not callable(crs): - if isinstance(crs, str): - return crs - return "None" + return None try: - crs_obj = layer.crs() - authid = getattr(crs_obj, "authid", None) + crs = layer.crs() + authid = getattr(crs, "authid", None) if callable(authid): value = authid() if isinstance(value, str): @@ -37,49 +46,47 @@ def get_layer_crs(layer) -> str: except Exception: pass - return "None" + return None - -def get_layer_fields(layer) -> list[str]: +def get_layer_fields(layer) -> List[str]: + """ + Gibt die Feldnamen eines Layers zurück. + """ if layer is None: return [] - fields = getattr(layer, "fields", None) - if fields is not None and not callable(fields): - return list(fields) - try: - f = layer.fields() - if callable(getattr(f, "names", None)): - return list(f.names()) - return list(f) + return list(layer.fields().names()) except Exception: return [] -def get_layer_source(layer) -> str: - if layer is None: - return "None" - source = getattr(layer, "source", None) - if source is not None and not callable(source): - return str(source) +def get_layer_source(layer) -> Optional[str]: + """ + Gibt die Datenquelle eines Layers zurück. + """ + if layer is None: + return None try: - return layer.source() or "None" + value = layer.source() + if isinstance(value, str) and value: + return value except Exception: - return "None" + pass + + return None def is_layer_editable(layer) -> bool: + """ + Prüft, ob ein Layer editierbar ist. + """ if layer is None: return False - editable = getattr(layer, "editable", None) - if editable is not None: - return bool(editable) - try: is_editable = getattr(layer, "isEditable", None) if callable(is_editable): diff --git a/functions/ly_style_wrapper.py b/functions/ly_style_wrapper.py index 71d48e6..3145532 100644 --- a/functions/ly_style_wrapper.py +++ b/functions/ly_style_wrapper.py @@ -1,4 +1,4 @@ -# layer/style.py +# sn_basis/functions/ly_style_wrapper.py from sn_basis.functions.ly_existence_wrapper import layer_exists from sn_basis.functions.sys_wrapper import ( diff --git a/functions/ly_visibility_wrapper.py b/functions/ly_visibility_wrapper.py index b37a4ce..ba375bc 100644 --- a/functions/ly_visibility_wrapper.py +++ b/functions/ly_visibility_wrapper.py @@ -1,17 +1,19 @@ # sn_basis/functions/ly_visibility_wrapper.py def is_layer_visible(layer) -> bool: + """ + Prüft, ob ein Layer im Layer-Tree sichtbar ist. + """ if layer is None: return False - visible = getattr(layer, "visible", None) - if visible is not None: - return bool(visible) - try: - is_visible = getattr(layer, "isVisible", None) - if callable(is_visible): - return bool(is_visible()) + node = getattr(layer, "treeLayer", None) + if callable(node): + tree_node = node() + is_visible = getattr(tree_node, "isVisible", None) + if callable(is_visible): + return bool(is_visible()) except Exception: pass @@ -19,21 +21,20 @@ def is_layer_visible(layer) -> bool: def set_layer_visible(layer, visible: bool) -> bool: + """ + Setzt die Sichtbarkeit eines Layers im Layer-Tree. + """ if layer is None: return False try: - if hasattr(layer, "visible"): - layer.visible = bool(visible) - return True - except Exception: - pass - - try: - node = getattr(layer, "treeLayer", lambda: None)() - if node and callable(getattr(node, "setItemVisibilityChecked", None)): - node.setItemVisibilityChecked(bool(visible)) - return True + node = getattr(layer, "treeLayer", None) + if callable(node): + tree_node = node() + setter = getattr(tree_node, "setItemVisibilityChecked", None) + if callable(setter): + setter(bool(visible)) + return True except Exception: pass diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py index 77e8ae3..a554e88 100644 --- a/functions/qgiscore_wrapper.py +++ b/functions/qgiscore_wrapper.py @@ -18,6 +18,7 @@ QgsProject: Type[Any] QgsVectorLayer: Type[Any] QgsNetworkAccessManager: Type[Any] Qgis: Type[Any] +QgsMapLayerProxyModel: Type[Any] QGIS_AVAILABLE = False @@ -31,12 +32,14 @@ try: QgsVectorLayer as _QgsVectorLayer, QgsNetworkAccessManager as _QgsNetworkAccessManager, Qgis as _Qgis, + QgsMapLayerProxyModel as _QgsMaplLayerProxyModel ) QgsProject = _QgsProject QgsVectorLayer = _QgsVectorLayer QgsNetworkAccessManager = _QgsNetworkAccessManager Qgis = _Qgis + QgsMapLayerProxyModel=_QgsMaplLayerProxyModel QGIS_AVAILABLE = True @@ -94,6 +97,20 @@ except Exception: Qgis = _MockQgis + class _MockQgsMapLayerProxyModel: + # Layer-Typen (entsprechen QGIS-Konstanten) + NoLayer = 0 + VectorLayer = 1 + RasterLayer = 2 + PluginLayer = 3 + MeshLayer = 4 + VectorTileLayer = 5 + PointCloudLayer = 6 + + def __init__(self, *args, **kwargs): + pass + + QgsMapLayerProxyModel = _MockQgsMapLayerProxyModel # --------------------------------------------------------- # Netzwerk diff --git a/functions/qgisui_wrapper.py b/functions/qgisui_wrapper.py index 56e5775..77b945d 100644 --- a/functions/qgisui_wrapper.py +++ b/functions/qgisui_wrapper.py @@ -2,7 +2,10 @@ sn_basis/functions/qgisui_wrapper.py – zentrale QGIS-UI-Abstraktion """ -from typing import Any, List +from __future__ import annotations + +from typing import Any, List, Type + from sn_basis.functions.qt_wrapper import QDockWidget @@ -10,18 +13,39 @@ from sn_basis.functions.qt_wrapper import QDockWidget iface: Any QGIS_UI_AVAILABLE = False +QgsFileWidget: Type[Any] +QgsMapLayerComboBox: Type[Any] + # --------------------------------------------------------- -# iface initialisieren (QGIS oder Mock) +# iface + QGIS-Widgets initialisieren (QGIS oder Mock) # --------------------------------------------------------- try: from qgis.utils import iface as _iface + from qgis.gui import ( + QgsFileWidget as _QgsFileWidget, + QgsMapLayerComboBox as _QgsMapLayerComboBox, + ) + iface = _iface + QgsFileWidget = _QgsFileWidget + QgsMapLayerComboBox = _QgsMapLayerComboBox QGIS_UI_AVAILABLE = True except Exception: - + QGIS_UI_AVAILABLE = False + + class _MockSignal: + def __init__(self): + self._callbacks: list[Any] = [] + + def connect(self, callback): + self._callbacks.append(callback) + + def emit(self, *args, **kwargs): + for cb in list(self._callbacks): + cb(*args, **kwargs) class _MockMessageBar: def pushMessage(self, title, text, level=0, duration=5): @@ -53,6 +77,48 @@ except Exception: iface = _MockIface() + class _MockQgsFileWidget: + GetFile = 0 + + def __init__(self, *args, **kwargs): + self._path = "" + self.fileChanged = _MockSignal() + + def setStorageMode(self, *args, **kwargs): + pass + + def setFilter(self, *args, **kwargs): + pass + + def setFilePath(self, path: str): + self._path = path + self.fileChanged.emit(path) + + def filePath(self) -> str: + return self._path + + class _MockQgsMapLayerComboBox: + def __init__(self, *args, **kwargs): + self.layerChanged = _MockSignal() + self._layer = None + self._count = 0 + + def setFilters(self, *args, **kwargs): + pass + + def setLayer(self, layer): + self._layer = layer + self.layerChanged.emit(layer) + + def count(self) -> int: + return self._count + + def setCurrentIndex(self, idx: int): + pass + + QgsFileWidget = _MockQgsFileWidget + QgsMapLayerComboBox = _MockQgsMapLayerComboBox + # --------------------------------------------------------- # Main Window @@ -108,8 +174,6 @@ def add_menu(menu): main_window.menuBar().addMenu(menu) - - def remove_menu(menu): main_window = iface.mainWindow() if not main_window: @@ -119,9 +183,6 @@ def remove_menu(menu): main_window.menuBar().removeAction(menu.menuAction()) - - - # --------------------------------------------------------- # Toolbar-Handling # --------------------------------------------------------- diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index 08de053..9e116dc 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -29,8 +29,9 @@ QMenu: Type[Any] QToolBar: Type[Any] QActionGroup: Type[Any] QTabWidget: type - - +QToolButton: Type[Any] +QSizePolicy: Type[Any] +Qt: Type[Any] YES: Optional[Any] = None NO: Optional[Any] = None @@ -65,6 +66,9 @@ try: QActionGroup as _QActionGroup,# type: ignore QDockWidget as _QDockWidget,# type: ignore QTabWidget as _QTabWidget,# type: ignore + QToolButton as _QToolButton,#type:ignore + QSizePolicy as _QSizePolicy,#type:ignore + ) @@ -73,6 +77,7 @@ try: QEventLoop as _QEventLoop,# type: ignore QUrl as _QUrl,# type: ignore QCoreApplication as _QCoreApplication,# type: ignore + Qt as _Qt#type:ignore ) from qgis.PyQt.QtNetwork import ( # type: ignore QNetworkRequest as _QNetworkRequest,# type: ignore @@ -86,6 +91,7 @@ try: QNetworkRequest = _QNetworkRequest QNetworkReply = _QNetworkReply QCoreApplication = _QCoreApplication + Qt=_Qt QDockWidget = _QDockWidget QWidget = _QWidget QGridLayout = _QGridLayout @@ -99,13 +105,37 @@ try: QToolBar = _QToolBar QActionGroup = _QActionGroup QTabWidget = _QTabWidget - - + QToolButton=_QToolButton + QSizePolicy=_QSizePolicy YES = QMessageBox.StandardButton.Yes NO = QMessageBox.StandardButton.No CANCEL = QMessageBox.StandardButton.Cancel ICON_QUESTION = QMessageBox.Icon.Question + # --------------------------------------------------------- + # Qt6 Enum-Aliase (vereinheitlicht) + # --------------------------------------------------------- + + ToolButtonTextBesideIcon = Qt.ToolButtonStyle.ToolButtonTextBesideIcon + ArrowDown = Qt.ArrowType.DownArrow + ArrowRight = Qt.ArrowType.RightArrow + # QSizePolicy Enum-Aliase (Qt6) + SizePolicyPreferred = QSizePolicy.Policy.Preferred + SizePolicyMaximum = QSizePolicy.Policy.Maximum + # --------------------------------------------------------- + # QDockWidget Feature-Aliase (Qt6) + # --------------------------------------------------------- + + DockWidgetMovable = QDockWidget.DockWidgetFeature.DockWidgetMovable + DockWidgetFloatable = QDockWidget.DockWidgetFeature.DockWidgetFloatable + DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable + # --------------------------------------------------------- + # Dock-Area-Aliase (Qt6) + # --------------------------------------------------------- + + DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea + DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea + @@ -134,12 +164,14 @@ except Exception: QActionGroup as _QActionGroup, QDockWidget as _QDockWidget, QTabWidget as _QTabWidget, - + QToolButton as _QToolButton, + QSizePolicy as _QSizePolicy, ) from PyQt5.QtCore import ( QEventLoop as _QEventLoop, QUrl as _QUrl, QCoreApplication as _QCoreApplication, + Qt as _Qt, ) from PyQt5.QtNetwork import ( QNetworkRequest as _QNetworkRequest, @@ -153,6 +185,7 @@ except Exception: QNetworkRequest = _QNetworkRequest QNetworkReply = _QNetworkReply QCoreApplication = _QCoreApplication + Qt=_Qt QDockWidget = _QDockWidget @@ -168,8 +201,8 @@ except Exception: QToolBar = _QToolBar QActionGroup = _QActionGroup QTabWidget = _QTabWidget - - + QToolButton=_QToolButton + QSizePolicy=_QSizePolicy YES = QMessageBox.Yes NO = QMessageBox.No @@ -177,6 +210,30 @@ except Exception: ICON_QUESTION = QMessageBox.Question QT_VERSION = 5 + # --------------------------------------------------------- + # Qt5 Enum-Aliase (vereinheitlicht) + # --------------------------------------------------------- + + ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon + ArrowDown = Qt.DownArrow + ArrowRight = Qt.RightArrow + # QSizePolicy Enum-Aliase (Qt5) + SizePolicyPreferred = QSizePolicy.Preferred + SizePolicyMaximum = QSizePolicy.Maximum + # --------------------------------------------------------- + # QDockWidget Feature-Aliase (Qt5) + # --------------------------------------------------------- + + DockWidgetMovable = QDockWidget.DockWidgetMovable + DockWidgetFloatable = QDockWidget.DockWidgetFloatable + DockWidgetClosable = QDockWidget.DockWidgetClosable + # --------------------------------------------------------- + # Dock-Area-Aliase (Qt5) + # --------------------------------------------------------- + + DockAreaLeft = Qt.LeftDockWidgetArea + DockAreaRight = Qt.RightDockWidgetArea + def exec_dialog(dialog: Any) -> Any: return dialog.exec_() @@ -257,15 +314,26 @@ except Exception: pass class _MockLayout: - def addWidget(self, *args, **kwargs): - pass + def __init__(self, *args, **kwargs): + self._widgets = [] - def addLayout(self, *args, **kwargs): + def addWidget(self, widget): + self._widgets.append(widget) + + def addLayout(self, layout): pass def addStretch(self, *args, **kwargs): pass + def setSpacing(self, *args, **kwargs): + pass + + def setContentsMargins(self, *args, **kwargs): + pass + + + class _MockLabel: def __init__(self, text: str = ""): self._text = text @@ -296,7 +364,18 @@ except Exception: pass QCoreApplication = _MockQCoreApplication + class _MockQt: + # ToolButtonStyle + ToolButtonTextBesideIcon = 0 + # ArrowType + ArrowDown = 1 + ArrowRight = 2 + + Qt=_MockQt + ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon + ArrowDown = Qt.ArrowDown + ArrowRight = Qt.ArrowRight class _MockQDockWidget(_MockWidget): def __init__(self, *args, **kwargs): @@ -380,6 +459,54 @@ except Exception: QToolBar = _MockToolBar QActionGroup = _MockActionGroup + class _MockToolButton(_MockWidget): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._checked = False + self.toggled = lambda *a, **k: None + + def setText(self, text: str) -> None: + pass + + def setCheckable(self, value: bool) -> None: + pass + + def setChecked(self, value: bool) -> None: + self._checked = value + + def setToolButtonStyle(self, *args, **kwargs): + pass + + def setArrowType(self, *args, **kwargs): + pass + + def setStyleSheet(self, *args, **kwargs): + pass + + QToolButton=_MockToolButton + + class _MockQSizePolicy: + # horizontale Policies + Fixed = 0 + Minimum = 1 + Maximum = 2 + Preferred = 3 + Expanding = 4 + MinimumExpanding = 5 + Ignored = 6 + + # vertikale Policies (Qt nutzt dieselben Werte) + def __init__(self, horizontal=None, vertical=None): + self.horizontal = horizontal + self.vertical = vertical + QSizePolicy=_MockQSizePolicy + SizePolicyPreferred = QSizePolicy.Preferred + SizePolicyMaximum = QSizePolicy.Maximum + DockWidgetMovable = 1 + DockWidgetFloatable = 2 + DockWidgetClosable = 4 + DockAreaLeft = 1 + DockAreaRight = 2 def exec_dialog(dialog: Any) -> Any: return YES diff --git a/functions/variable_wrapper.py b/functions/variable_wrapper.py index 416e864..d3f8a2d 100644 --- a/functions/variable_wrapper.py +++ b/functions/variable_wrapper.py @@ -1,5 +1,5 @@ """ -variable_wrapper.py – QGIS-Variablen-Abstraktion +sn_basis/functions/variable_wrapper.py – QGIS-Variablen-Abstraktion """ from typing import Any diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 5564654..cb4e6af 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -5,7 +5,7 @@ Verwendet sys_wrapper und gibt pruef_ergebnis an den Pruefmanager zurück. from pathlib import Path -from sn_basis.functions import ( +from sn_basis.functions.sys_wrapper import ( join_path, file_exists, ) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..17b70df --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,3 @@ +{ + "extraPaths": ["."] +} diff --git a/test/__init__.py b/tests/__init__.py similarity index 100% rename from test/__init__.py rename to tests/__init__.py diff --git a/test/run_tests.py b/tests/run_tests.py similarity index 100% rename from test/run_tests.py rename to tests/run_tests.py diff --git a/test/start_osgeo4w_qgis.bat b/tests/start_osgeo4w_qgis.bat similarity index 100% rename from test/start_osgeo4w_qgis.bat rename to tests/start_osgeo4w_qgis.bat diff --git a/test/test_bootstrap.py b/tests/test_bootstrap.py similarity index 100% rename from test/test_bootstrap.py rename to tests/test_bootstrap.py diff --git a/test/test_dateipruefer.py b/tests/test_dateipruefer.py similarity index 100% rename from test/test_dateipruefer.py rename to tests/test_dateipruefer.py diff --git a/test/test_layerpruefer.py b/tests/test_layerpruefer.py similarity index 100% rename from test/test_layerpruefer.py rename to tests/test_layerpruefer.py diff --git a/test/test_linkpruefer.py b/tests/test_linkpruefer.py similarity index 100% rename from test/test_linkpruefer.py rename to tests/test_linkpruefer.py diff --git a/test/test_pruefmanager.py b/tests/test_pruefmanager.py similarity index 100% rename from test/test_pruefmanager.py rename to tests/test_pruefmanager.py diff --git a/test/test_qgis.bat b/tests/test_qgis.bat similarity index 100% rename from test/test_qgis.bat rename to tests/test_qgis.bat diff --git a/test/test_settings_logic.py b/tests/test_settings_logic.py similarity index 100% rename from test/test_settings_logic.py rename to tests/test_settings_logic.py diff --git a/test/test_stilpruefer.py b/tests/test_stilpruefer.py similarity index 100% rename from test/test_stilpruefer.py rename to tests/test_stilpruefer.py diff --git a/ui/base_dockwidget.py b/ui/base_dockwidget.py index e0ce1f6..ededcc9 100644 --- a/ui/base_dockwidget.py +++ b/ui/base_dockwidget.py @@ -6,6 +6,17 @@ Basis-Dockwidget für alle LNO-Module. from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget from sn_basis.functions.message_wrapper import warning, error +from sn_basis.functions.qt_wrapper import ( + QDockWidget, + QTabWidget, + Qt, + DockWidgetMovable, + DockWidgetFloatable, + DockWidgetClosable, + DockAreaLeft, + DockAreaRight, +) + class BaseDockWidget(QDockWidget): @@ -23,6 +34,19 @@ class BaseDockWidget(QDockWidget): def __init__(self, parent=None, subtitle=""): super().__init__(parent) + # ----------------------------------------------------- + # Dock-Konfiguration (WICHTIG) + # ----------------------------------------------------- + self.setFeatures( + DockWidgetMovable + | DockWidgetFloatable + | DockWidgetClosable + ) + + self.setAllowedAreas( + DockAreaLeft + | DockAreaRight + ) # ----------------------------------------------------- # Titel setzen diff --git a/ui/dockmanager.py b/ui/dockmanager.py index e8f0393..bcd92fb 100644 --- a/ui/dockmanager.py +++ b/ui/dockmanager.py @@ -5,7 +5,7 @@ Verwaltet das Anzeigen und Ersetzen von DockWidgets. Stellt sicher, dass immer nur ein sn_basis-Dock gleichzeitig sichtbar ist. """ -from typing import Any +from typing import Any, Optional from sn_basis.functions import ( add_dock_widget, @@ -14,6 +14,9 @@ from sn_basis.functions import ( warning, error, ) +from sn_basis.functions.qt_wrapper import ( + DockAreaRight, +) class DockManager: @@ -24,22 +27,34 @@ class DockManager: dock_prefix = "sn_dock_" @classmethod - def show(cls, dock_widget: Any, area=None) -> None: + def show(cls, dock_widget: Any, area: Optional[Any] = None) -> None: """ Zeigt ein DockWidget an und entfernt vorher alle anderen sn_basis-Docks (erkennbar am Prefix 'sn_dock_'). """ + # ----------------------------------------------------- + # Default-Dock-Area (wrapper-konform) + # ----------------------------------------------------- + if area is None: + area = DockAreaRight + if dock_widget is None: error("Dock konnte nicht angezeigt werden", "Dock-Widget ist None.") return try: + # ------------------------------------------------- # Sicherstellen, dass das Dock einen Namen hat + # ------------------------------------------------- if not dock_widget.objectName(): - dock_widget.setObjectName(f"{cls.dock_prefix}{id(dock_widget)}") + dock_widget.setObjectName( + f"{cls.dock_prefix}{id(dock_widget)}" + ) + # ------------------------------------------------- # Vorhandene Plugin-Docks entfernen + # ------------------------------------------------- try: for widget in find_dock_widgets(): if ( @@ -54,7 +69,9 @@ class DockManager: str(e), ) + # ------------------------------------------------- # Neues Dock anzeigen + # ------------------------------------------------- try: add_dock_widget(area, dock_widget) dock_widget.show() @@ -66,4 +83,3 @@ class DockManager: except Exception as e: error("DockManager-Fehler", str(e)) - diff --git a/ui/navigation.py b/ui/navigation.py index c621b7c..d1d9a05 100644 --- a/ui/navigation.py +++ b/ui/navigation.py @@ -47,7 +47,8 @@ class Navigation: test_action = QAction("TEST ACTION", main_window) self.menu.addAction(test_action) self.toolbar.addAction(test_action) - + self.plugin_group = QActionGroup(main_window) + self.plugin_group.setExclusive(True) # ----------------------------------------------------- diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py index e0ce1f6..83a1571 100644 --- a/ui/tabs/settings_tab.py +++ b/ui/tabs/settings_tab.py @@ -8,7 +8,7 @@ from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget from sn_basis.functions.message_wrapper import warning, error -class BaseDockWidget(QDockWidget): +class SettingsTab(QDockWidget): """ Basis-Dockwidget für alle LNO-Module. From 039c6145922a27f94812303a4bbae5f6351a76b7 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 9 Jan 2026 15:19:25 +0100 Subject: [PATCH 07/11] =?UTF-8?q?Fix:=20beim=20Plugin-Reload=20werden=20ne?= =?UTF-8?q?ue=20Toolbars=20hinzugef=C3=BCgt=20aber=20keine=20gel=C3=B6scht?= =?UTF-8?q?=20Fix:=20Settings-Tab=20ist=20leer=20Dokumentation=20begonnen?= =?UTF-8?q?=20Pluginkonzept.md=20=C3=BCberarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/Objektstruktur.txt | 282 ++++++++++++++++++++++-------------- assets/Pluginkonzept.md | 156 +++++++++++++++++--- functions/dialog_wrapper.py | 29 +++- pyrightconfig.json | 3 - ui/navigation.py | 12 +- ui/tabs/settings_tab.py | 168 +++++++++++---------- 6 files changed, 443 insertions(+), 207 deletions(-) delete mode 100644 pyrightconfig.json diff --git a/assets/Objektstruktur.txt b/assets/Objektstruktur.txt index df3e392..0cf5e72 100644 --- a/assets/Objektstruktur.txt +++ b/assets/Objektstruktur.txt @@ -1,122 +1,188 @@ -+ PluginController - └─ GUIManager - └─ PrüfManager (koordiniert alle Prüfer) - ├─ Dateiprüfer - ├─ Linklistenprüfer - │ └─ Zeilenprüfer[n] - │ ├─ Linkprüfer - │ └─ Stilprüfer - └─ LayerLoader - └─ Logger +# Wrapper‑Architektur – Übersicht +Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung. +Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch: -Plan41_plugin/ -│ -├── plugin/ # Plugin-Code -│ ├── main_plugin.py # PluginController -│ ├── dock_widget.py # GUIManager -│ ├── pruefer/ -│ │ ├── dateipruefer.py -│ │ ├── linklistenpruefer.py -│ │ ├── zeilenpruefer.py -│ │ ├── linkpruefer.py -│ │ └── stilpruefer.py -│ ├── loader.py -│ └── logger.py -│ -├── tests/ # Unit-Tests -│ ├── __init__.py -│ ├── test_dateipruefer.py -│ ├── test_linklistenpruefer.py -│ ├── test_zeilenpruefer.py -│ ├── test_linkpruefer.py -│ ├── test_stilpruefer.py -│ ├── test_logger.py -│ └── run_tests.py # zentraler Test-Runner -│ -├── requirements.txt -└── README.md +Mock‑fähige Unit‑Tests ohne QGIS +PyQt5/6‑Kompatibilität ohne Code‑Änderungen -+------------------------------------+ -| PluginController | -+------------------------------------+ -| - Dock_widget: GUIManager | -| - pruef_manager: PruefManager | -| - loader: LayerLoader | -| - logger: Logger | -+------------------------------------+ -| + start(): void | -+------------------------------------+ +saubere Trennung von UI, Logik und Infrastruktur -+------------------------------------+ -| GUIManager | -+------------------------------------+ -| - dialog: QWidget | -+------------------------------------+ -| + getParameter(): dict | -+------------------------------------+ +stabile APIs, die unabhängig von QGIS‑Versionen bleiben -+------------------------------------+ -| PruefManager | -+------------------------------------+ -| - dateipruefer: Dateipruefer | -| - linklistenpruefer: Linklisten... | -+------------------------------------+ -| + pruefe_alle(parameter): list | -+------------------------------------+ +klare Erweiterbarkeit für zukünftige Module und Plugins -+------------------------------------+ -| Dateipruefer | -+------------------------------------+ -| + pruefe(pfad: str): PruefErgebnis | -+------------------------------------+ +Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung. -+------------------------------------+ -| Linklistenpruefer | -+------------------------------------+ -| + pruefe(pfad: str): list[Zeile] | -+------------------------------------+ +## Ziele der Wrapper‑Architektur +🎯 1. Entkopplung von QGIS und Qt +Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik. +Stattdessen werden sie über Wrapper‑Module abstrahiert. -+------------------------------------+ -| Zeilenpruefer | -+------------------------------------+ -| - linkpruefer: Linkpruefer | -| - stilpruefer: Stilpruefer | -+------------------------------------+ -| + pruefe(zeile: str): LayerAuftrag | -+------------------------------------+ +🎯 2. Testbarkeit ohne QGIS +Im Mock‑Modus liefern die Wrapper: -+------------------------------------+ -| Linkpruefer | -+------------------------------------+ -| + pruefe(link: str): PruefErgebnis | -+------------------------------------+ +Dummy‑Objekte -+------------------------------------+ -| Stilpruefer | -+------------------------------------+ -| + pruefe(stilname: str): Pruef... | -+------------------------------------+ +simulierte Rückgabewerte -+------------------------------------+ -| LayerLoader | -+------------------------------------+ -| + lade(layer_auftrag): void | -+------------------------------------+ +speicherbare Zustände (z. B. Variablen, Layer, Nachrichten) -+------------------------------------+ -| Logger | -+------------------------------------+ -| + schreibe(msg: str): void | -| + exportiere(): file | -+------------------------------------+ +Damit laufen Tests in jeder CI‑Umgebung. -+------------------------------------+ -| PruefErgebnis | -+------------------------------------+ -| - erfolgreich: bool | -| - daten: dict | -| - fehler: list[str] | -| - warnungen: list[str] | -+------------------------------------+ +🎯 3. Einheitliche API für alle Plugins +Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen: +Code +sn_basis.functions.qgiscore_wrapper +sn_basis.functions.qgisui_wrapper +sn_basis.functions.qt_wrapper +sn_basis.functions.variable_wrapper +sn_basis.functions.message_wrapper +sn_basis.functions.dialog_wrapper +🎯 4. Zukunftssicherheit +Ändert sich die QGIS‑ oder Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin. + +## Architekturüberblick +Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben. + +### 1. qt_wrapper – Qt‑Abstraktion +Kapselt alle Qt‑Widgets, Dialoge und Konstanten: + +QWidget, QDialog, QMessageBox, QToolBar, QMenu, … + +Layouts, Buttons, Labels, LineEdits + +Qt‑Konstanten wie YES, NO, Dock‑Areas + +Mock‑Modus: +Stellt Dummy‑Widgets bereit, die keine UI öffnen. + +### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion +Abstraktion für: + +QgsProject + +Layer‑Zugriff + +Projekt‑Metadaten + +Pfade, CRS, Feature‑Zugriff + +Mock‑Modus: +Simuliert ein Projekt und Layer‑Container. + +### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion +Kapselt UI‑bezogene QGIS‑Funktionen: + +Zugriff auf iface + +Dock‑Management + +Menü‑ und Toolbar‑Integration + +Hauptfenster‑Zugriff + +Mock‑Modus: +Stellt ein Dummy‑Interface bereit. + +### 4. variable_wrapper – QGIS‑Variablen +Abstraktion für: + +Projektvariablen (projectScope) + +globale Variablen (globalScope) + +Mock‑Speicher für Tests + +Vorteile: + +keine QGIS‑Abhängigkeit in der Logik + +testbare Variablenverwaltung + +einheitliches API + +### 5. message_wrapper – Meldungen & Logging +Einheitliche Schnittstelle für: + +Fehlermeldungen + +Warnungen + +Info‑Meldungen + +Logging + +Mock‑Modus: +Speichert Nachrichten statt sie an QGIS zu senden. + +### 6. dialog_wrapper – Benutzer‑Dialoge +Abstraktion für: + +Ja/Nein‑Dialoge + +spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.) + +Mock‑Modus: +Gibt Default‑Werte zurück, öffnet keine UI. + +### 7. DockManager & Navigation +Diese Module nutzen die Wrapper‑Schicht, um: + +DockWidgets sicher zu verwalten + +Toolbars und Menüs zu erzeugen + +Reload‑sichere UI‑Strukturen aufzubauen + +Sie sind keine Wrapper, sondern Wrapper‑Konsumenten. + +## Designprinzipien +🧱 1. Single Source of Truth +Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert. + +🔄 2. Austauschbarkeit +Mock‑Modus und Echtmodus sind vollständig austauschbar. + +🧪 3. Testbarkeit +Jede Funktion kann ohne QGIS getestet werden. + +🧼 4. Saubere Trennung +UI → qt_wrapper + +QGIS‑Core → qgiscore_wrapper + +QGIS‑UI → qgisui_wrapper + +Logik → settings_logic, layer_logic, prüfmanager, … + +🔌 5. Erweiterbarkeit +Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen. + +## Vorteile für Entwickler +Keine QGIS‑Abhängigkeiten in der Logik + +IDE‑freundlich (Pylance, Autocomplete, Typing) + +CI‑fähig (Tests ohne QGIS) + +saubere Architektur + +leichte Wartbarkeit + +klare Dokumentation + +## Fazit +Die Wrapper‑Architektur ist das Herzstück von sn_basis. +Sie ermöglicht eine moderne, modulare und testbare QGIS‑Plugin‑Entwicklung, die unabhängig von QGIS‑Versionen, Qt‑Versionen und Entwicklungsumgebungen funktioniert. + +Sie bildet die Grundlage für: + +stabile APIs + +saubere UI‑Abstraktion + +automatisierte Tests + +nachhaltige Weiterentwicklung \ No newline at end of file diff --git a/assets/Pluginkonzept.md b/assets/Pluginkonzept.md index 14f41dc..b00f2d4 100644 --- a/assets/Pluginkonzept.md +++ b/assets/Pluginkonzept.md @@ -1,22 +1,144 @@ -**Pluginkonzept** -Das Plugin ist grundsätzlich als modulares System gedacht. Komponenten sollen sowohl im Plugin selbst, aber auch in anderen Anwendungen verbaut werden können. -Die Module sind als Python-Objekte angelegt. -Alle Fallunterscheidungen, Exception-Management und Fehlerbehandlung sind in die "Prüfer" ausgelagert. -Der "Prüfmanager" übernimmt dabei die Interaktion mit dem Anwender, um Abfragen oder Fallunterscheidungen durchzuführen, die nicht anhand des Codes erfolgen können. -Alle Prüfer geben ein Objekt "Prüfergebnis" zurück, das das Ergebnis der Fallunterscheidung, Exceptions und Fehlermeldungen enthält. Die Prüfer haben selbst keine UI-Elemente. +# Wrapper‑Architektur – Übersicht +Die Wrapper‑Architektur von sn_basis bildet das Fundament für eine robuste, testbare und zukunftssichere QGIS‑Plugin‑Entwicklung. +Sie kapselt sämtliche QGIS‑ und Qt‑Abhängigkeiten hinter klar definierten Schnittstellen und ermöglicht dadurch: -| Modul | Aufgabe | Beschreibung | -|-------------------|---------------------------------------|--------------| -|PruefManager | Nutzerabfragen, Ergebnisanpassung | Der Pruefmanager wertet das Ergebnis vom Typ "PruefErgebnis" aus. Sind Entscheidungen erforderlich, fragt er den Anwender und passt das PruefErgebnis entsprechend an, bzw gibt Fehler aus| -|Dateipruefer | Auswertung der Eingaben in Dateiauswahlfeldern | Der Dateipruefer prüft die Eingaben in Dateifeldern. Dabei kann bei jeder Prüfung vorgegeben werden, ob leere Eingabefelder zulässig sind, und ob sie, wenn sie leer sind, eine Standarddatei aufrufen oder temporäre Layer erzeugen. In jedem Fall wird der Nutzer zur Entscheidung aufgefordert, ob das leere Feld beabsichtigt ist, oder ein Bedienfehler| -|Linklistenpruefer | Spezialprüfer für die Linkliste aus dem Plan41-Plugin | Damit die beiden Objekte Stilpruefer und Linkpruefer auch unabhängig voneinander verwendet werden können, fasst der Linklistenpruefer die Ergebnisse zusammen und ergänzt eine Prüfung zur Kartenreihenfolge/Layerreihenfolge| -|Linkpruefer | prüft die Quelle eines angegebenen Links technisch und entscheidet die technischen Parameter nach Typ und Quellort | Enthält eine Fallunterscheidung für lokale und remote-Quellen, sowie für unterschiedliche Datenanbieter. Der Linkpruefer gibt Fehler und Exceptions zurück, wenn die Quelle fehlerhaft oder nicht erreichbar ist.| -|Stilpruefer | Prüft verschiedene Stilquellen | Der Stilpruefer prüft .qml und eingebettete Stile und gibt Warnungen zurück, bzw. Exceptions, um Nutzerentscheidungen auszulösen| +- Mock‑fähige Unit‑Tests ohne QGIS +- PyQt5/6‑Kompatibilität ohne Code‑Änderungen +- saubere Trennung von UI, Logik und Infrastruktur +- stabile APIs, die unabhängig von QGIS‑Versionen bleiben +- klare Erweiterbarkeit für zukünftige Module und Plugins -Jedes Modul hat seinen eigenen Unittest. Die Tests werden im Unterordner "Test" zusammengefasst und können gesammelt über die "run_tests.py" aufgerufen werden. +Die Wrapper‑Schicht ist das zentrale Bindeglied zwischen der Plugin‑Logik und der QGIS‑/Qt‑Umgebung. + +## Ziele der Wrapper‑Architektur +1. Entkopplung von QGIS und Qt +Alle direkten Importe wie from qgis.core import ... oder from qgis.PyQt.QtWidgets import ... verschwinden aus der Plugin‑Logik. +Stattdessen werden sie über Wrapper‑Module abstrahiert. + +2. Testbarkeit ohne QGIS +Im Mock‑Modus liefern die Wrapper: + +- Dummy‑Objekte +- simulierte Rückgabewerte +- speicherbare Zustände (z. B. Variablen, Layer, Nachrichten) + +Damit laufen Tests in jeder CI‑Umgebung. + +3. Einheitliche API für alle Plugins +Plugins greifen nicht mehr direkt auf QGIS zu, sondern nutzen: + + +- sn_basis.functions.qgiscore_wrapper +- sn_basis.functions.qgisui_wrapper +- sn_basis.functions.qt_wrapper +- sn_basis.functions.variable_wrapper +- sn_basis.functions.message_wrapper +- sn_basis.functions.dialog_wrapper +Aufgrund des Umfangs ist der Wrapper für die Layerbehandlung aufgeteilt: +- ly_existence_wrapper +- ly_geometry_wrapper +- ly_Metadata_wrapper +- ly_style_wrapper +- ly_visibility_wrapper + +4. Zukunftssicherheit +Ändert sich die QGIS‑ oder Qt‑API, wird nur der Wrapper angepasst, nicht jedes Plugin. + +## Architekturüberblick +Die Wrapper‑Schicht besteht aus mehreren Modulen, die jeweils einen klar abgegrenzten Verantwortungsbereich haben. + +### 1. qt_wrapper – Qt‑Abstraktion +Kapselt alle Qt‑Widgets, Dialoge und Konstanten: + +- QWidget, QDialog, QMessageBox, QToolBar, QMenu, … +- Layouts, Buttons, Labels, LineEdits +- Qt‑Konstanten wie YES, NO, Dock‑Areas + +Mock‑Modus: +Stellt Dummy‑Widgets bereit, die keine UI öffnen. + +### 2. qgiscore_wrapper – QGIS‑Core‑Abstraktion +Abstraktion für: + +- QgsProject +- Layer‑Zugriff +- Projekt‑Metadaten +- Pfade, CRS, Feature‑Zugriff + +Mock‑Modus: +Simuliert ein Projekt und Layer‑Container. + +### 3. qgisui_wrapper – QGIS‑UI‑Abstraktion +Kapselt UI‑bezogene QGIS‑Funktionen: + +- Zugriff auf iface +- Dock‑Management +- Menü‑ und Toolbar‑Integration +- Hauptfenster‑Zugriff + +Mock‑Modus: +Stellt ein Dummy‑Interface bereit. + +### 4. variable_wrapper – QGIS‑Variablen +Abstraktion für: + +- Projektvariablen (projectScope) +- globale Variablen (globalScope) +- Mock‑Speicher für Tests + +Vorteile: + +- keine QGIS‑Abhängigkeit in der Logik +- testbare Variablenverwaltung +- einheitliches API + +### 5. message_wrapper – Meldungen & Logging +Einheitliche Schnittstelle für: + +- Fehlermeldungen +- Warnungen +- Info‑Meldungen +- Logging + +Mock‑Modus: +Speichert Nachrichten statt sie an QGIS zu senden. + +### 6. dialog_wrapper – Benutzer‑Dialoge +Abstraktion für: + +- Ja/Nein‑Dialoge +- spätere Erweiterungen (Eingabedialoge, Dateidialoge, etc.) + +Mock‑Modus: +Gibt Default‑Werte zurück, öffnet keine UI. + +### 7. DockManager & Navigation +Diese Module nutzen die Wrapper‑Schicht, um: + +- DockWidgets sicher zu verwalten +- Toolbars und Menüs zu erzeugen +- Reload‑sichere UI‑Strukturen aufzubauen + +Sie sind keine Wrapper, sondern Wrapper‑Konsumenten. Alle Fach-Plugins nutzen den Dockmanager des Basisplugins. + +## Designprinzipien +1. Single Source of Truth +Jede QGIS‑ oder Qt‑Funktionalität wird nur an einer Stelle implementiert. + +2. Austauschbarkeit +Mock‑Modus und Echtmodus sind vollständig austauschbar. + +3. Testbarkeit +Jede Funktion kann ohne QGIS getestet werden. + +4. Saubere Trennung +- UI → qt_wrapper +- QGIS‑Core → qgiscore_wrapper +- QGIS‑UI → qgisui_wrapper +- Logik → settings_logic, layer_logic, prüfmanager, … + +5. Erweiterbarkeit +Neue Wrapper können jederzeit ergänzt werden, ohne bestehende Plugins zu brechen. -Jedes Modul wird durch ein Mermaid-ClassDiagram beschrieben. Die Entscheidungen und Exceptions, sowie die behandelten Fehler werden visuell aufbereitet. -Zur Verarbeitung werden alle Nutzerinteraktionen und Angaben zunächst in den zuständigen Prüfer übergeben. Wenn vorhanden, mit den erforderlichen Parametern. Das Ergebnis wird zur Auswertung an den Pruefmanager übergeben. Dieser bereitet das Ergebnis auf, behandelt alle Exceptions und Anwenderentscheidungen und gibt die Daten mit den richtigen Parametern zur Weiterverarbeitung an die eigentliche Funktion. -Der Prüfmanager, die Stile und weitere, universelle Bausteine sind im Plugin sn_basis abgelegt und werden von dort in anderen Modulen verwendet. diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py index 43e7654..3ed9c41 100644 --- a/functions/dialog_wrapper.py +++ b/functions/dialog_wrapper.py @@ -1,9 +1,16 @@ """ sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge + +Dieser Wrapper kapselt alle Benutzer-Dialoge (z. B. Ja/Nein-Abfragen) +und sorgt dafür, dass sie sowohl in QGIS als auch im Mock-/Testmodus +einheitlich funktionieren. """ from typing import Any +# Import der abstrahierten Qt-Klassen aus dem qt_wrapper. +# QMessageBox, YES und NO sind bereits kompatibel zu Qt5/Qt6 +# und im Mock-Modus durch Dummy-Objekte ersetzt. from sn_basis.functions.qt_wrapper import ( QMessageBox, YES, @@ -22,20 +29,34 @@ def ask_yes_no( parent: Any = None, ) -> bool: """ - Fragt den Benutzer eine Ja/Nein-Frage. + Stellt dem Benutzer eine Ja/Nein-Frage. - - In Qt: zeigt einen QMessageBox-Dialog - - Im Mock-Modus: gibt den Default-Wert zurück + - In einer echten QGIS-Umgebung wird ein QMessageBox-Dialog angezeigt. + - Im Mock-/Testmodus wird kein Dialog geöffnet, sondern der Default-Wert + zurückgegeben, damit Tests ohne UI laufen können. + + :param title: Titel des Dialogs + :param message: Nachrichtentext + :param default: Rückgabewert im Fehler- oder Mock-Fall + :param parent: Optionales Parent-Widget + :return: True bei "Ja", False bei "Nein" """ try: + # Definiert die beiden Buttons, die angezeigt werden sollen. buttons = QMessageBox.Yes | QMessageBox.No + + # Öffnet den Dialog (oder im Mock-Modus: simuliert ihn). result = QMessageBox.question( parent, title, message, buttons, - YES if default else NO, + YES if default else NO, # Vorauswahl abhängig vom Default ) + + # Gibt True zurück, wenn der Benutzer "Ja" gewählt hat. return result == YES + except Exception: + # Falls Qt nicht verfügbar ist (Mock/CI), wird der Default-Wert genutzt. return default diff --git a/pyrightconfig.json b/pyrightconfig.json deleted file mode 100644 index 17b70df..0000000 --- a/pyrightconfig.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extraPaths": ["."] -} diff --git a/ui/navigation.py b/ui/navigation.py index d1d9a05..35bbf45 100644 --- a/ui/navigation.py +++ b/ui/navigation.py @@ -36,7 +36,17 @@ class Navigation: main_window = get_main_window() if not main_window: return - + # ----------------------------------------- + # Vorherige Toolbars entfernen + # ----------------------------------------- + for tb in main_window.findChildren(QToolBar): + if tb.objectName() == "LnoSachsenToolbar": + remove_toolbar(tb) + tb.deleteLater() + + # ----------------------------------------- + # Menü und Toolbar neu erzeugen + # ----------------------------------------- self.menu = QMenu("LNO Sachsen", main_window) add_menu(self.menu) diff --git a/ui/tabs/settings_tab.py b/ui/tabs/settings_tab.py index 83a1571..fdacd5e 100644 --- a/ui/tabs/settings_tab.py +++ b/ui/tabs/settings_tab.py @@ -1,87 +1,107 @@ -""" -sn_basis/ui/base_dockwidget.py - -Basis-Dockwidget für alle LNO-Module. -""" - -from sn_basis.functions.qt_wrapper import QDockWidget, QTabWidget -from sn_basis.functions.message_wrapper import warning, error +#sn_basis/ui/tabs/settings_tab.py +from sn_basis.functions.qt_wrapper import ( + QWidget, + QGridLayout, + QLabel, + QLineEdit, + QGroupBox, + QVBoxLayout, + QPushButton, +) +from sn_basis.functions.settings_logic import SettingsLogic -class SettingsTab(QDockWidget): - """ - Basis-Dockwidget für alle LNO-Module. +class SettingsTab(QWidget): + tab_title = "Projekteigenschaften" - - Titel wird automatisch aus base_title + subtitle erzeugt - - Tabs werden dynamisch aus der Klassenvariable 'tabs' erzeugt - - Die zugehörige Toolbar-Action wird beim Schließen zurückgesetzt - """ - - base_title = "LNO Sachsen" - tabs = [] # Liste von Tab-Klassen - action = None # Referenz auf die Toolbar-Action - - def __init__(self, parent=None, subtitle=""): + def __init__(self, parent=None): super().__init__(parent) + self.logic = SettingsLogic() - # ----------------------------------------------------- - # Titel setzen - # ----------------------------------------------------- - try: - title = ( - self.base_title - if not subtitle - else f"{self.base_title} | {subtitle}" - ) - self.setWindowTitle(title) - except Exception as e: - warning("Titel konnte nicht gesetzt werden", str(e)) + main_layout = QVBoxLayout() - # ----------------------------------------------------- - # Tabs erzeugen - # ----------------------------------------------------- - try: - tab_widget = QTabWidget() + # ----------------------------- + # Definition der Felder + # ----------------------------- + self.user_fields = { + "amt": "Amt:", + "behoerde": "Behörde:", + "landkreis_user": "Landkreis:", + "sachgebiet": "Sachgebiet:", + } - for tab_class in self.tabs: - try: - tab_instance = tab_class() - tab_title = getattr( - tab_class, - "tab_title", - tab_class.__name__, - ) - tab_widget.addTab(tab_instance, tab_title) - except Exception as e: - error( - "Tab konnte nicht geladen werden", - f"{tab_class}: {e}", - ) + self.project_fields = { + "bezeichnung": "Bezeichnung:", + "verfahrensnummer": "Verfahrensnummer:", + "gemeinden": "Gemeinde(n):", + "landkreise_proj": "Landkreis(e):", + } - self.setWidget(tab_widget) + # ----------------------------- + # Benutzerspezifische Festlegungen + # ----------------------------- + user_group = QGroupBox("Benutzerspezifische Festlegungen") + user_layout = QGridLayout() + self.user_inputs = {} - except Exception as e: - error( - "Tab-Widget konnte nicht initialisiert werden", - str(e), - ) + for row, (key, label) in enumerate(self.user_fields.items()): + input_widget = QLineEdit() + self.user_inputs[key] = input_widget + + user_layout.addWidget(QLabel(label), row, 0) + user_layout.addWidget(input_widget, row, 1) + + user_group.setLayout(user_layout) + + # ----------------------------- + # Projektspezifische Festlegungen + # ----------------------------- + project_group = QGroupBox("Projektspezifische Festlegungen") + project_layout = QGridLayout() + self.project_inputs = {} + + for row, (key, label) in enumerate(self.project_fields.items()): + input_widget = QLineEdit() + self.project_inputs[key] = input_widget + + project_layout.addWidget(QLabel(label), row, 0) + project_layout.addWidget(input_widget, row, 1) + + project_group.setLayout(project_layout) + + # ----------------------------- + # Speichern-Button + # ----------------------------- + save_button = QPushButton("Speichern") + save_button.clicked.connect(self.save_data) + + # ----------------------------- + # Layout zusammenfügen + # ----------------------------- + main_layout.addWidget(user_group) + main_layout.addWidget(project_group) + main_layout.addStretch() + main_layout.addWidget(save_button) + + self.setLayout(main_layout) + + # Daten laden + self.load_data() # --------------------------------------------------------- - # Dock schließen + # Speichern # --------------------------------------------------------- + def save_data(self): + fields = { + key: widget.text() + for key, widget in {**self.user_inputs, **self.project_inputs}.items() + } + self.logic.save(fields) - def closeEvent(self, event): - """ - Wird aufgerufen, wenn das Dock geschlossen wird. - Setzt die zugehörige Toolbar-Action zurück. - """ - try: - if self.action: - self.action.setChecked(False) - except Exception as e: - warning( - "Toolbar-Status konnte nicht zurückgesetzt werden", - str(e), - ) - - super().closeEvent(event) + # --------------------------------------------------------- + # Laden + # --------------------------------------------------------- + def load_data(self): + data = self.logic.load() + for key, widget in {**self.user_inputs, **self.project_inputs}.items(): + widget.setText(data.get(key, "")) From e6ffab1c10a03ab3265c8818a9b9d3c78d681e27 Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 13 Feb 2026 21:39:12 +0100 Subject: [PATCH 08/11] =?UTF-8?q?Angefangen,=20DataGrabber=20anzulegen=20(?= =?UTF-8?q?Grundlagen=20gelegt,=20noch=20nicht=20lauff=C3=A4hig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/datagrabber.jpeg | Bin 0 -> 102043 bytes assets/datagrabber.md | 38 +++++ assets/datagrabber.pdf | Bin 0 -> 32398 bytes functions/qt_wrapper.py | 68 +++++++- functions/test.md | 14 ++ modules/DataGrabber.py | 324 ++++++++++++++++++++++++++++++++++++++ modules/Dateipruefer.py | 4 +- modules/Pruefmanager.py | 282 +++++++++++++++++++-------------- modules/excel_importer.py | 91 +++++++++++ modules/layerpruefer.py | 4 +- modules/linkpruefer.py | 4 +- modules/pruef_ergebnis.py | 54 ++++--- 12 files changed, 733 insertions(+), 150 deletions(-) create mode 100644 assets/datagrabber.jpeg create mode 100644 assets/datagrabber.md create mode 100644 assets/datagrabber.pdf create mode 100644 functions/test.md create mode 100644 modules/DataGrabber.py create mode 100644 modules/excel_importer.py diff --git a/assets/datagrabber.jpeg b/assets/datagrabber.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..e38d84b899667b99d16f6076254e4a5a4b07cbfd GIT binary patch literal 102043 zcmeEuWpE|ClIAh9-DYNHW@d(rb9l#f9aVc>C2nYZG@_7LM?155AiHPVcDk+Fd$%_3W z0u&4I=|H~#05-PHj!F{3L>ih}M1O_-yE6nj+5bEK53ZklpO*gO4gk#4{|E8^ryv+( z6DQy&(aq=S?D+Zc&&(QsVl=aV!|s1!qkqGZe_=-zC6P~>+)qqp_BU+wH|*wY@BB$K z`WHRW-tjNI@rn6ttX=+k)?et=L$t~2}Ldn7!+SuAgB`P6lZ0Zs;{K*aNuNL$_yT3w_|JTqp06f@d&XB>70fK&GHC+3bcN zPt#n__xsaXUS~|p2g%;sgns~;9`kQZ6#hvC`4A!YHy+A=qx$bh{j-?=y8!?D3;gePW%94? zEwtgO!KEOx*7G;4*KgLpTR3mdbI#VJUydW;GW9JuXPDZpd#u(lVxQf2HTU4!{{e7Y z-KWG5e1C=?BOxLof!@gYT|(l`_Xp52;-{SX?tcFVU>5lTe2{_aV=EXy{Pi(9r+49vlJd$3Xlvf#B-?Xeo#HclSSl zhAD58N_f7*Cun#_lcLKzRQNIuw2?;0(QPj^K ze3*{}o)4ogO^6tZcW261+4Gn1N8>NZ=n#+07eC(Fhuscu!TD4q3}IE&5;ry0DAHT2 zYKNDIcJK~vD#lF?WvTg=I;+`a>dhKMyZf?h23+a~oG?viyf&;n3K{1ZM9^ilz4>|C zNmeS~0@{D&EQ2(6)pAGsrr3gurT*W!~9QJNFoyqe|f27_w8RZ>WY` zpmG6;#<%4>Ph=;=#S3lE_ET8%A4O0zrr(a=f0%2tyw@Vb2uW$hmXNC1kmF|h>Bewc z^?w7qqP_Lc4=q#UX;(kYU!_K{0o>Fcy9~N7GsQh!FzCD~KU7gq|3+F+J?v5z){9Lq zb1x4^G({;RLNIW^l9f2lwdG;*fNcv-^gcpV z4;u>`SXl=D0i4`vQ)Z_=ej&rBpg@lnI1G=cV+4VGb$Jkep>O-h!gwds2}go_t^7^$8sy^cz20qn z8hI=uBGueXTppE?ma>ExgeDx{^G4Le06&txxfj`#@{UQTLScia)m{j_o%V>J2ivh? z#xNza1cRvBvr#$^&iy>WIWzqU^$H^23OA$s?bn7wg2*GrjP9?m ze*m%?Ntja~efR2P`T~)X(ccH|mb(z%el@;@40|c`419Onco%GIc$@C&AI*pDT1njO zn{g&Sq*c&btm3hP^1)$iKBY>f(YYMI|z&;$ies)HaB$U1u4V#w*tGEk=%R z{bj8eICe;0bfH5^J*La{uAL>d_AtbZJpaDyS9^dEJD&J+{BfH{$*7(t+4WWQLB5S6a?^C#@q~c0=gvMjN{CRTPfyh za!**bVsdF`yGu(}A37aOs)5r^Ll2Af)AjVN6@LH)@3J#sSr9sLh`cl%!Qhq5npjrb zz*+U!X_7)tbW!=yrtuO>l?782=(b!J z2PzCIX;DuTjkIw>L@~KliF=TnD!pZ|bP3R(aTx+jOLqDxl(x-7CYd?xa{QQSSgL#$ zO7a?=mlj_gNAP1D-acphoI`cGSOXm291LzzIemD;QMZ`#@vhyGSrBK-R+;heAJMZ@ zXIxoomA@GwrlPuueNpzrjrR_Xok!hP6fk=g8(v%{d#4=B9=}H#Wz5_9{q>V+8hKaA zW>?A8hss-;pzm``%RT9I`x~xci0HjbvzOC6KlZfSR|I~0m88RY`Kx=IIo6g#b6xeV zgoQpoS*M*{-kxk}Bbj`-6Pi0ogJO=+Tf8M!Lmj@+%Imb!k^Nu%U)-1X1+fhGZ8gkV zhOwSkvpf0D2k742Z=dc4^t3q+YvAku00bNc=sWoK_opMjUrbNOG#w#v6n!{!QB{;2 z>D#{2!r2YHH(hLF-!m?n*I{jk{>pK&y4V`HYuWxW-6Z{1GV*(SYveoQ7S}Y4_P)59 zOPh*VUFM9K?Dv3sk>$%&%OIaev>!joC~LXxC{FKdf{d0;%k&+h!{0(Y468~;Ebf!v zO;qp(Q+Lh-3ybf*ke`0d^kB?oq))N&Q-pWwc&@0n(|a;_J+usyd5?Z%fm&BjxrQ*q zvCGHJfSNi8D5ltfj2@)GW2ZBlZ;~|Y5^E#VTcazMjF#V(0#w#Xo1gSc z^#vkBE)V?{tS`Bp3?LW1UD9={Mj^3XHaZhFI1zQ}#*U305^LXhcvQyc_neLirS=My z{~ldZ%OHKUowhE=Ut)bLU9FcwSgRfJUS%J*UYj7I{HxYleArb?9CAt9OX?T!`g43? ziH(O-`<%<(gDpcp7++noA5=0;G$nYCD(tEr*WGzB9>Fw=aX8S{GXfGAcHv3E=spR zHJr3x5gq;%cmk&Qwsjf=!cQv6&Yu7&j##C$(m8U?0pJyBAQfyCs?r&f48)vo^s;{{6 zUjb8~Lp-xKF@#Z(05y#f{b}gu!esf&`4@ zr<;?QHSZP z{q6qmY`%;2hAFRK#|_?-8LUWIg5SbrAKXoA_DgBVk0tz7G8ItR#kPMy4Y+NW@mHAX z%e^P6)%6397D0Yve!Kl~3e=`gBJ7pdcR=BaLxOB{BN zLfXsWLGk2b>nKMVP9(U}AL@cMLyZJ=7s)!6$zeeA+A+o9T5+-4L^G)2sYT(Jh=do1 zc;S{e2?cjR8==!507}QUy9wcLuKlmFHb})k07AtG#p>9!rfd81inJ2$)M}` zM&eNRNeZNEKi;7|zamjjiD_T)_B!*%KRY?4Ne|>gUmmd!iV5qe>$+FMJ~17mo^CK< zG#Lh2?<%XuIk7he+37M}@N2$A{Q=A)mc`@LaOYH-2f%^5g}(vH%R(C%Bdmg?tgxml z3RJ9{fJD5EFj`pxnYH=jddmjckcHYg=s7YZ6LdiCbw^LXk<>tDqsJEx1zy3LOY^y10(^FFxngUd-K z{2Nv8cC%_6hx$^SnuNZo?Mu|U#K5JPBk zg%!*W$&0tK)HjAzEhn9lJjJ(cJ#9@3h6c1r1OCn^lN$$IQQ0P_5e+jE)eL^8W*1Hl*|a+a%Rr z@~`ng2={MtGaSL+CaIrd_@_w9K=c}j9o%%Sr&VLi(tThjJLNEKn05`dhrO+cK#q!$ zTQxq+-EPOv#b35;muUT(Uqe=P>veka zYg-${rS4FaP5o*<{daR`gVcEdtKYTJB4wq`aaX)b(nU&#V2BGh?%f|i-=I{jAuY#} zB-fD1e8bZXW4D_F1`Z1?R|x;(}b;{qci^f^xYzziDo%sZ5aj86#fc7l;B9ph*h9Ei~p9` zcbFhFRlZp}5oY|8l*YkT*JWbpy-Z?Bx zM~Kl>ErU6*up)%eNB@RahrMobSnbk?p!@vY+ZVX(qeH|{F zE*VS(hNT~0-D+8pCQuOYGHk8BIv%LLO9J zdW}eBC3U`N@z%JeV=T=$-03-n*tE(<8(zkFvK4k}s@isnTZA2k=Zcc!It%Lg;!^Y& zeg%=0`ApC0^~CScb;u2BSn)EwB3#=Up(_%1Q+xZF#|>5-ulM6q-ab^=dAg! z@tBum8urcvo~P;CrJlmJVe`B?(01mk2*zqW%taRKuMlN=m)e0xRq1NF8UNwUO1JgxZ{xUdt2^)m1pM%JoO<6ONn({PYdd(49rd$vjltpiU0kz^Mwl{+x z7swXEd|Z~1oE>wRGke3e;vj0-l8O<{7}=5G=_%8HOHFsqX^f8R3_PjEQ?0fsbG96M`vC zUM<?;Bt~3f{7H*7+G4-5uodWL!*;b&Rhm6T1Ea>$LK%wxaBa>$xc(p$it zRRh+=H0>&Wk7%XAe65@E0doDuVNNi6Q4699W%SPidp;=`KWjVfZ|-5kZvLe`Ttr$i zZnf{zV=B5iPM9)kk-j8ySh_heNbaruEY%7_!HRlW1Qz=2nKXxSUE0Etz;bPAF5hG} z!)eD7cAto(0wHps35P1HF>`H$`%z@8jCF1p6f;)`*8A{fcfq*Zfz+t?Aa_+fNiR^5 zr=3}svp$$#I%as>7Wf8 zrO6kVjcqEac!i(3^1OLMS?LcSVFk>WRB)szHKQCoZ&PqgF}r*C`Stq={Q0z>!S71v zmJt|~$-#HNeQu7ZZqEJH6FZ94f=Tp#Tz`)vxLghwFic(S=EIFE3D?y$@5PHd5W0P` zqXQ{UD37w^np2vD(+a7dX7x->`o4DPwNLLE&wl7pOGD8J{)hP_h&Zp(SIG%gy;oig zwT}S*^t62a1PtdEIXBs;k%?r6%lj`=hh+WK)Y13toa)y_>1SJ#{OtS_sseFBTuN9` zb4Iua5Af4IdXA4GEZjX?A!*~`YivJbi1mI1>EkZHJ1xKe#0O=SNu1|l!a;0ocU5p_ zw1&nB*_^E}a9hNM(Ly-(E%bo95oc;%DKNb1QGdjDQdb>ltd;DLT;!G8{*;Bku z)U*-xtD^b&+xM1Efbxzjw5B(Q8qmU|()KOi6I^XOO_6WHG{DIQ&>aRl~?;O6^Pobu= zsV$3MB9TUnS4L;BYt=FM1Z|feRLw7lJ#-Aj47RjzpqAv^c+6N*k>2$>4}BvEk}R=F zIi~bH>H+lHxARv5d`2VohP3%IuA}uAh-#O_ALcEA^oTw&EgF$vOW<+=q#~oR2mO&{ zxZ;RQ0u36Qa-_8`AQ8AC$e!s~FU?LXJw+m&Epy{En^PFH+3F8n*d%3Q!UZ@X&bv)U z45ZCSK%&+!_dZH{o-TtrqJa6`O9z{ffHB=2nqgiqjn$%;Qa6~*gKv{&Z>YFuruY0yfGmr8}f%2YB_8fD=Ls_;oZLo{lFV& z{7ZURv-p5y)G%H8dYSt?<^#xxiov%0MmTGY>^a6V8Y$Obh10k6sD(Amv{uj_J3Qck z`R6h?vPzsFCm&(x3!2sgERmt%OXp4)7cGO{+4g&0g=GmcOfO2?Zu{HyNK zU{;#C-_buz&5wF9YFLfbQ0v7MdYSedXZw1{Y>BaBXINVZB2e>UkJgqVRHNujB{xT1 z|0syrT_NaYU0G9k9x=_28xeJ2+;{Fa!N#VYf2W>QQ8fpVUoPN4nzzhd;rpn;U!I8y0o4E(XrU(!`(_a5ZrYwUpcWgmst*7DvURLc6JncXx7m`i9GK**+eJIx z6y&I2u`Z#hL3V@DH0Y%kgo-iI7aph(Kbq0M_#1+OZ8z6Ek)ZV%d2Qa9EMX;z$8{g3 zTS?k!RQ(S?^7?nuN2cHl-eJy%G(>f9dCdTtgBi=M9fecef$v3I7t$14*H?Wik}Ij_ z$}oBtJtP>pZrmTNs2~h?2n&%iS-0}WJGeT|8jEOP&EG$~3uPSPPj}1<{S6v?i*(zx#K^g9XqLSP=3HnX@&@7I* zlO~OBPZZ?O?q`)c&*0LlVXE4^>TSlNagJ;0QXT3r9&)!!u1jrPCaWt&fK)s)rr=lN zt(zJV=o01@#b86xy9-2>h5MY}81!_cc&9bzOmC)Y09b@wS#80Ta-Ko-P zL+U6cM?FCvL(dCdnx8^U5UnSJSm9}w8aF}j2NW$dS-3s)S!%!S`_Vu4m@nZqqe<^c zc=(AWMHv}+6QOF&VdjWPotr_S5@!lY-5d)9;LH0j*_o}Khpl;Egd_DEZn zr~_{s==6?1xH`b;g zgLdjv=~07_&b|QF1)+Ei4`EvyHZFISHqXxo)}O^SM#lAavh@&_?xKf+y)VifWY*RJh69S?ufKk2{fy$Ml52vtOYKY-d0mI^{L;ASFxhM{ZV@1toMKhQjTk``P1#bqDWD4U zlE4?Y=J3Qa_Tp7b)+N-PJjXaRCH9J~rmGxz34=&L6sPmJ)QmVrCx{I#sD!wA4p1vf z3&((gQ{6JUYpHbp#j~5ds;)J9#McBsgPg$~l}U}Sk*#UGm+Q){2a(%H)-as383bUI zw2qPIg$0(;7w8D(*@ol1v{{XZ=4CFIxs?)9TSItZ2*~ z{U}>nb|U~^>FpwBHqwrhaG_s!oOK|7vY`-~Mz`OY0Vy+~WskNKVpvy-uM~+;aGiks0~fSpo|yV92ew+d zbZL|3d~$?A1F7X&5f;TgwV;J~6C-g1H*E$Z3`drw-`>8o=sO=F42+H>hq zg=gtfJ#{F4_r+8t;vE#Qjk2R^C1EvBG9BK@Eg&w{voG=>a$@TbfT#|k8zMnU9<1&J zMJ8zxj*!oFsds6#?m?y=N8lc%QtlS4FL;1M)#XT4dq4?eV`Zug3HL~%0uH|2Bf@&bFj^j9#d(+Kcmy)gj>mP}JJV zCT{B3zv(k5I_3A$M4|z(B8-oZ5vX-^Ug90HD*7Sl`9WDy9#MV49zb!crqM+p`PG4Z z<4217RV%RCHzQC(&%iG6!gp5B@W2eDhZBzJ4ot3*KEjscOzDfk9weRwmK7D_HLqvD zow}M_xX?E>(OX~yXydlMI!XdN#IkPK>f?c1>~FG$MT?;nU8rEq3}0h~fgO4*BB?!4 zn!<9M)}#a!8o8nknI9Y}*OKeSk0&&H7?q?}YV$7hUI3PL^CM$_%S#JP!?h&Cfmp3< zDo*ZY;o0FwYC17zIbW7Oaq7wm-BG^j|Kiijz`K|H& zb-msqDZK`+XqH-Nwh?1c1`~n7jME@nR?DG-T5?>;GXEE(=tz|OvrWAW7%_5 zZ*iI~G{TiVh~24QQYDMiMw@jP68pXhVHz2rR13-HCMh8k#cQS{jWfDJ53?pM-j-}Y zv(}!xSk))MvQ75Yjb3b8xbZSi@-9F#T_Ri9YOGrWu7>{ZWL)&F6Fvex!qzJc2@$|k zDk|ALXyN`I>m@hKB3QLGNk1^^XccYSH~qT-f_*$WM%mf$&=yzQZ=)p;tiH7UIIo=J zD|)PM2NOI_{|?kiuVRFX_!L&?B09cfYb;_2VhqhJCq2Yrt7{gc%O3#P$2)j(9({*M zj_ZVUvPyB|N#*mD08g zPGVEwV=vK0!`k5Y^@T*aUes}4q$}LUqZ>$)s&;{uX3cXS(+3K5vJ*dT&}i|ZU&4*( zVdHBgQo9?dS2S_AziJ=@*FE06$rzT+Aoc$FbCvCH5c}uJ^HzTag$JB`$s%kVk_T?V+9ETG5mKKXf zwLs)GM%YYhfHV8e1fS|56xjOoNU}oztGzE#GY$P5?pYGwFEWc`nyj#z_#zXUMpYl= z^uXv@tcNB;ou&gvMzV71&_w2pvoDT_9{ zG?H_U7DiMP-IA@lOD+^z?QFhez<=F?ke%JXUvztWsD^pZH^0J29yt%IIq*zqf z1#S;GGivCW3ZXo&qFr*?u}Dnss9OL$e#+H-;Z8)AW+=}<@tGylLMRE{Usd;I?8{o4 z6F9{Ga^p1dE9;VZj&b_iSb1Dtz;%^si~^bn-ii^CVt~B+bwf}@n%81Ew)Pk4)R4}j z7u2!yK_{iId*8}Rz-$aAU!oH;&g8{L=Y#i#fu~ghkD}W=Wa-}ec#enoGH=0oIY~Bd zaM%U!0U7idLjpdO8JsI?M@Z`v%Vr*ZD@vVDX0MjIZxO0t;FtXbdrUOhEi6fqpKEPq zg@^9;=_wQ}SaI#RInirLe8^QpR-3!KUbJw|Ec=yZ0RmaI!l`=AGuT@794(d7b+Qg8^M7MsL`Y(um<<2Cf0jeg)E)qrw& zt(6DY3k5-8x2DxXhq^8*&*7fWYX02m8^FXCm5?@Oe>%jQ@-29Uj+*VjvU-=5P4BK5 zwCvE@YCF=64>xCw#SSYxV{(%5cKuF5JO29#3@ers5hO!+Qh;$5e2PZIN<(kz_@n*w zj=(g#jtUg;%(Q`!B6PkV{$a`O+Xtm#@3)GUG zcI&%yqB<>b#?sZ6@++ZhF;#3UqfX(9YM_tTFz?wDo0)e%U{fD5*+w_-PT{l*@U)HW z_?OE&pRA+&bb|zO)j+?1o6LNNM*?m;C}>0?#I)Ddr^1_6@syHyHh_R z9Qa9kHUn)U#Wl0mwcA@P9m>noI;%6l?>ZvgK!=@Uz3WP?fDzbBdFo^{D>xDd^wm1F zP`tmAB?dTS;@9Sak#9mA#FY4kbdgqej<;naJ$H(qs{x+lRs6i*HgggE8)>y?GB`Nr zM|Co4Z~jr0q-GJTqfQT*nYk(#SwXuGm%hrRs-bd|BTZQ%%Nudd4nvrV5%*oU_RAkR zn;blvtXBG%avTs%4b>?H6q*>K(}q%y^H($DjkuPFU&o>{@Wi0JAb!$O+u~oY<2Pn}A#7 zct!|ekvq>Q=)KjLvk|MXy@iW0#1sRZ*WRRh%_D1izj;ZADen_rK&341RPuUe#=2qB zqmH7sc(9m<({AO9?ldd}fSYVdn#NO{N1o(c0ngTT{B^i#`mTn6Pf{uUIAfI4-LN;q zRswBqZ#C5%j=@+h^ABiIP<-$4`Ra$bga&rZkTnGBx_Kz99gz2!6h|DSeGX2dV_&amLKEGnU=bbk8I=M(B|6>x!@{WeOB9S36?0`grvXvVrx^8H;zhapf2@ zOhOP)VbVsRg;ejrO}iVX=UovDcWhOFv2+{5z54KG6c{Z}`(~${N!cm2RO$CBXuz2y zVm}2bSl|!P`@S$0c$G*dS_wrK@faeYG^FnMIlh$R=3-?C`^v&?7a^_GN=p)i3ITlF z5=3#)(2ROjIuq8!aBVBZwIK*GKC@yW!Ns`Bq?}np_IkvR_MBRamW!9)O(E2lHP?(8 zYrTQu%28bLF^5#J=P+salr!xWpl!EyrO8P}go-tc zC{G61ckCHpvEP2$C(`4d-0pulWBJQ!_j9@Mzv%sKdGbGJL-!AB--^fWrOc{{juD)P z7+I%Z0&UETR6Al_+fM52;&)kJJZhKOTb-nK$sm7DD!!2$-6XDJM^uvD#~Vg;HXhve zL?uHwoR^c^mBy%Sscud^R`FV-w>>;vH7f-y|Climhi1droYl+W=1;L!Li+k(+&H)t zW}_JC9Nr2S5*-^6mmIgDOK?P7D7)h#RFtICLt;!c_)dLloT|p5V&5TLN(runKBbT` zY~($LD)WAj6=XtoTj))$g=_79_cHIAP59!_qt=<*0oUZw-wsu79aS92VPJCb%ZkQZ zKf^M@r}9Z-iw#LC*)9Ps{P2d>(^SRl>jht8^T$5T8&O7#A86h>mew5;kHGXD;5H*5sZoA7LQaoY@35ISW{9sv_4` zq_kghE1D(-)vRw>EVRVN!{GZi(_O%XV`^yCN5imW<+^`UpI~cRaR+9p8d)8Br0AZ> zwj_L|;igS+2n6-6see8U(Q;9=aTxzd`N*klVm0up9)XTtbbg__eC>wIlWh#dgvUt# zR5LE!tcRbsIrS7_a^_gS9uOM~f|ehC4d@*0_Tkm85u6|Q`3CEITwFMOy*l+HAG)oO zf~eXSWr1e07e_g-U?;$Cs{_TUr|QIVP}gZmET1vgHGrq!Hm0qbIlK-% zwD5sQRQLj+6_h$!katd`)tFz5$u0hRNExyA72DNnz zxs&0ic2jNV5!n+nY@{JNk+M5Fv4qb2-e3hY^dP*srkxqJa2oG#dP?uxvYlYD#{gYf!kjO?k%q zEH@42r)C7K~ zEI^Sz01d>Cqt)BF@v|}F();7SjFJTsfLh6{^yn9x^7r8|p6URzz^KhIzUE;T!eD6s z+7*@9SeFHZA74mbyer;>9e+MDA7i@IHIIs`ahgB6@ouy0jFeWjxUVsTJ{B=AufoG@ z3S_lgZQ^we6F71l`_-RShBCdW&I&NmS7^G{zclbpo+JGn_?1DBa1KIkUNN@3IP22# z+Dg%A8Sn?7!bJiCH(J2m)|&2!CNsZObzUz8+Z1I1Rf2!y^7wN~XMeAHIVH6KdA|a^ zSR9P_5i8=TVjkzT$%ZyoOHaM1)U0HV&8ARjIT1skRQ;EnGiiqI(XF~Xc@Y=J&2f5F z3;5*Rlc{mODEpA?3G`QpxPyegAoy#$DBXg2#I@xtAihRsTt-f`n|?bQ@VyQZWMmmX-60{tqk z65`mVQ_7AxN%(nx0M`Xu+rO9o0A9W^)u8;>N0k3~NTPF}-p`+&Gw7MnkRkfJg$z;r zKXmWn2iwtse*jWhKFoTR|IyZx@Ct1;q1+MqJAMx+)*Ip{pXHux!{6=X)7{^b^u?#( z{{RLFm>sCGFXN**dHw(bx?-NETYjMsVr=6%?_m_F*@ho&{yJ~3cSL+_^Eg?`p~<8{ z;VaUdy-QfNAxFrB$Oui2=qb})AifYeVS&myp30-Bzs$C@-py?IZogjP0(Bi77govR z4%%GTCG!Vxa8OCkojhDuVD@_;a<@Bol}YVOA{->4&ysUhOKZexND-Odd!DY1Z+iTj zzMbu5+w*L4&mwhF)%@adQKh;}M{C(VT#XQn>KU%oS{Lz1AYvHyJ$GAxM%X30O)7|e z2!|DaUgSZLnugZtN##-v*dY#qjL_BimkS=66h_mGrmObXj9JbnT8}B2R_j%vX`9gT zy{MsT95yvbyDe73#HScvKW0+H%c?`RL7rcP;qH9{!`680l>!9<#RFS8IFA!3mPeR3 zib)Gq;uK}nTl7>lUHJI2bO|n&vLg%h?{|jBNmbOkvz_L7oz+!j1yE`N_)IjV(;mi+ zVCnaD9hw>J(>3}sKC8+V)f~xCqaAliJni^=<}4>G=Wt2SJgxmkKUcz9zAK>om9-DT zmj=x(I@|E*rM+3+tY~jXq!PJJLg{VdOct7)Zz42^gMz^?DaaN-Rc!LUdZ}~&iubO) z`~~8%ka$93p_9DQDXOlW!99>;2^x$m%}HXhzezV-*=Qa8LCWbAMr)(uhaM_B>=?v_ z-0YE_qq(ynsKPeK2KHuMQw;sEgQ968iL<{m_|J~8IYDOhoqPz4(~ufIVmS(101+3aPH%KQ?Ic zTP+3jWOVN|v0H~xc=5=TMjn2ejOrjMd5)>7Ez3gA#irdmrw$caX(cwfU?H!no=zLo zGF6h$QOg;tOW?61hC3-ONq8(8Uy4cMZX83#>PcH#UrKMatf^S9)yBL`-Pd~}b-iZx z-yT&zm5;n+|AYk~JN1-W`AI_tA{vf7Ha${e&%FPh`h6me>#;%^^i)Q+z%7c;ILpHp zo}#>8NCsN%du-G@&`H(0J(^vMo};!ws#=H}ZIl?(R8fJkQ0g+Pn~5~D^bXxinF^z3 zud6HF>j%^IY2KU5l~y>QA(VfJ|TZE)?z782NlAoLKA` z9unPpF1+M=iIi3| zGAKE>tzr^S)d7D(_ZRwwjjJ5o7)pZnrG>_^;)U?8TQ7W6l=!!WMFzet3>NmOO>fSL z0eNKp)noj6)~oy557l&?hXX?e^cJb7OG88}2Q=_#ImL$kZ)(4LP!91qqY zOKMQx-~&A|q-EqwydigsG!g4pg9S+Q*96VHw}|zKWtEQjZ>3R zs49omS`RC|2yr5s@#u$&wn?B23DQk83eVfc`MO#ICJ6WtvR-$3P#HbUmq40Py?xdcr)jgY` zK)A8q@N-PqgRLdHBK+>@e%ZDNKftE#GT_4uKLjnbQu1O%sUdpbW9x~_NG!tPJ-0bT zS{)e)c-X={&-B6h-FvA6>_=A{jqpyhFvCoB=83#zZNF6mP6M`YBsZVvRtfs8mnTQsq!i~S` z<`~J+74mX#1#=PklSUagDKfjc?n9g%9y~Pwe5}^>7qu+8%QRO~Epetq(>z?aptY9; zf3*n?_uOK^itWaE9?gEyOHXp{_5y@!&@MO6P9=d^!I|{cRt-&S`^OMvOf#hUT$nzF z%_1^`23Rfagsu-v_Gt5JJZ4W^KOTjXLTq>_)t*&OYk38YcbQ<-xwiy7V^AbF1zWWq z+#}wj-wywclR3`~-pQDm`4BDxHHqQ9*bJj;I^rr4RBicgiMVNu$)j|jPFxRfX!vf~ ztML1=%@Q_NUDUX~%I(G>tDY|To;_)wjkH|a=KG^VQeUj}vM?xmA9$6Pu1-fXDk|=X z>x5Ze>Xb!-0*_C+Wq;octzh2C*v>&`IvJfL-Q60<7dk5D>}A|$cv)xXKz|-1>vS`f z3GvZXtYKRdGMBO8qsi(EefIm7uO7>b_6~xIE$uJ>^Rf9v<&7$ELN-iwN^+Vp{)s5%k9pH%^gQLDlEz)enJt6Rq&%(JrRZGPwGaoroD7-h#p-qudIz;lw_=Y?*QXa;L^3Zu z;Sn5lr^WY!2biFv)U#*Oy{s=z=bHg>J*mx$Y#>pU7B)Bq7Sq;DyxPjGF)e!SHQ>saTMX^W1!{Dv zoAl?iaCo52W2#Q9;~8$v|CIz_E}=b--tn@S)JAcr%(bGj0(7IDq#y86PC+i!UQiI- z&AD4t@PP=QPf^;;ESoQRHCnh74?(Og{+u6c>&P-0cw@6`AT7)ukW|-l0}>zW%@?C0 z*E1`C+mDk#)_=R!xQ^+Y*L`g|WByG%b+;^f%sI5}+?gT@KXjT&mIu5fa!dL8P8*sU zc8PMIWM#1#g3Ro-GfG6^JSymKJ8vv|AR3KZZ$>umdJ}r)Egn~d^e6{-q?BSQT8qs< zk+=)&=DU%Q7_#GA6{Mv`Mp5OTcElILVMa3$e;$=I%n`Q;Kz3fEqVtISA&1i6F-y7J z`2eRw@X!^2wdZIlQGW3)gJCR$d=rU;=hL(xyu9tQP;$b{U7UA4x(B*cf?%vR$5Zrl z7C!PVOJRMG*ANby;HCRAKRc1U=*%;@owVkRf5-Z|g6j2@yk;zfj1CLGsLq+Knh`IeBPr9gBk2z*KQE8-qS{2H!95>oDK2?8U0>`*gSC|;50UPmP{7?YQ8KtdqT6CGyL ztvX?h$BS_7v|U#S#K*R>sd)}|F5zAo@sY7we@*0V@D?_-{LKyD1R{<2kuqi*YKLw7 zy#R*qIpBaA6ZJ+53(L!Ae40~k(9({yft$kpDxy@tx7ixECBO7MxS;x6^GqyANdbge zYg4oZO{i73b8m8i=M~r|hAGhrM-{WI)w;^Y^|P%~KJ(D|%t-4G0HcHB3V}5VXj7K4 zUsLr)5L8!6MN4Cu76BI62t##xCoIlBL+AT2OOeH<+vg-K*5489R57UL53}M=|H^mR zbO1(p`=omPFWNFEDhd&U{qC&!DvS9N0b~3(J1sI4;lHHT&wn7?&g7i3)*R6;#%kRC z%}(pzjKdsBTez}UPYD)|GBR;@cJ@8aGq0&@2T-`Fe&Vc8OG`YIpsXjRlDBD^5jX3+ zcF-Bh6AJzy+R#&WG34Xow^}|Ra=VeE0U|9s3yq`xvD=cf zlH!xxFRwm=%B|LN?!4ZEss8||YF^o2f%k4Pai9OgDVZgV{rLW5A$WUNvMl)8bMH3( zasNMbAI+Wci}hV2+ux6L)xAC}i&FV1MZlc1G92uHZ^_onx&-iTBW)8e`Poqnx~g7F9&4E4Z#&lHw|VN9Zu=GxThj`Wlzmvvd!710-IP z4VoK`cnCrc@#V7M*KsVzk`+}>eXykHo_P;jKoNO-gj zMZV8+qYIYt%Z6VZTupcY;#tw8u@km1t)Oi`bj%eKI#7rM5VJk9P=Sw|3 z^So-ji?b^gsTVJky{unw?=iSffycb{FTG6Oj4!#_JlY?efahYuk26QFPnIm!U?n8mctRX zHGyCyQb614r1mh`>Oe52a?rwbBXlDOw6}LkN?4tFL9e()mz(JK+)dK|v=LcIQH8ESVyr>O4ZypBfp!$T&r8|Csg%ud0dWJskZd%hSjIC-S z{oF-#o|%}|E^7^h=G;zvtzaep{!SKEmg!%5Iu3M;IK*@(>&SPxh^ zKI3s%dO9c`*L{G9%!Ik{hXs-!(!nGqb8fjEjQXX!uIehI8afDumooeMF;#!G-6unB zOhxu7#ko`-byOzZNUyGDxapMKVQc5Sctvk=kSBva$ ztX+v@(f<#K4JU8`CG7Gx6PIZcSQ6h1G+qoB%y~7O2to*k+?i#Cvo7-RNc*@(I4NY6 z^Wrb|T`T{pDm*u|&O-MaE9%5>%3Yj)%Yv>{wwH#6gaf^C_=MDcM=KWM6C8=Y-iUfb z@oY%->9{IZbuO$4{KTwlmzv6+ELt2N&`XK%<2`? zS*an8Fj`h23^!nQPg_Wf9>CK!NDQfUJA^ma$Hj4jG;}1bM=T{{kvf>Tmvc*bpMzY< zzaek`0sRAtI5+k}j+oS%=pZ))f19$QtkQ^-s{}ZfK^Z|m)2_~&jv5dKs@?>5Nim7K!ImQm5Plo1h>q482Ir`ahPBXiF3=qXG3+~SiP4iWbgNb&rYNH*_2+d zO&K>;+KfUQP8pwQ&I0R+g`zrhVul72+v*Cwl2=%QXu6v5%Jm<=fBxz&=X9NCemi=5 zUD}cjK%{!=8*_-U3dj4}uPCR$tKleO>K~BmNZ0Wc_l=a&64(+jIn|RSFv~UN7pM@@ z3Ssr)QYZ z-^Lf~X4Wtyndgy-E@vOqP8{~FHlE7MGhQzXcJxHu=t7FBr$aEk0g&{Q6}6B}d-|na zjE=YmHe@&_a4tL?x?AS7*0YNB4XYa8?6X zkcOJcDqRmeJjf)gX|w{E*m!Mqx(k~e2tcoeN}BWu-32d7c2TFk!!aF*uM`x#@Fbjv z-|t+=|A2N9{{bZj8BdhA(4v*S!^U>YDS;*i-Wz8e0#+|IYnH(^y$EVX%OZzJ1o4i@(YYE}?3y zI70$*98w+4lOI)^#;|7(l8CEf#C3_?KtV-bR}hLdit_zK(aWOXMu*6 zk-Tt0_<7PI4_-J;nTR-$TflZ4o|$h?k?qk?%M;dcrs!DPHn=~Mi`a!_xFr|E!0{+X zVbj&^EIVs_){{eUFxuwR3C*e7K6`tAC%;ONnf=c4i1p}UFq;2WkG$5k*ZTxi%@90B zhv@Ehwa@V)Zm3ha)WL=GgENoauFr!YBQ(0vP@o-Tt#)kE@Y}y79??(-1;G_HXQaUp zrY81}&!jCW4)+lXssr!MKr71cr7J;k(wM|G+@tIxT;DK(u1%c(x@{@&4b zq;avlF}G{67KRI)U=M=3-ePsDeKDm~y1o(IS|Y>IxLMAo`aa*OVIe+`m$}(^OUQedZzHVRt8z#n z;KQV+(}VJ^&3fW`BBwJ=nHT}#shUz!W(W*?iI#f1u*!rhNhdXMP#_%EMKRh@$SMn5 z=z4}qW`#(K-nmgFt~Km`4ITGip-^N0MS&C}3Hra+n27EF1qxOF{}3R5cE>4Sd>^^A z;g;~Yiye)(Rh09@E;SlU%1`n>;G^*c`^M2w#4M7VWX|84#H@rAup}(pDn=SWMtz8f z*rKG8%HRCN;z@&)I6GdgG@JI$dl{n5&OkEECY0)A$V=FVOsoE=Uy&5b4vp35IQ zfQ%{0|MTOV3~auQdP?q z>dVSh1QZ*F)(0~qC3;*SO0Xykx4T~b9)1I8jT4_sbC zSOB(4;FCF8^2q<=e|36|Ng*Nmc%eaU=qax)ZW3@%(#%q%?<|ki+@1h+nZ*W zb%r6IEnZ|H)1Mc%72f}gT%Z3&4EitmvH#bYpa0QR+kYFe|4*0y(b%oj+Cj^ColprH zUPku`(3#RWG)R%J$-r7UZ$)V~r2!P%mVA;x z&hf)rQ*Mhw04UyS@vK%+#$QNulC!0x*l;3(*3b`cfXrY!eTL5{Jh;KI)JTG!!lHxP zz$;7s24hT92iV<-66R_>ii&6;k#UP6d3bB%uWeoXAS+pwn$w4IhWI5pJx>r*xfn2# z66z!UBu4dd;pi3B6=X|Gf~*XA0&eaXt`+k~58cOpZM40Xj<~mVWWwY}k}l-nwo5 zPCfs0504ev{%I}A9j!~0YBrKXoF@zXjzKt@+*sWFsxk0;P+;I545L|$NHm*Znr&Vz zvdUf(hb96uS2BGWlX;V;W-}IOwe7R9=5RgnA#D&M$-)EBcyFg1H5)fR$tCwC2NxDc=<(GTcp%d# zESzi&zgU!KjL0nga+75(F|Z;$`F1p0M>G^`ZOUF46F3UDm!dHQOeqDoAHlSm$_KQb zn$MP5NW-0W>So$|%UKqfx;oct)oqFC&2>d6q>R{ z*K66=l1MW_RM9byC6nfyC{U-SE)!P3fGTnnXnvDSK?c&pbe_=0-(h0ZjXb>xby4fQ zvJ^jI^jga#Vn_JZh@YGe;)T|oZs*aoL3D(US<>~9m-=JqHfG;Y&i?=)*Xog0fI!(P zuui?y_`^=E!-EW=gSu9lozV?Kt^QB-`agK@KPi2=t3}~p-N5=l2u4>F6f2>X_LCTM zoA6USYod)0P4xQL(?nW;CQB7{X7K%vpwD!mBctr7OV7%Ltd)*~eErPElvqISq@C;E z2|uN$9AM$XLZ+;Gls)7;&YFiBST70ew)nXEZLD=aG_mDG3vbPg)Fko_rVZl|>uX1d z>#1sP;bYEcDfgwZZWgw053F>*m>*~-+m0t}U1f$V7DSPUX(nFqYD>{+I>jEgxsFd8 zmAJax_;{ThU=4bswCK0ig4N_3UF+($nc^W(=Zp`bzR==K z!#uSIq`vTE%FAA;PvOsVc2O&ZQ~60Op?@5jGlXaNW#Y+f8FIuk3w&BJc9-@#t#N|w z_3D}~`V35qn5vlZR{y1fEGVI6B2yESSYC$fB=mIj%r}cJjKIJEk~q}CT2DsvCoX&K z+3Jt3X(DmEKqrIgx|ak}nz*z~0!L!?;CF#g>P|9O z$#}%`MQx23aondS2Ii>e-(!4IYfEC$7a`5PJB=dY)12068HLnkxujaaP8!_Ur42B0b1d#Ge78?90+}#r8Z6_V< z8IYRC8{b2B(rbR5SeAP_+g796kiOz;LIEF!lM0zWLl==$@Q+5?$iM)ccBan}jX?m0 zewlY2Le%#?gL}&SO+ff=bLr6U%Rm>vmG?*YHO^to4RQfsI`EuPX{B2_lulMfo0pWh z3{I;0Elm{1B{&&4s#AfW&4gYq_9DiX28|kJXR32@yi&|=gbznTgSRgq`Ps47es06I z+2cO6SbEgY%KT6)T>w+QjBD(GBC~9w8a|C7JqjN=y16DV<1r$qcn5J(dXOb#-Ub&8 zKp51e*s{aI5N~p5XBg|&nse!?Nmbwj^h|yA56C|Xh9_sIby@<|iWPQ}8X|2M)mYW! zm-03!#tb9Z2#)Y}f9c&qKIREH?eeXTJKx)oq@6AG^wzpA66)i%$P6|8MSu!$alQS> zLeVNllogX&_L%W>eG90J3ridLBTQH)PBV1Hdf_X3o*%=X2C~Mz0tUH`+4rojDrmVQ z$E@~Kys2^uPh@R`5O5{oEEG4*6s=g)+k`%Xv&bmDCfoU+6edp2Xe?Vk{o~*N>jL-bKpCTHf z+tR;zkatad4{CG0;77fnbzhxq`-fUSYTvQZ{sGyVZU%zCwgf@672P1Dw4CZ2t-I6s zGm#K4;?gn-Na0|ad4{JO)zS)RR=F35c#X(Nh&8pyA4|5c(}a=|8&6+(>-D-wc2W?o zCUHqGg7c5hV$pMwXKqn)Gn}V)G%Y%kRqvOOx31o8V`h6E=4`8D6^fQJ&a^L^8QDjU zrOx~4anJXR_)xAAzsIk(DxOhZ&nGXi+nFwH`|`o>(JO0NSHOOl#Uyn7w0Ja~tGHmo zl7gEBi;(J&I%5lSDgoMo9CI4~ykl21ED@$%g0ll!>-*ZWX=SGq3SBj@s=e!m4~c(3 z)p?t-E6<&jE>W35^XMBw1H(sXa5Nk#p$aiEPz2?Vidtyy{Mo@a^Z*IsQa<+O|b|Ei42 zaqhHSuzeT6^}08aXU7)a0`AY{Qh07VFDrugwxX^={+MH^L*RkWr1}JoNImki&3)Q` zKG}A!Y&D(Q2yDW>b(#C3W!#U{8paY*j)T^%{J7>X=iQDSZVM3W_kq$j$JP#r!uVll z>j;u@{o~R@4VhU6c)hGN$JJJ9vrc#3YOn?4UOjBql6=B2Iim2B-zTTTg(T*_f@L2c z0xBeOJ6R-jSpEUYddp!ff~!4ssm*k|n)2zy7*!y3tev$3UBx0EZ`UnIv@So4P{9J> zWiRIz(TOT*qA-)fOL<*rq+rdc)#WWU$@AoB*B3VN!)B~iQ?+y&H2NO?>5pZQD^x(L zel(i|e!be{8a=(F<}Or^yFs%AHVBwuXE%@}L)yV<^O1EAGBX^ZA3*}yNEfqR;~P>C zkYNs|YRl4Y4R9wIt|}Sn+&&#Na`txGmYt3kcv)Xpx5-uuf)5@TJ=ef{K6RuxOB;h? z2SE-k=^0FYf3cabG*k3s3A-AD>p#t`%p!X8K6RCH zETx;TfvuquSq!5qA~!qu&QDCOBR)g?O_u=jq<`|a0AoT3wEL8j*yt!PF#Zgbutc$A z+@LH~`CaUBznMvN+=>6wD3!JT@f-B0gBX-&^TNkYUkV3o_1R1u3Uih7!P@7&^8(T` zaPYmCpA*`fx|1V~%e z!?`7mywUMfe31Al`<^nh=WC)bcpZ@gnb2>qUzqJ^$af6URlr%HJHqkuG2?qYGp83D z8_5y%d!%QF?(aN06|TJNUy*Wxa|g?TKKzazAJ>L^&^SAGnQR(*l-z)VPYZfT56@SL2Bsiidl@RnI7aQ zEbXVAN4&ahBpohWQ~i7YBru#mB#!W4Rt;v~!i~!#3su1A4*ko1bHt=+k|}%RnSt!Q z;sJkYX(?2+=V%uRVm>vH&-{Y)hvz`<5&@nJ7SUF-o-DK4O33YwD5aheTtwsXO_yHm zsfRokD~V_kk!CU>98{?SJlXvTu|AAz4`H>|!Ub%{_Oq;Y()!_NnLbm8Affb>r&lx2 z@(kW0Bj)$8RhC%Hm7lh8s|vbb{E2!N>D>}#1%@%Tk-_x8K?%irT7vO=;y0-WK;pRr z-`GT7rEOwhtu@BhP!U$gF0z^mE-xn~%W?{;05?;GnP+Nwy4P27<{T^?^1*~v(S>Zm zJEsLYT__(b+ji|bswA9j(!NgQP~OLZ3~-oEjwCDX2aUn-yS&*V_WrfBCeq?&hgsm9 z>h1cWAwA8|_kxy^>yQTF1QoXbtvM_{qa4|d0uV|-=Xkh0o{c$r>})quPg?9JFtc`r zRT8LY9OdSo6JqHHvAFtxjTqgvWG@^)bUf8@v3<9Q(#C)`^+-N0k>*{!`;M=HIR}AH zKm1BeDqf{HSKK9QxO2EaAba{m1m%^@gtX92tPG%O-VTz2Vr?Ru-{FKUQLylWG_uYb zr0f4)t%k{&hJ>Z5_7^AnYO2`D%EhTU_-fGxOfeGeKa?(3eX|Eb$j|eFkTA2-+#$yj zW3j*f^)u}r%1nuR34$K$+K$GY;aXHvn+GCs5&i|0=bo-a437*;NS-~y9!{D7k(C_~ zmJ_|oiFdaM1(8v>pEF+XuwVIwX};a4BL`2Rxy(Gf!~M)JtAV;*3&O2+HzxGCZDGOW zjQGop<)w@{9#TpidVGR0>W}~0aZh_0L}!zv@X4Xf-wd-!n_f((c?3i>47Vt^7R+#j zqDOkV&m3$Pk(pQt`izjc)zTlYXA6B|^v-c&7c={Od(Tvp?RpIM>7!Z{bNbX|@Ghcy z1F9G|NOd(u`Z}Xyolx$G7NQ|_Ph;*-@MVzrD+tuKF1;Bf86u@pQR_;(O2Y! zFkXO?V=!?J^bSG}4#S&Rzx%*`J7rgmFjsCTBTb(m6&OS)J5L|+_4*JG*Kj4ex!LS@+sh-OWzlD`yt8w|c){S7ilu z+vs>Omrj)a4vM8vaq=p9))W^s1vWV#Ex0*Xd4lvrc!%)^ygihOS9O|ZWU`r3@8YnW zZ{fUL0i}N!7I$#DNZg@`?ZGOabXmC)DrKFTZfWR6R?-`6b zjauTYsRnx}7kaXy(djpZIomzbmhb$4dzhn(nHRo%9p_p%;dB6oKA<%g9~tAtJDMx% z%iQV21rZ5RwL5UXU5$&>7ufZ6WadZ*ym2@<(HONNj};??w-T`~cZ-(zhYfyPe4OV* zU+_^}7x(;O;L8y|qUcCLE31PR1va-S;S4$BR!Eha>x1em+04Q4aDg#JU*h*KtRNbR z4Y02mMrZJ@({K~a^d3?4rc1d|T-&KX_Yaq-;$3P!jRFEca@6%7I-dHZnr=N7S)mg$ zkWX|gxkYbB?xfj@B=?vI(>OTK{X>=oSO}bmYrSAb@al4bFiGq_*S_TYUEFWOp>uUW z*OhO|&2RgA?6wTyGdEYfN`SI;@mEWEWLZxr05d+*El+N=z&5?@K&^mWh4Ggt$X(O0 z+78m~Hm?1IU{6C<0u29R)(e;w>o{(yLOjIOHfoo?6nLe@HeL==va=}@&ts}klXjqy zNdL5$UkHzRUQ~aSP+|S)I@;a!v-uSR!C{`Mv_o8I#r%~p@CK2Bu?h`ikT@!m;>P_m zHrq~gRMY3M2@;er&gM`Zvvzoys))~z;237+`2u6N@%8$=^?f&+ja1Rdme#nmwm|oj zZl`0doPrm~c078ipt-c9v3mj*O`u7M^YTT-f5f?R2UVAo%XElA!G*1H9zHZ4OBND{ z02Xn)gu97HYqK=-B}_&|hEzihgSOI#uiZ2b3$rEoaNV*)>rf65IO3zZ{^G z7>aXGDyO6lJT$n%@&~+BKFOSjwDMvWEvS0b^|wZ8QOn=^<4T^kb`RY}nStC^YsQ|9 z6oykAPmXGDZ*3Q4`>B=Z9K+7F^!di6O^48~(P`7ZV*1(vFrmW!ADG6J(4VAC`0UKH zdgxqlh5}fY5g%1lo$LwVQS&smXxKkKmczOP4PmoQ8*1$un=OjjPrD zL)q4lt@_t&e87#}Ti$_Z%?gdBDT0V|@gF{w)j(T@ov8AIFQvaV&1e6BSUoZFTH{={ zMu{~)&xEfZ{{fL#M&;r_t0@l)P1J+K5!UNpq-$hn`O!GW#V$n<9N4m{C}3E&|H*g4OZChf-SJPtq)`3EUh#oqSgJvehA83K?_$@chQ;|$qtBLJt3bt z&25Fn&q-rV$8p>iKRa8CW3m-doAKz$I_}9bv{4SiZJ$%=PM;n2+prFqyH8h8N!~EI z$Suu85VmkfP+>uXdkvg{F-eo0XmlIDh%FG}pUUGPG0CZ%sF6HusOhSZ{F?!W<)lut zy3oSL`R768otewtJ$QY-#SbBu$l4KwpAnW5dfIR)tZ_AS)U@kpK}iRj`VX%4dTgXO z=_PuW`vUNfjH#%y>F>#6J}NJ%kpiwe9h^O*K~Cmvwk1DX3!Q|RrAjJ{nHHwC2&%&G z#TVwXNg!u3U`Jm;b2A@7@CGQhDt+M6-a>}$+>E|>m+$ps<2YYNeKKz2^fHO1&3!cs z%-2-xyc(m7I4sB9`b#1p0^Ar$k;YYi9CaLN(eCo#DJY8d1BH^DwBlJRoOuLqOmW2k zx1A2Z;f=lGEL{>wYr<_qf~I(@&FMsYc6=zV)*fLdvVfDvjUp&=c65T$a6SQj>%wv4 z=UzxI$Ag_h4~>h8%}rTORFvOT7g^J@@+Xl3%etI|XOoO&Xbh;*gJ9s`G6djiMD&Lj zcpWzKau&Q8AHvlAc0a5A2jtYo`)P9t|6^BE9q9QD2l!2foKe1?;Q-XO^cMFR-*i|g ziLMu867;ZwxX&G<$jH41#s|hInOZd5@Zr>i1&bSIG)p#(C2~F}x-nz_KdyTHXW|yB zN=sEBe@ym~$32|sp5PC;r`DHi@WV+m*Xxb4CpruBA5a*V!GQwfCMk~14`&F%6aOyP z_L_u;riblpWT~QcUTe7HBd2^S?{8;q_AJFXGDJbhQYIytiu$Bs8LVKCJhD7DsOZlA z%wbmDd?`1!1`J(_89+BWgOqMHWm-LiXR=Xx^aDGYvMjte2&BPcri_@`|9LGG?}<_K zCa(_K{W>PiCOziaN?N?ji%Tl=L(?e~9{H)~Eq zmR!*Lk1ef~k7fhX=u?K{1}fwz8{h(?oms{R3Uf)=xTC^e;>8)a1;%wFvhiIGv@+ZNbTv;96WxkEX?xO0LrRIRHu7jKR98JTzXxNUHaUA#I7M_obVfJh7+q^o+b;79x zkN}oY#S$k9s>ib4GP>#7_lr4dKG7tfN^-sA%Cn}Y&kkpu;hrybFD70&2IH zdVJwP&WcPEwrCaKImT2@S#95V^Er^zz;cCMP7m;`)5{Hx+<(zQaP^`mI8t{^ z74-NiPK<;o>gk3dP@kJA@B-0GGAs^Ecd0yFtn z{Qx40oFaeL?ZuJdGid!is^(nA+_-~PVL2F?JpWcG5gkRpZ3(Hu$}XS-IBn|2hNEmW z-b-Tu6dCttf06z1pe+=vW^MqLQ8h3DDjf(2HISqh3+KmMb!llkoGpgjFe8p)f}`5i z?xS0-$Ii<4&=+EYX|d&Ql4<~KLno~}Nb4$uU-k?qv6aCVohT*4EmWrofCZ%Hc9r)o zG7Hos=(Wtksjj2BQ(rL@3212;)fL=r<3ZjWXHM^=Fmw#2Bxn2%G8Qq~7L>|@M_4m+ zwDCh0T8P0zJTZ$TUxq`gk(X<1c8f_%x95h1->H);=G_sRYw;nBKDQ&47vk?@@S-h4 zq8|u?lroXDmQXMCQW)1s9|g!n;_wdX>%@O^+dj!g-|9{J8`Y>KJFG#7?1))Ew_(x8 zQnoJ$XPcX67Op3BPS?c!q1Xt9L)>R|C5VH;%R4~^5nO=?Iauca&mv`kzo*hhN)T=7 zyhiGigWc}U;2wF~{NGoLgNLiBG@*k&ax#P*lCqful+(<@m?Eu?$KE__z9WNEgCX#z zFT^-!Xm&z3aBL%dv8z|M@to)3WpDkdP`pw(I}3hO+G&aynsJNw3Ry6M&K>pmcGxU;AboV;Rp5wEqH8Y`_k_+dUVKM7xOtD8b}zVYv$viFoG zmYxC+ILeVQ(>;bzq()fLBE~c1bTL3nagvuaG6gkk6Yp7_{En~FunF#eh2{r$|n$=wE=#58rx~POPgHyW`M>-OVGZ^{>Z`Q zw!uQkoN+(MJ$V(l>2fRNviYyWv|{2bi}T@TN?a#ZQ_x4|UJyE{6A5fVu3{ZrbzKBy)m+d7F1iYtg(p|Lq=`Tab%pg zl>F0}nzKoyv|vOJXx%xLnjKFP$m-xvYxAO?%-T={pnoTYaswP_7MQ+V(&Vjy^oWqf zj?+UWDMj ze{;(tS(*atZAcW6ik0{TOBA{G0kI*IpjqAinp~051mslJECh8Z1jC=}cz3vW^d*pl z{_3_Q<|yh=Vn5Py*RngCLY_Y}EsQPMAJgBEW;OZ;KN6rG0Z-{ONknc z_y4ImS%GKNO6@15+=^bhhf#XEj%>BtG{>%Z6ycQ-uSBU*UYM=VOeGa3%%H4WFe7>2 z#cjU?mTarHBa-@bxI0dlZevuNlFpcu>gIL%mso;$5R?}8nJV_LwBn%m;oq&L?=@>g z#TDmsGjjtq=x|lnK*WE{+HF$N;ay$97n|qh?p}|REr!OLmkv&JQRYSk6;qgr%)UM7 z6lT#95Od?uo$yj|v9@eOU1`u+v9_NvUhe=htMv@9cR%N;u39&aexM(m{$|oG zIA8B*sxTW;lzgLCupf-Lc2wK;*TGjkwku6O4nk%)j%qRq;49Q0BO-$l%ZD5UeD&(> z_m!&|b=@wNDXQY083F?g!nU_wSy-(Xg13sE4{yykrY@C zR`q2ld!#Yn(;)HHLL~(IRBvZu?dl^kqpj1EMy$oHCZaAt$*?NXQd06ph0O^4^xJc? zQvLW?lR)9P8aDbcdVi>@Ukg2oZ2E#?@&81?)TbkB+zA6UOa(e1C8y|iv4&Tg)k7b7 ztWk5GHR$BPEo$zfIiJr|tr_dD$k%}lf+`oDTBOvMpSS7pvn49((isM#c41SWz`{t7 z)u&NV5oRRJ3_4uo)4GiW0iSwqVOb9q&4b+tjGPsS*D@4196}KXE-2u$aO2oYwI#D> zq^GXZ2U6Mx83`HQAokM_L?7mVih|1mbShma&PLCcZ0LX6T55dBD?apD|M}~?^L{cl zZDqJY^VQloy+6NWSuJa_#RnXmg{jVBgvv-J&9i2pBVN&B0{|TTJWvi$IGmKk8SCs4k;rHBU8+72+Kz7q=QA{>{K8=?g#q186@M$SvFQx+)*;#=>fO&prx|hCNlcND zQB4MxiWQ13&kV4Wct<&roNNN>FQM6>orWtFJ4&dZVLeBt}@q!Vr3bS7fzu zWP?;1Mw{C$$*v$lBX|5D+EXHZLqq8hubpfb4>%qsXviH-FeN2!I%o}TRN5kJ`Ys?3 zg~@otOr+!$ww7wsB7alnmr&l46Wi&%*yE*{^y~X0V~Y1jj!92Xn-;jLgV*s->7*p~ zSmE6PYK*Eq5;jish;sPPHe@iwzq<|LDK@JDSk0w(Wf3)+eO~ue8su``D9rn=UYRB@61)(mhIW z@HB?C5uMWd8#20PT2aqv;Z=Hqaed|~30*s&DL>D<+Gq08ysdKMz&gyzJMmJ1Xcgyw zZt*UdfRT6qxlRR*2}>kv2+CHygU>(pjN5U#7g2)k^s{AKy#zHSYNXt;(pDeqhZo@yz>*|+c8Xqr1=W_B7$dGbW%ILDX+`pFyQ*=mTX7n}AoUkNW zU#`z`nV#^3b=sjpUUPAaBUTFkl~s0Y+hPwPOf=*Qjo$t*kV#c zo!x3_>2QK$jsPYK5oq{6<5SN($zVE5Y?VDMAFb2(t9^B+vY%^SNjcStg=@SEZ8-n# zbcDE@bbTME*l~^TdUPFCG{R9?l@{2ufmVx(^9_10oL&P7U_-pf2Gw>laeY;t2UF`au+cMVUdn|M-f$&w#jUeJ#@UT zEadV^lJ%Ucr2vxSDB>z;tP#ikV~BcB7gE~EUY8VL$AW5!M<3H(ODSkh>?!@_r)p;m z7-w;xj@!`Q@w%iaFT8Te`FmFaryv~fjn+LunWcrH0NDZz3`?Z)5KuG89hWIe^jf0= z=y2!EFBH^=y)HZ)r?%jWMXtWGA4apj6Dsq(PyPb@g{#UxATB?TU`K6v9ywL--hkXP z5nCD4$1j+$K)`wWn0S}I6<@Qzd7Q>`+tGS86y$WeV)?|~ag~pd5;xQwLyqwD)i#Z% ziZ;n)JE zJ54`O>@VM_U4&)iX%6|!0cdEQn&MLn-{LBmOi9@N2Au08T!4POTHFn%(2F4+DZya6 z-wOg==1_?K;<&+8{NlWZ?jWl_ML>R)EUGq! zs+pR}HppX2tgRZLYBt9j>x4uB#Sz-q`TDa^o?VJ3bp5&nGt%6=({h>=j@fMe`|rSi zu2cMH!bFAl+X{VBb)b%-LDTZ^{Hk*brL%s&5>v>816z8uu7-GUY?iv>k?K{y@QOUK zTri8Y8%+(b{_`N_c)+uJbi|afAnJImCiqOuV~uE4bn{9&9@Y6yC>550$sXLp;YUs+ zATq(TvNVx6I<@sgInl3j7 z3FpSzWrIiYX@jK60%$7Kf#uro04i#mEvn+=AqZ_V$(EjgxUU6kyfXxgTfL@ZJLTy6 z2h_>xgs>H|Hn(36A;+++yauhk=8$p0aS!wkcA0AJTBd$IenN+B?ep7Q6WPP**BSzk zNVvhx_lA8EZ))7%J6ljDs`}aD+ED3YK>|*W(9ic;==ws}V!NXPSV8 zt!(dTTN|?iDjm1(j*L}P9Z`)@A||0+_WtY2K5?-Up}x}TV-Lx^w`&N~NHj08udpON zt9-B7D9eM;Ao;;5u0D?Jgqe=mEXr{C`N@Fy;_`$0bow+%zD|M7>cQVdzhRo&ru5{4 zq}qQ%{b2ydpJ`l(+K6_XCec%k`vE#BB<1Z&DX{0f-I8tMueakEu05wW|EhwWGQQ0N z`0>mV-bRgBj;Lcv0EFYQwxa4r=#v+)6~VNhyJD(bYc-{Kq*>|u$_hI)?BLZ|bP1tb zijMZS!2Tq=)7OLVyURT}aCZiP>PyL9^bJF5~yXgv8*b}kz zmJvoDU;8``#%bvgjNt9ncJx9PKo(2&sk_xzA=x_AYOE!+qOZzQ4uxQ0GS!oE06a_E zhgzB&lg*gu+ChZ@vk`HGLB@4rtS)A`k~J3lY#j*MBhAiFRaoqPy;Rk4&bu2G9kWLO zvgEN8;)R(aE%%leU+pF`nafPJV8OA~lAVU%bF_KvIy52Ly>MnL$6^tXx&1V&leZEy zJS(qE?|QUtB`c&@;Jh_009YK7NUakSz(NquHeC+Z#u#dBXv*eFU)qj#EeN`ULByLL zv%$m(oDo-ZA%PdWvQ`DRT_sELT#By?{1n&`GH+%~l_Up&QBloRLUvOrr znE)LLGiyBQkacsQlL|A3tKSxXKcP?#8i!E>F${W7784VK-W*tUz&c#;QZ>sybYB6C zjTTdpsreKfM{F05Et?u`q#vd^LY}-eO$m&vC1V!fB!6paHeqB$ShzJMYU^ny+IHi(H3J17YJEAC-kxh{ zZt0>YEyD0@w6h3wD_$itv=7SMKci3LixhVo>w@oNygKke9zmp5L_z{PX>hp!tFrC0 z!M0Xv%9l?8_eZH(-8weV8FXz@GiJ7Nrmk<&N1m10pV7e&X$rJavm@Us6r4P zvJ4%FGG%$Zt2C@~m5P~-3q^~+q9m&kEZ7=8rytf1X5}H}bIK;w%Z`mxo9DEw&x|Og z;!W_cmeLLpQ%3~h7X^EOry?T9Qj-bCFxr%4*Dt?2<9=T=r7EtC?s05-D@9Mu)BXt! zr4;YaK3xUtsi7cPPl5imgk`MJv>b8e57Q!P+ILelag5auB`rD`sb7YoVaJ$1^vdC~ zm>%MvK}5F$#uswjC&e_0fQ{Gkrm8D9L&52?!K*2oNJFc%?cDuV%wq?Z7cC{HB3m?1hE*O^UXJk8}d$Y&+>Xv&^5 zy!k>Zc&iMv&D=`6U#qAN*Dso!*mOGtUK=CqyvvMg3zcg)QAKwsy>SX&#JffYdeFuX z1J}A)@nh_{?`K#ZZ*!IWC*?r(Ez_laxjyiGX8T`PiN7{+W7Fdw3MdPbGUE-&3uI+x zu}Cb-L}8xyTV-=;>+V86@0&u|m!Ft$#R)jI`va0zl`y)IG4beA?CC)L>U|8zSRq8C z`ny*!BSpaIcW@YeHvU?U>>jVAAhFT=+$g4%hWUQm@;(gv`u-Sx1MdDqp7;L}eKj)m zJ5S5WbZ+FaxgJr;F;*LDk;oN{1c2~!P*isOqj!?9zB`B;g*|M_|Ha%p1y>q=`?|56 zj&0jBwv$fMv2EKObZk56*tTukPG)SYPxe_CXVuv^yK4O}cGbN3ZfAYl$!@}O=|Rc%aDL{sPtthlS}yHnjWYLt5y@eiaxzgUpV z+Advq~iPB@aef0JXjYz5SY61Klu7{E1Q(ONob0|c$I(-%NDK1eOo27<=hu@4FQdw6dUeAvjv*)%&Pt!^*=NolsW zZry1uM=M>T-eyBj69`1q<2wS*#PwK%P5v_C4smJcbAk-e6vgK5S9Wu_y_AwFkl-6X z65(I9v(p3u+q&r1;q&G!O*B;Kh6sp9zC-0ow;5BSt zBkru7uKiPryM3U_o2m5m1#t5KJSS_f{PhbtimYW>6wwA*Uefz)_C?!1;SzcqCzUnL zz|~+VtEX0O1SaTU4J0Wr@SGeo8|i^`-(ThdS>m8)(P$F(gcVm%vG|Gfs-j)`w=bs@ z)l$cYv#sG=WkW{;b4ew9Y;X3YgE7n0-zv|_>Uz4qmpVOBCvr{!`M*%)My~=V9d^@| zc;60l#_Tsjx*3YaC2dA9&|`$=DVCZexRUdZw^8a!2G->yCA{VMxk5nsZoGFn;`FPu zp*JN@bBHmaXq8ke$r}HFL(ooe{exkROgntun|&fr`ATFA+@3`YiT)fk8EtD1NpM0S=Y9Rmg_65#*TNbnzqyLur@!u5l{aI3 zMH<+$PLQU+VLp=KZ-@#xdom}xj+VBBHQebXV|r1Os8M0j9gxH5k`R(};RwY^;Px6@ z-*%D?V@bPy_f<|rJPT%NyZw*`%bMB@04Idc^At!l8uuew$su(StvTeme))%aX>nwv z$0mgC=ul=~^PbAuBr6e4)iDTVsLh(*NgnCz*nW1u1G5hNA{2g~@S3mWvYXGR_R6O# zl}U&33WuPk2X=!=WqYb(G=}A2=cSVN*dlskYDDsi7%1HGcMO;Z{&@I1iOr4SAO_=T z_|SN>$$R0$HkVXxsV*Btc6Li+DU>s!^NRLxxuaF&aVf@St~vfsgNe}Odi`V78Zod$ zq~A|FxfJ@?prAR^Ce{&&NU^kAtBb$b7OOkc>ry^n>B{>&)0gDjhF?%8aHhTA){>Mku4nX5rka@K9RW_xc){isV)1?RbO8Y;OOLeXeFDY$QIUy6 zJ_U=-`>0!!K^-;DWTu54`A<#klR!JAPDPw=xCHsSgzhL$xRrP_^gt|Y#%|U)t0(TZ zK4&-CvlP42jCNF(#YPRn@>H7D0Dp(`fL@h{X@l8Ry2k*bhvC7rCNRg-^EQUtI5w8Y zG)2$HW8@ot>+^bh(4YSyvz7d}di~Po={;n-o%nZBMIoobf_X^RU-h~K+~Z^Bdc<`u zryHrqtg;mau~YxHuanoDI7+iMVA`ckW(#};KRARcBs-%LJem}{G&&iW7%xBmv=N*a zoMFz%tA}Q3I3gq25veKJCbuye{%iM>efst0+H`ii8!$vmURq|s1;*OzEWFGbpxkZt z`cWXH6^EeJifBzm*E0OdgLwct_j5LbNb36b!1>uLZGZ20hmezdtwE5G_1E)~VJ0jg ze5dh|s6fF`x93l`gc^Y#md`U$_}i|g9~? zzXx^U{=jnY-+l#jmabz$FL-5~oWlq4To~ts)#i~xo*WGDULMi8JSEn`AzUpU3op#? z@_IF(BSl0}CH8)#Bv-SwueV0t6M+p1{GM|X4)-^z_1x~PQr{GJ|4fzowfW+#q`!>M zk3eT`Waqec0<1`OUyE-Pk2}b*nAh`4EJd>B1tA=@F`l`* zxVU5qjy_;*50NqohcpyDslvka+C}qG0}m~$=qO>>jz9K`yOJ`tci7g+O;;QVhG1 zBGW{Z{n&M}qxMZjNq4|YneUDw{PWXs$;E=wX)@1&qt%9lc2bsSjX|MhL}Xvek_i?u zso|4e@5+>5?>aT6iOsb^B0G(11Bm3R_W=U1oYv_EpH6b8;;o`VTXy5CLER9+R`2xm zt!Kqd|3e#g=Qy z6o!A|24rbQQ-$pTTN~tf>C@O;*;y@F(*a5CPW8csTFKP2c;N@rHm)-$1W!OA;Ztxz ze!ue_>i`SrLRg#BSnKF%sU{}t&E18TjM#*Dhus&eGRwQK-Kx}xpk;%#dZk{|M%jd< zZS$qwQTLX2$-OK1$G~t<`sGx ztFF_2iEUmbBl^mGO;aQIx9ZJ+V}bY+r-R7#$hsQ+`|hNM*O-n!n1LbSr*?KLVDs9# zuQW_7{8Y5^Ik~ACjh5&&Sa$g5@M{1tI2A-VfE*^dga8*ep#nc7fCrfg5lmK3VJHa% zrGHq8&0(C;0Y3u?B}_mwCK`Fz+;IPBPlq)w*5W|k^R%4vpb>bo&`!DeP3C|~V56fn zds{Drv;RF0vjBII(UlbDL`&d%-?;(Zju-Ep2^%eOX9J(lsvuWKG;O5yF++M1fH>*R z((S!E$=l54-YRijZ&L6AI|zAr4GFEaQ^2P=r6)q${0TD4PGedK^V!0MwvJ4z$^U#r z=LW&$Mr*MmM)M=sLsnB=d(ZvUoOGxodi@D9uYvm@?y3R~X`_utJM*$a!?cl&rGs;_ z;Cqt}#y9UGv+VCvt=S*ayFy2Xykfnsl#tne>F&}tF1y_N5z!JnH(-;l4sgbo3LK4Y z&PANVFG^_Qg*XVmz5FIp0Sec9fQx0Da^G;;0BC$r^dZ^hF)*0O`KITC*TUfUe#Lb; z06?G1CR<@4Q1$us5CEi8SIy;igcMyiMPeE^o8nF3P8FzY@3phBrX!XtvciT7li>u< zCUx_qkFT<&`Z+CSW}ZDL#~Nu1a`0~dsE!;x2Z{7eOKLAS8<`t%VY1gIA>Mx+EOe!k z)kRHl4qq{oEPk?eAis#oODR7#lsP{MwX=2ZVZBXov30eo#c$$N>+_QHEXlHw#Yfw( z<@rhkw9sziSXbJz;g+K0j8r>B`?ss{f{2Eo7^61gVi%Sj-1_DP6fUCw^5Ynlj1qI=Cp|aQ zsGBQ|1p=Qi#(mCaBE#{d1scET# zbKPkmt(mBx(KdEY5{rB-mbKNwe$LPrwR$CKUkc~?=d9%JAqd5+L2|0g_?C7$6l~x4 z%I)4>jFxj|pn;X^_UzxW;h{-m`sGxSk!uvM{ZUf1ji1|47)2hY`fAUDGJl4b=6@tp z8LugZto8oMs!wV9%LJS1sHJe8?wqgHo?0hV5ysYfYTUoKY>X+;8$GR9-qPAZw7`+e ztDov@2-ZiO8Ms~JoNsWZ^)sp}3I|GQm=z1-NEIKu9TSG~H!riPM8HNpbFK0P=?9yO z6;LBbxLw96ltDok^^=5a$^~IWbntM3yW&hatNM^JZ1TXUJ}_onV9t%FbuUMbgl!rp zMZU!K)fyv3X`S!fGBsPk{Ae2B-)QfRTxzGq9TWoi@e3d1o5uDh?;W!_z47Ya1Zu4fPLJ!Agt-(x> zc|Z@_<37a`n4{-o9D{xEeH}AR%_U^HJ($TA@bS0_?D_{%sG#_^@m2H>WC5Dbzw|ot z-GMZ%6ExKgnqr_d-wc-b@^`pi6d7_3MNuu3oZdU zA_+mZb`@hl2M&va#5vACtnKVDREY0wc59n{JvIFC%sIb;$m_J#LVq(cJ^67oy1vi> zkg%XieaGHW{l>()LH@?4Mc0Z6UbA5uk3u_r=68XtRJRwXD*>73-8 zqawRyayj3L<~gdUX_db6J3{J^=P6&rJ-El#>PFCuI`$Sx1vEv;w8aFUT1aP;KS3L9 z4-C+|Sho_b@Pu7pr0g(|;Nj};ZX-4WMZQ>bva?l<9)lf0En}tcNH#067m*_+lTT&* zMnHO6zZP@+DgJ&c&MBv;OQxspiV#pUx`B4pm9XBoq1wCz*ReSos!eW(aUz8fK_F}X z9_ARcYxC_d3B2zc%rJlr4?KPrN*D%&rY@oAVKEBmv5++djbO9oVr7TqhVP^x=bNl{ z&;6b5JQNet_>j{MxE9f86O;LaOoFLjJ_mj*($J`^7J>-oP?4*mST&{${Z%-Squ#`EbY1% zLu+eUN||z;owNN1GCWexmz=}BWsu0>A8EH zoLdfcY#Avl&Mj%0Dx*Ii1zF0ubo_>S)Lvoa&czV&Y-@ypvp;Ce7BK02OnzGs@24rp z51U>>bf*HRtdgr+YU*$Y zZyH9gbDV#M8p6T6D_& zyvp@$`_fdJ>r=>m_ABYYi?1`)p(Vbk@007fjbZ z3|RsBYOT9vzFZ_|vG^?~wSfsyN$Q!+#XCu5gBO0FZQP6lX0Dv zQ9MQ$0@f9&vb=5Sz4u35zy(rd_?GhPZwEo2^xamOMT`P&yjWny%WFfSs*HEMPh{(jhlM{ht&Lk}TcMT?TmT00EpyNNd4$GwE@%({Ee(}DkK4_sjH9z_ z{WVf1@KBf%zElUV4XUd|c~V~@ZiM5$x*XP|JYxnhw@KGCYby8ekXqcyRNL#s&q;S@ z@DK5%#Oj@m*LoolDDn!(DWqoTHE`=T7Fc)pMdlL5&~UpuepgG0Yh%ivCdOoD?nEX$ zGD3u*R}=LB?Y=+l^JFmP5*dsy8Nw0KZAcC6YYpzh! zT>5==oETYkumOKGa^fH$4%qte=_R#8RZRGF#~PXtU8#6D^i@^(fQsX4yA9-5E&Svu zNKifmod!m7e-5IrfJFV`b*}X1en8}>){PdvyD{BHB_AL+AFx`Fq?l)#$-;g9BwOWy zy(m@}ddXWnH|zo(rs8}eGDkbQH8Ns{a8rK3$hROUnyiy$R6xFY3%fov+pVHsquVqU z7Agte+u{j7m0>e^tA_}+zORBpqGBU2tQy|#Z1*7#*-(Y%>=@j}66W+zFgzycIE`4&lvFYb6=j<~=iN&~KP?d*iot)kD`S+4r8>@}Y7a z@bdku33^${g@5jGyj2hw*)i{=byRtpa|ujOedsK|_3G?OZI@Ry^u`~#y* zN@T8tNnZ(S39q4@kE;{o&V5pP!%fvP?WX=F@re;`(}51{T2a*;OMN$!#uZ)$Rrf=5wd(YDzse-f&5<_QDL(2Ltl zfdtGh>bzz@5|rJ$ii<2_xW3L)^3%-Jcd^L@tMui^FPJ17TzpG=6mBAv&-hCQXvYFN z>2F8S>Nrm|FlSix{a}fjKJ)KDFs(RGD^?T652t-Euar-DW!k~1(vA*bSkges#ZQXn zv>$YrB%(hci|`zkml4Bwy1DV&Ej!&ZX2+|uqLMQGvo_Y$S>^~y2g*+VQxL%a?iQmD z{BLs*fl~jAttO0@=m$Xmq;&ll^7lc1N2 z5Tv-rpI_{zJ1XMy?&6GiHt^guEiabRD@=v1|@y;FaJ{lF5>#;5g?qHO6AKgl21W7DfwPMLsO7j%iEx z>Qo9U0!V$BCH$e~GR|f40#&(miV`=QxAqmmu+PeO!Y+3-$K?7D8KLK~skYL3hNRzK zHGpOb&3Ej9g>IO(gnvQxy;H@9bshTG zrY$kGNSuZ3(BZc$0fU>1c+#$zii?U!P>X7=fO<;6hpGn$nE(%hu;Bz^9c)N0B^FUa zjxD<^M8BxEz?Ge0jlo&?C-7d4F>_KKcou?}Q$FEdhG>He9Q4u2Ip?$MI_~AwxxLhy z>wGy*hvzz)1UV>-5Zl{{Ye}IYG$qG{?I}qW)YdIvSbes2R?u~3IPqf4sSL3NNKxM@ z`oV!d$VQwo>XYL5#%8enP8)i?Kk9C32P7U} zN56Fm-**w+G1dAAPV5Lxzlm)e$EJUt`*--PeEsEbSo711dSXiQZkdDd%#VmWAPn21?X*Fp1*@t}mc+y+KPvQ5;Pk}^0w&~ghNplk)s3Wy2hy;hV~zsfkv%; zTE5FHT7P#R;xpLF97ZC5{L%6v(#y?w8uoVeMN~ztw=+%WueB0;I`)0Pa0Si_Gy1T` z{jDC&%t8`K^z)Sb4GAOFPFBvyA9O!^r7+0Xha%HC=6OoG%&D6#;*4Rer>6|rq$u~3 zQ`!+CR6rlx#bL`<>($Pcjd7s};rzu4duchWhDG!e8kBwjoXWIbZ=@a>3&r$3)Jr*x z2hIQVC#LxFQ5OpmO`qvxWhOq@Jh$bdUk+&(j<*mx>;N3R{7Df&>Cc+d^vX^Bjp5G* zql=h;D=OEcxRnF_|h7SRr^IE@JSB>n3&WWN0b_2N? zHz}e~OOYK5V{8eodL1XoZ+7@6n@Zf~%NsmnUNRcftkR?cxNXcEYPYQp@v^Q*9hU>x z^nJXXEwtj_kRi=T?;m40gz1`NZMulzQW6<$zpJhL60LQfC%s1&F6nkfQVW6(7p4wE z3;XHc&>f>6P6?K@bdnz!^eyW_`cKOg%4ik>$2_f`XdQfmhm>rPx0VKQcX-mUW^GC| z($%o=__AWG{1JmWoe}`f&DtD$EeMac1Be;ThACYcPsRs)oQB^GePD>kw``5aFh#)U zAce={{(yp(^VPrai^3%jrD z6#7RlXUc@%w=z{=XX02zUz)e=bGd0A>yS}T&B$eq8++zK*>v-54BU(~}!{up!= zb&AVNG!%}pI=OrVB1y1N`%C%383wWq_1T>KLgC{4{*IYvbHny(zfjglpr~YKD zzV2bSg^qQbkcHd|PtyH~(vY`} z61xby>4u67jj!-37w2ovqQ9Du_{ zk3wg&=g9C>`|UlD&s8g7EU>LSV|*9OlD^k!xklt7ewl3@rzzVM!i5W031!~0(fd{E z>2B)a2=6q0c343?CpFD(dF_3Qd-uS=z8deuwZK_j%az9ls5X3s*SZpNiT7YPz~J3@ zwa|dybh*@`r)k*m9ejSjASpb?z;B)|HEHaTfbOwihTwuZ=d923eNX+z>4QK{4XuA> zx7M?Ls({5Sj2rmQXV4DNH+9R8X}#-Ad)Mz^GspMgCEqJ$b;*{amcX+5GU6Nr-l(_i z-Z;l^Jx?a)`jdJ^q-jlZ+;%(>Ia@hwl{e0b*4 z@%b$5IJD>N{S_9{&u|k1N6Nt&H=&@g9|@5W2g}U{PGMc)uoQ@1T53&39PR`f>mPc{ za4g)zA=E`!5YT(AQHtK}RW{9F>?hh99profkljHPFXT~lo^5wa3~uv-Lo#$PwbL2|T&P^& z{jGf2=EalUuC`L~;A%L@SuXK|nAJ^IA66S*zPbh}*vyghL#->!+QNg2aB|#gndufC zbeDKC>sUtH_xDQiPxLx*l%IvQ3f%yd2ue;jg6mBl+uL~M$%j$N_80RE?GF1}M10GQ zXQW08=ZeeE?N31SK??R!oYQ$RKgWSRcjONrjrw9VBzniuS78-5BK`yG`<5^Pr4%+_ z4BhplMez#N;q_#`r$7tao5y#}TI$_?Aw$h;Bx&4{)1U=esV+XdYJ-{3{r|HUWzW5U8$8@FVtI&98sX$S3@{L-PM=j^hwj@hLsUDtOE>#O1dA{%{tx8C zpf?|mY`+_8x8RF+lLDVL(&qNuA#dS>!S+aP*%sW3oxesb$p3Q%x;4z z_Tu2p;9_;y3uW#8a-M_CD3nQ2oZ4$cfmK#wXb-!kOH@Eo^{6 zd>s$@5eLO#e)2&5QIh_i3Up3Uwy1h`B1G=2s56h}*g~eV@SF~6SupH`73)Z3V8bF} zF$FrA2`{Iys=}=Zg|!3{O*A8fZaF~v7)MBhvEyN=`i(v%ofQk~(01Ns$(%&CAN&a! zJK8OouDEsk3Qh)nw3Y8qwrm<`HZ0)x3LcyShlOQ80#+7rC>t!je;%a^>5eOtaL3VG zaK!Pgqz+c?jW%pOS(=vSCFN|A5WaztOKIGdr1*i;yhB2q!=ypIWjD2&OYqvkEA<7b zSfQ3jf+qn_%v2*KQ6RZt)o=*Y--`fLFR& zf!j;T^Jua6drALTXvBnF>_8$jrtn$#!j%o>Efw?SULHlhLXEHQRQ%Cc`5#>MD88ROB zC&2Veg#0q<*Zl8JD2TZKWwj6FqWo~7di&nViFo|BMEy4>)PHTj>zrK}+j0ns-r9Bu zpt>dpgpNM^(A0^hnsINDkVWEQs4^}Hb(?~@e{?oW8y=Anv~9}?cXz>#efe*7)NoKx(i{6Z5DWYFY zJTy5qns)6}Zb4h9baSrxY>`i;iL3`Z-46>W<>l4vyz+3-r@_1n!x1eg#S>~*s&WGr zp$|;(dhWKcCy&YWSJMK^CWaS3k4G4~+6&zK!s3AF*5zCTBDx|^qH=&1yrDtWhOdGm zvaFPIG>^3fZi+FI5-7!9I-?_Ac0;0lR|=!U`wX|vduC>N$C+|RcCG7bm3eu38|;nc z+4xn5#oB61{beRwn%S3CeC_!dtI}ym<$6mRI<+f116_qgCpk&h%aFo}fX6#-{@~lx z<+7SRw-y$Y;%a+f?wgFRL)M|6BhIt}D{y@s1y@M1n2e4MB!`^6x`PpFpw))en>e8- zX$$IxuZ98Yw$kW(e6PfDU|wC)$ffpLQ|X8BmamSMj`a`6wWnY-1_0fTbUw0mNeRyZc}t=K|L2q|?)NRoc~=oP|Je98E!;v$)l-Z2VXo0_v9d5udfbh^_=iHho7{Z=dy@pZ)LsU7!8Uuf9g!bB#ZIbD#bD z3rUb$^s;l!a6A{PhO9?_;?kg~zt|<*ZQK0=X)rQWV6ybZBE z5Hx_+pSg%8`txFSa)GKP8U+i5duWbWQF)W3s2NzQQ4oGLazW}5R6H8Pi?q_VftMV3O>*X~7)$qM zM#=V2be?uU_o6KL1OJd;U|2fodAWPa!*+mQ+hM@0D$TUUtSjwh1~JY2 z`})3@gX3`@qhT7G%Y781$M-TZk}JUXeT^S4!VoO6r&;7swrRkw_n9To+ zM|Ihxy5=U&_^P>f7Y?E_wBPI|s!Gbr!Hi&-&(^6b>(EU$4eX15tK>@@ne(w^$gKw7bSqh)mSvvrviWyUf&Uu;^gnUW)_ zSUbBxj~if>6qw&(lnqu4a8Iv*a$}Oz1Va(=!CB^8y@JYQ3bQN+Xy*(8Uk6p>mO^8* zmFq=RqYc<)%}n{riicg_M`G4n+}8(%?-xcnEGD7Z+#U&Vq-^p_PXTgvkUi>m?nO)2 zz0XULT%4?a_zD8=G1Eem)fM?WWhA&!_${r%zmKi)7^Zq3uw{AzGQc<@J-eMHnbf%i zFhPhTKiwX`duqxUCFUv`Q#WwJsDmJ7WMjncI5nRbxS z4cj@za3!!tF9zil-QuA^o8}jJ^ouPSQb?Ww}geV{2gw6Gd||gF7mes zQwGPFE3MK8(tAC)=V7T*|0r^6|KN$DtEbk6M?_$kIS_a-Csy<6Q*Y-yZVxNw6CN?W z)fZh)T0+--A-3pi(sWXX!val{nU>Ks8^kl@b+}JvRYh16{xW4jC)7rTge~tt7yNH(>8* zu!KN*1dAUl5iT`60j)W7Nd8YW-Ah&JKafNkS^I^TvOkHZ=fNXlN~s*ahC8#Ex`_Lq z4VUmRtjlGv0KiHagOkEyb~haSdcoxUtB7O$Tz5HMu1%GYWW3VUS=Bb51MTxx3Bk=xF_ z%vcXPjIXGHfQW(DRXR6^ghZ8OivIPVZcGymv&uf$ai3VENOoKJZ>W=m>Aukl%hkX; zkTBraJS9p%<_>Bm?ibu(P}2pbs|6W0w+43KdVcL-F0o7;PXqpAh&l>5X)C{fH5s(G zJ)-Ql7NC6M9|&@g;ENN7r7!X{hQIPkwbew>RB|V-sCVTtDV_HG?cc2U`ncd!{6qq} z?7tUUi@~*=T7*vGmAx$Fm8CA-lNo%8K`w8sEE7B_q%^@lewmP~#r}y5i8aN*UAcC* ze=$S7Y+^SrxX!xRJaFG^S*X$9zTRkYn`wA@x|k8t z;Eh5>=DQ2!|@wpk)onYyasiBt~z7@ zU6z-P0z(oNOD+g9l#c2(Lk`Z|Gp~mBszrL`hD^+C?I12~LGbb{*yZzJBfkrcHpavUL_8XTHO!+RbgdR!HWldC-(! zwvS_Q%+qusb3T}Z&rza$B~F`XB=QZ=TY`6c%-&?f?b{u2F}vUr`B~Lu8j>BWYaW{Y z&NLod9#P(1P~lJ>Sc+|erL`h@yB`3il&Nc@B z<5+;`<@!3J*2z39{2ymU{r|iu|KH>P3?lU(D=AR_PpXsuktU&QWgh=}%Axi!8^?G4 z0p=8;*ueq)S+@{bwSQXP+Op|uG|W@9Y!#E$DP#3y`6T;pu=VL*sA=xq%X+u^~7~Gu(4ubUtozn9~Qo zL;3=Sbv;QL2|kyhbKUk$&Yc!D48VNM7{kE=5wE39HQeV#T`I4erna<0+2DwWce!OfJj5kedut z_4l8#<)n~7y7TNSf~yiWO?$_EL`?v_p%TatF3%X-7BkRdFuqK$2?FbWZ7Q4E4NhcT z(|TS}OS#4~r*j)ofZ-W*=4I^3RFb!)nP2e+DFWM(S@QeV-Op%4r%m+a`HdnIeddTT z|K2x>&Xx6=l42Ie2Bf08%9dFhoX72P$nM$`jA(15WmXIymv@>H@5@Y1)-wEQk`64~ zbW~YHjASlvuEaI$j}4JMLL#aX=MZdZfuZ9kz~hL`T+={Ey;Z(1MQyH~4r1e4Av`#L zm!XyoqY)hm2|e>@E`afT+&AyAveSgTG7H{PpYdT}RC+*cls~qwBegCH|8jjr9luMd z9TTy}NbBR>wCy#@HyTz|^4KvyMp!9Y6v}=fKPzkjANf3>rmn%1anmf-RnpeJ|hkwtPdg7Z1g97uo1X$JU3^ z?oJDISw6pZj2NTU_Waddz-$!n+1oQ{0N7{?&X zrX~IL9^Pw7*Dnn`#09+~24SzPQr^z|NzX6;kTw^=!qIc{W$}@(4C?Fwl|uXj4fH5< zR}3EoMQ|t{vwod8&Lc4b&2ZfAKZG?KvN{mteGh+2)9f9|W-rN6GGz}8$zTh`U1e%l zrF!jbKRSQ0fUol5dnQ`LGEoZh6X4KwRW~E=^vgC_99V~~WIPw_tX1psd-G#gNa#aHPBasu) zM`Y{*o;7;TLxd;Y;*3f5Vux#^0Qu0?ELdIf{A2*9$K2P7kfZYVgSSm*Xm@589@T$Q zvWP4r-{*Gc|6fWL0cvQA59P>E|hRmNPorZO$m6E=xJr zCs8nW#S*)WXGtc<(5&dTU7Pj_QUrk|7sV)et7YBFZky?QuK~M5Z8X9y?jtCUP$TUw z3eQZN1vIbxf1WK^Y0VPnvB)fOKHqDk$ii!RTBF*>drNm#_OuS+-@Ns>9o4CdpSPjs6xM^U0|JNm5RY^ zhVa+$0^gAR0}HuMrn8cf%wP~+Q?KNtI4*)yn!iD7Dov)(TlbsWrki7*&0&LGWkH0L z;9OUs^!(zz;IEFI8i4N9^bYMm5Fgbt!BH$C9;G!08bk|SViyYIQ!Vd}%1#T8af_F%fSy(R`-&W@msSPT;v_ z&h37kC(4Ff=hJ4yVD4~gU#}G4h4cPf>J2Qw6Tl{oNHAvP;$XlA%61O;)zZs+l^_z= z*|No*&3B`lUYSz4fwEIuSS;G_SlM1dKpff|8YUypz$W>oX3o&F($xZe6V~4;aj|r<#}>st4)acyUDvk zx(zU*xjJ6ujI(TUHG1UVV&4g8$UbnqbsE%yp&^(>{YD~=?3old?KHVLI^Nx15Rc^w z4|V#+gnSo@A)^GtjcaY^7Cg4=%m(|}E*Qdd#IF7>Pliz^C0s7HN2*SjOn7(`%fCKT zk@ZEC)!M4(GUP${Qu}^bAB+8XS1J`o{o99dLmT7D({$q|b@PE%iA+*^ zaiBzd2qMF4)22DLN6!7*V^SficEsf`?Dt`GqM>ogV>3WdW0KQsTSI?yo3lgc^gV>R z8_;-;mJMAULT|vz*`&lis+)HV(dnlzZfC@cj42{S>D?|MK3sceK@HogIhKZoUrOx!)F~y{N|?8Z)O0{cm)L#&d>ap>aDWQ>1_PU(WjE8 z9PCwmxfl_6K<4@a=OIXXXPC5db|C$wRw?-Xe;^=hs{Gs;VhYcZ*x#F1L9%esHzPsEM0h013$aGh)6Wn^hy9= zLWr#^ofmq?sgb~z%`G#`?70*o<4ur)5;vF4KSkKyT^(gF=0&a=d`HfNXu4>cI)4&l zL+2uBEzxU<7FIGD3bgBQ>R_q8BE-JtZ>&waT5I6J>Poje1_OHAQ2!!)j(OwTab(GFEPii4YNLnx18c-R*8T^$F-LXsxAPtYs4omj zNAwmr+Tg;~v3j3mp1H6YxipN?2afy>u}7*r+BSYQ{*-=5hMw`~eP0>#*I~rHD|ziW zsq}@5g(^KbE^A|MQ*&LJ3t2{I0H{maVSR%#d<# zha)j8EiBQx?82trT-I))beW)n)ai0^a3Xg&EH4VT-Apo`YX8VJ;KT zQ+wIPK~@{vVxs8ZjRYW5{vY-jpplo0*_TpVnmqiR3X)kS{|fe27E&)~ls<>s>+!zS zXWi85xa93ewe+tHFCRz0iTO3O8k36d7oIjlYF?1}^bmpg9Kmv%Y+)3Ypa!oCuM4!t_&3 z0jQOwRxC^QSbU=Dpe=OlTz!6CY)oyP2=lm}#Es?IwD_3RvCcMBJ=>?*$b!E$a!{U> zfVK9sh@3Lwhs`5d+~Rfs+Y;%=!}7BI;*f*s`c)vbo4m2w`h{0gB`(PyMp*_Go%uRr zdG<^Iu@{lW34M+i5|JcVs@M`f5Qc}#Qa0gqax%5oL8uhb)=Xzvb4`NTS)6eXAl*)%$4JQmr%Bmd=RFEQs$ zI0R*-A>g$U2j)n6;yP9S;f**tIzZQIJ{y%gdLTTtji%q zW4%9Tb`vbpv5I0yx$`C1=XZA1*FWaYwI%66TWF<&}TlFWmdt}GV zD|*}O4Z83ktgT~lAdD_UjuZ2(KJRq4H)wohL20U(_9;7@*jzU^dhOB{os;b##pAX1 zWumMt;jEppyEZ&B3P$uK0Bhpe)Ghcp26&I9o@oPFU#54rr{GJ9K=O#uJVKu?lQZAg z<_FfF5%$mkQDb%@>gQDl2>|=$m@2ko#;m3WX`*00SYmvSnGcoa2W+wE+BlP>kxtQX z{3Y(2w!miJWk-Rep$$xO@@O5?UmGBXBF~KYea2tB$?;O0?pB5)*KrjXw5%s0n zR;ex}Xx5)mn=y~7dySgQ>Au>yE`3dQOQG9&c(KwU{uU!H*@=VO;5oR?hH_n=Y0agPZ zRAJ0ndaIgfp;|Y2>=O5DDu4jB(`zfH)x785Z>CnUk|J1B62+GUZ;qrXNj%|6XLZa&NV*6B1HB1eW7l z9G%fMTrMMmF6eQWN_Oy>^H?0t*4LPGJei*k9bpdv35pj%ODy%YDllCM9!>qC+$%4Q z6K-;w9xDko?bex96(DaV=wN&TH1`9=j#qRABWXi z$Fk@NRF)bFzxX|=rutQ%Zm;SN1~M#*WJpN4tQ&9bOpY6;_2dqtq(V9wkKY#C5#p>Q z0AeAU+apu#p^ovWp#Mja$NwI%{7;&%;!3>u-QzJZHD~J*z0=k00HiI%^1j#&q{vih zNRftNnaNgoQb|Oz(W4V&!5j-2wvz~1_{_wNba%gM*;~&U(o45UDOCMV+mBCdnlE88Ue_da1 zb{=e>J1oK1n^ccIX@FbOzHXXl!MyeLZo{vtO?*pzUIKrX266#6AAF__g8pYxW>Icm zo8zMt0`!E2XK748)wW0aq}mun&0j5)t_Ip-N81auc%EoVR~%3p2T@wBZFpE?RkCK6 zKl)?>PnN}R8B8s7H5XYK&&%ZHKPJjn1 zhpi=rwok!5+lBY2ex_o8T?XFC9_md_Xt|Xo9NACR4tNL_a_APS!e((;6J3tRq%CiX zJDsR(44WlnvUOGq1%_IufvMNb%~a_h$aqJ+!+~rFQsVG${f*SvxlvntgEqSlpIELF zB9oG>BOk_HQCZf_Gg@}H2OqX`J$inEYYCC>a3nW1mTyKH##E^&AIZ^+P)Xr0SsXy8 z;f3PlL4VZms$-lwKgi6zI3_E@&WDy!ymDQl)4}<`V-sAEJjd9uAe|E_J_cD77Fj3) zsji>d#|W3+3yw=~>b+@XK%{OC4|_xpsO{di5hNksXfa|Oz0m@U>A|txXfV%exKGzv z?&R(L0CO)qqV+=X+AL}HcQJqKV@_?Q`b}^6y?t+(4PuNlqpG7yVLYTy(U-(C37Q!O zX3ZKOn@yP#QIi*@Cuh!n2d;f?uM$_S-Y7IcUg&9f$U~ITci8&(ABbt#62F!Zmqpr8 z_)yX;U*%N?ZBlD~L1%ZI3s71-Qhsc_6PqF&&zPerQk`j_th9AugI|4af!QsKN^p6= z!R3f!0l!FI^AnkFuJEy{s&?G!F^_^C2H=(lSv>uK8`Owbn^#d^W{q@KSV(1RLLX)p zumBoYYvP7w8i(q9A?D7Ut4ubGGOV_+3Qv@*tqDP5x?}Kpe^`5` z=*+@zUo&=6NyWDL#rk5~wr#6oRBV2+ZC6mSZQDl0|IXfL^yuAV^i7X*Zr0_xTko3h zob&fQK(mF${5ZyqY09XDb0*uo^DB(cJKk%`1-3D6Tc-W!Y|J>skU6fRzLnV8kU|-{ z;!5yJyawD#C}IU^l;6H9-{}IE{=?-Pf6_Dx&%0Wa1Jc#Y_qGKTch2|7;~nGDKX}id z&zByK8xy?A%PN(vokpA~p)=XV!;JJgizQX%eR1P;3ck=mVa902GPY!$+;h?8s28xf_A>3)9Z7HO)2agwuusJUXb&OAa=5==K(J}el*J@Mf$ zv8TZ5x7Izp8j?*{jAH*9eXZ6K(c0gay8+>CyAt61eKJkK=jR$waPl`iTF~HwQ&f0= zk+BU56DUk0NQzGq*%AwRKsWp~Tc65{E&3de!?%Y(AL<2yz1s7FEetoc!2^w!h;%c;4S+~heJanz!l{2YTjBVCn2L3k)&enCgldx9 z!NJaSKXD#veg!Q*I}>HacJX&NFlL41!dY{>ZTSh-2#NFd)R^ z33g%m;Bq6k?N)vvb?65qe5=iRd?-(i&?La>00eJ^&@lpf2|6dAGC z488mD#_*Bi+ceIxm8@c4+NqFBy3Z$oho8YLooxUyUB={4gAQRdh_zpT5g@tP_wn1I zr-k0s=5U-u%{m4nusEmVprU3D)4-eyUk@U)6!^_-9^5%Bm=WPwDto*Om#xvTC?ZrDp2D%}QEh&AJbTqV6G?4i zv70qiI|^aklRbW!SUmQ4tECW_nl*sgj;w@Smi6*lkwk3EK}nZJ&Et0t@M`0109nWF zd;r;}WiRfJ*Fl^Bzt>?OSH2T{h_=5?C z@z6`!OiqgiR4irzcZ{hYMs4`4>%2sV_2O%>&TJG!5-(y38b!dbRy@|P- zox2+>~>~-;g%YJfK`$uK!)F9q3tZo~?EDS-#e#SLVuNvDcl^bVJ#)Mzh3C5LT1!K@8iT zXItyJVO7jlmYb0%sVCf$H{w*7=9rj$zR7oJXKKhFxBR#aCJt_(&#n2Cq6-E)2OZg~ z8*0=nIsR$fCw*mC(!+x+7&xKn@RN9h;9a!0)_U3kYSkA{aI&M(asb-NE`sN+`!ccu z$0v6zmWkwQhkSHyPg%igr6{IblhZA8$CU8ltI<^f@D{b64JpKV_cTH}B%g=2&$ak_ zoH#mKSV9}f=s`ymrYZne#Sj-I${T7t48BCI&o88dCKveL^bR#aAS z&VtokG_OBAEERg9-M;E9`sW ztACK$Sz~NA^ODY}7<|;(W^1yoYY4~EJ>juC4z^^Iq_Ua|9|$Xe#?q}#t^}_iLQRC0 zOKM8C)QfrtfO?WRZs2;AmH4IK`CkoB} ztW|uRBki&52Kx9q5pJ}YTGn3k9HCP{XdP{;#d*B4H_iH&FmiXrcr)rMwX=Y(P&gZIMro#FjU-H=h>qztjDrYR(ELfmfJ=J?^sqKgF$;{;0n_kN<(h@mh$6IHO_I;u3CkBIf9@){$Q^+8_}?mNPAkYm5D6D z)5{(rR=jaRZmY{ZjCm`l<2u?uJpO=sp?d||D{^JBnBu_HB&XYG=s?rq@!a7+WAZP?((qu`Ti%SCe6k9mcX)~*yL9_izkAuMeSs&n?Rad$pc=m&xKlPg$r)fg%}5b= z?j2X@y!NRg&IZKHmry9r4n6>>3!IPB46`}Xb3H)>u#PB9j zP-UfUDH=67twFUvG%C~PkK111&$eHS4URuU@l5Na=ILR2FFEWav0#?lqeLXOhV1Ty z-P02rRJ2OZsnS)rfUY$la>o;vb>yI59tc!AigkxlO>SoQvXJ+8e?^6Wto8)V z^Q{$JAQQ;a;R??4@d8DZIyP zk7@@M!pgggO%F|qkBWD>%?p=a?aX(beOb4aRtHsP7o1KA{myZ1Nc{t@zad1`q^>)~ zbd2L*Z6=P4D%}I?T5!W|@?yPq= zCiDs6-cy`ieLjzfbFDDK;BWY|p?vQqXPuWCL^{pjc7Mu?i4&Ez0mclCjQEpt7(L*$ zl3TH~9KS9no&v5Ag86s(TRw9NnkQagCAZBGcFNCeKh8G`ssPfQ1FbVlzWtoB{z;bE zf5`ZR!vb@0!yABsPwC09q~n48Eo`x*8I1%{Xij#L&o0Bsi9n>R5w;9ua?aQ|QkO>! zxK=U=0VNXyPg%Iv)04>^ac+;b>DZ3{fcdPHc61(R_u1r~@`|jGud=?Zz#%NJK=Ni! za$w-zY!VlH^?n{h@90Z=Zn8sCTWU{bDfE&O*jw$3S0z zt!mvxl?F^8x0E?;wS}cxP0ZEGF=u1b8Bi^`&`xXk%K8*nljEr^{xMbS(-aN=k~a4g z>t9B@Z=6NZN9_^v9b|FBosw4gJZP656B&xc z2*znvz;bQ^F^bKls=EuB_S3&7@CT*hQ$<5)?pQ}{;tPwucJIKF{B_Naf!ax z8FxqIwDTKvj?Pm2<*NsuT{z4p;Vl+6OySi&if$j|)%1@ezR6v&qQpb91qvXke4ZL9 za@p@MHS7^jNqAJRQfb|UUT=tL0WGiCMZSu&<&eagLkfY5iP#VFnpc^rs#s=(ozmCq z{*o%cV~*9d7x6hg%%whE*n&*7nO&=25KnzOSQOUjHRT%^yGq8&V(&beMA zk-e|Hrx<9>We#KebT3_n|EO9UE^9qR7u42AX9& zSQ$;*;GGnabmXt?Cypg{NrVHf$%C-W_#Q?l^}sSj^yg;XYl4KZc5>d$HL#WRysR&8 zGp9UE!A)0G@B8ISojhiQS>zU0Gvxsfb0}m~*~62&M@6l5TK1cB$FaE_4k>4TpfKg7 zhy@*-mJIG%@QI^r@pI==KzAn1q~5|O+PZ#+&xIFZ` zt`tAB{`OxU9D{G;Ugz}dY)>C6UcDX6VmBJN>pL95a+k;8F;z|(U4r~v0>sFVtRF@s z1O)#9+b7%Go*7l_7*;i_2AH}q2Rg8KS>Z7&lYZ+J%s2i)Qi-dY;+bt?8-i#==cpTM zN?fccq2?h7B6kz>yS_)yd+)-n0Ofm^g(lvA<5CLPf+{PI>LoKA$~im%uPE{KAXGe> za2vjQeoh&3xNoccC2Zs1>;r>#IyQFrN?{N5ZnR-HD?^wq*)S76^@5$-u>T)5AX#O& z`;S$v-nO^x%L72o2ARYMyQ!5@zh>r|-{bY9EZgQPf(&_HOg8d3izAiDy^NftTz)g3 zW@8&* z<8;$+Jvjs3;=*ETyd<(Vig64ToLoV*OXA5tC~exX;7?FQm43Pj7j0AbofNz)5LWsj zA93xK#D}P?&Uz1*tSyED!7Tj3nL82AnoM&}IU!NuWM^d1%9G`o2@8+SE$)P8WN^4? zLY5Kz=l{Y!{GXsM{}0y0H(7%1<+SNBgZTmLCp_tY-EW~B{s(Jj|fpQ_iqrk>j{@$Nb1p^ zuDBb|ke9CZ)O=e*OTpCb+KgsVYBn0Zf(*h^)d>fB;svn$c;^~E&RlTz3+YE2*sDe> zc6XLGS}PkaAJ4|9Z}7~W>pCjLg2vkBkps79z7nO`PNp90l!3jC( zqdWV(^z9_!>`RtO*C29pU2JXB`1CMUkx_qdK4m;`|!he3#=a zt($V+`RMnSVDUuG(LF(H*HrFf;JhZ!9M(^%@`qd}vEwDl|LmW6=F)pbpA@m#7;@a$;ASv&R4-v> zMv&Jto2@2Ofq8M^zpJU}6CKV7gg;WW-+;ib8D65Evf&&1RPzw!Wo3?Bw}o$hz72n? znWZuRYY|Zi(`jTO({SI)U|U-KQ%@y${wX~cJ|y6E5XM2_Z$0dD^m^az ze7b9Ig`7ZAFxGkI{q|L4Jv&sevVBu|Q@yjC`S)8%C@oFomaS%S*KzdP7DAtVWlq3| zq1@DL0tbzmIuzfCCUwq{4topdiV0g&`F5dm}ZTM}m5m z;37x`IDHfpq4-Y5+;ugLb%uDM1VUpIaVc8#?thMZBm*vJ^u z?3k>2>nbHxEubc}{+h-C$nV`~)m>1`s;mT8->4)dE{L~MibleWJ&SAwquxFcUv(Pi z-3+4}BUS;{&NjtPw593!Hm;y;_aZmNk@%5iMIs7*OWwD>u(#@+?)q%}_Qfb{sjRgz{Mum`p3EiQkHlN9 zp;>TJy(N1VjaMdGzNA@cP*L_Vw~(?ENa<-RwO7GSfUkZ;lap}`iVP(x|J&C1!z9c@ z0`BL4WDn8Z4{SS$>FfC_&%%q~X>Tt`B?#f=m${YYcPWh2UNLE01i8^QxhxH*4FRXh zN42K)0&zbe@5~R_xWhb|zBcP0=oOnKVPXXMg9sK<`-m8tFQ;@pmMZw8N+96E=n zjk~LM6thbjEDzanv6;)C=#mzAZ8h&#Ixdee zD{Z5>T=+Gq?$pbsui9i**#Tny5kO=3rN017N#IMVB7E+_3}hT#4g$f?tpqn7n5nr` znDTX1*%iundV$G86lzQ#`+mzaNA39#b^=I)geoBK36Gi-9shUCo-(>C)F3yxCz$f% zh1OmJ>xQS{+<2{!9HNb_qZbsHl4Bx&K-MUkTt9}5=I#Y-vsiOOMpxs?$*A?W?2fnL z+|jeEk*j7(quT#L&_-+-Evc~kkp=c55||R`Td2V^0cdd23JL**IKQH>xIZ#Qa;J`_ zj+iXB&4@m6Oz5~DHDxk0%)8Il4;_B*K@!i4A7jR5x+hFR{T+7kJ&y*NKtHlKJSsp+ zYjZ&jd}b;naivh=%os!h7d(;CAAzzH5+SXUNNyPgOJ3e~c=2F2eEU&R7qO{Jv#mo3 z!>p}oco$>!UTaRqNnY}0I2$ETYL@Bo{LOGFoMTPZ%}<1=E}j|Dyz7~}(xdtWv~EqO zKA`Fi7GAfWkwb=umSf~<_zDe`ffDdfWq!CpE5;zA%w&m?5%uPWXQ%tQf|@dle?xwH znnuc8TO9-SLZpxB>S`W;6lc|@cGW@Uif8Xl1-xb%IM;aN{4LO+xa=opGTRmkS+|cD z7#`RDaE-}JVKiZ_8caAluZCMm`$Slpj`bqiI-at&srY@~dz8Vhyj9o;oi4&1ghM}3 z4)_^x_d)Lz49~LKsT5lrcyObFx!CMue$GaU5=a9cGTUQ(wDf!IrvDcaII|dsCDrJe zNjX`XW{fIMOKL=lbN(*m`3L*))&_MAfpJ4uX95P94Wc?!`Zx0w zt*ly>GRv3Kb{FP@k_M*Jk;AItZ;(OkTe&Fi?omd0h%sZ+{TR|H{Zisz_4Ktoajq) zs2QNVzoOhQ#qHe}OCk|X&0Cra<}(q$_ryqy{&0`o0WlHl#D~|~!N}{kxYgzjUOj&x z+Uxi)jIkE6iz7RMMuWsR8o=DkSWLaRZ*CdabW9r}c^E=fq|OL8GpQGp<0quFEvR=m zF_x*x%A9kZ=$U(#)mwYfE8ynpa)u#9xXa#kypj|CmYPlflj&UlqBRNiG38s3AvA_~>R4oO z#p>GS^X-`YA|0T9XB(@MH)SnW(Q;gBqMp0VdR;tmAlmD!&YB8Tt*;e34Tx}3nU|hC z)aRESn~lG)pL83rx9M9k|0b?J+L``yb+dwd28Q5^rZ z6qu^m)7k1ok&@+}!9H+9FK?Q5`DU)7^v^N85x$C>T@wKhTGauliypX>#w&Q9wvZ0&=tX6sVNr0faHjai`{f818C z-R4bn;xEE3kDO;#pR-n-w#yH1#F%6G@&(dWh8QPVa^u1$LXYaCg|r8KDKgrTUPS?^Gd4<)df*Syyn?-OP8< zspi}!rh%1hC~rXFE-tL{?15Tm2Lla-ZMORe=kCRRb)1b;-gU?@G|s{q0Ax!NmFGX)N$}%m8w3bXFlsH!bc;0Pe?FMw;ew88`r5ct>@4 zE$V6u{ON5*;#sf(Q^HyIF@oaf>yc7}2Wnu~84>}zXM`|s$%QAj2g<>4b2y88Y+bW0 z+oT@r54%m}(i`+`PEI_>QbYwU@$=;s;xoH@NBSLevJvbucGu5&8{|5Tgk(gEtlDam zgezVO=k$egt-wAP3oT!ROgrb4rBK<{jElq(>~^o)labgSh`#?5_nrT5)y)6%W(KdB zgTk44quxm^S(5*Ga*!nb_g0w1;Z9uWf54uEZmO6b#?Fm1@&9Ij0 z9>!}or+hH0l5g*DF7lJi6X3WRq}g;Vrxwkyww3l;n|$QBQU;vS28^Ha8E0B6QY`fQ zwTiK@k()+Ao>(rFEZ93T$PY~Ig~rsOj#ffZyGLZU6j{hE*H_yc6u1sWgw@D_ zM%Vp>E93EQ?b!PdSQLWE-)TCF+f*7NxBQ%Z_ zNp3qt;4EOtbyt!Pr$%ffG$EoqWwpgo`fweJM1CRDe1|1rat78L)Gp|*efVh)&(QiS- zc2_d)?=G7_J`Hvc>91IOLn&bZUtq2+>eGe3PBxz>aYfHRJPzB1E(@RA5n>$Qh(sQ~ zf>mho@4MR!c8a;(ymXj5q0@XzOGY1b=yZm~{bg_#UaWi@mNng`ldo8^70`~oX8{tq zVj2H?=oS|^^av|E}!o9!#I_MMno0XTb?Kf7e2eU}|DP)D*=KZaSv< z%5LTj$?3q+lpFIMSwa#TDvoQD*PRrXkZJoK!ZGUEtTM+Lhn%hm7h-9nN961ZRvpB= zF0(SjHRzT)03Th0ymWnxyCWi?)ds0bbzL%xIEGme8{4i|&~1)w4ZRY;QWmdot%&N=Xt<#_E0x*fuLRG1m5e zDYM(KLy+J6vrzx5%9;p>C3w||S=}CXX1R`a!tSI$;7d5pQDH~Uz`uv^W}^T5th z{KI8}W#UhYDj~SQq1AnTVC2gnK;pbD#BsozGS5;Rw0kwme~qII3S&Fz&F<_BJlo0s z(|c+TSir;{BkI5V#s0lQTNq$R{u*&?=wpAGNB!NDs0(?{GfICA`E~$D{^M3OdXMUC z^SjkAvd7=)g6vd5sV(o+kjQ0ehP*ATXK&&nUWkP>CU+rfwqUd%OK7REYVo0lF(3r7 zeyrTM%gZ9@Lex>>z5GU=o_a#BrRQT^scBX9M=~xAVyPH$oFGsW!)?)5M%GgzqaaVy zTT^aH!fzflAcToDTtcK|I<8pdp&;i}oTY?ZW=~&;wk^IQ&ir%oDUuzS z>5JX??m13OuG-fk*!rH=CGhV*U~aF>J6~)Ydjg1?f2#KMKZrYcb5UK(oAR%@^!ulm z5Cc)d3eQ*WR~|*zT!aNAILqGhyq?^1R(noO?iA9PPP6>$f3=L^ikQ<&BVV&V^rFpg zp=w<$)G_|W?iN12iWJgUN8X*UVQH13v3ai|kXV}TIQ=@tG?PuarJ!PYZZ0|bQPF?{ zIE6%nI!-{Vkp3a&fr6-o?kij6KitWFx83#L{JIA6fb_PCKK`i?;_ry3_3_}NvUCA# ze0nU=5g3HJKdJ*8y3QFLquAt&Of4zCMEet>bOWEOz!s5&<0g#0K$HsZ=BUuwISkFs za74?r>@he&`aSW+i)%GPaEh*G( zYpcv~fx}1MqSTAR|GVN?BE~>wb2w@}6l4nSd*8#;APUW|?uk?5=+Ma%c`_YOH=H{s z`zS5%kI1ghLaosl+D`2-e4i9l@J!n721=CIER@)5nFEkyQgrL~V?(1RIF_kbb6MWU zq~vKxjw|`8duVyo5X*?qDkl`HqCQiK&Z;XYVN-TLPm%_h$Q!bEWna`x2!)($A)DM$ zDr32OTW4S*tS-eNX8P06_ZYY1WJWE7a3K*p3ZHi1RFB4zkzytMy>A6}=X z8FhtGbP7zb=p|w1?&KitT?dp+P(PpByMFOr2BBu)!>4tqteXW-L0DEdOr3CNd$3{{ zNe$@w&qNGGQmxr|hEK(EcKwd>_+L0L$1cv-Ysde_c_~b`ynf@nI`1D2_^mbhoknJr zdlkR`J(YqS{{bs-&;T7fM!!<8_Eh-Jx>Ha;v<-Z00q!LS{0Y9v`)OYvT{k_ZFNOh2 zSH$SFp3z1D))-VcFLgn$F+Mi12R#S-JwLV%^hQ232mBv?MZWxse7|Xbi6!=Z7Pz@- z@vm!(bh%mh)r{!V*}Y;k^~?5GSB_| z@i|JF7Hlk&jTCwCuOlTbW`6C`R{uBXGHTaB;)9=CXFdwDeJQTL305X}iK{HF=G`7ILVwq~KvacBjp^}%-Z z2|g1{TS?e?r;U_oV)c5b0g4`ld1U*TdUno?jm~AY)(%n;E;k4V(^|DN+5OJIbe!bL zajBxlcj4?mVDn+!TU#F+Ztmg)ijeF3Tn7wr=U?K$YV!;7i5Eq$m^~`_-|uvLW;Wyy z6Aop{!U6!a%*_jfnV5REr8Ytz5hd4iSV~G*uSSeRrm_dyC5}M%QDzM+N!Qs5$|B{p zpC?8A%d0(RATj{}4EM4`CxxG-?gr(PqzJrti`BYIVQg-8NYt+nTZNDGftVck8R-Yc zE3v+44NT)bls0*5a?mnLNPD|{QYgl581XDurfJaXE4)97b{ydgZiBuA&}f|9!NV8Z zjzjC$ApCISCVt3vkrYdi|OCNd}u5xAuE3jy)GMcvWFp65%<|!gX(we zKdflYvlT|72Tqenp@@pY-J~t)N&4WsxMB+S|4Kn!;Uw49&ib4ZMc}tsjpnP!oi^gg zw3*kK!|8U7n^v&irjc{bkwh*c*NdQ<@ZhKJ8Y_;iFpEc;J4UW2XyA!OwU#a{;;`W@ z&oNOJys^_wRX8BLg_0b5H@r1kT&A=#E~kEk$*?oiKs#j4CM%EJwR-s_i{?1OJne>1 zv-wuf&pM{aHiV@3s%HLs+Tq7kmY>QBwh$B6Pq6dnr0wa4Cbt1Mjv)emGdA}cw5Z;x zKux%Dju0(Xm&oD)PEe#dA>+>BKUi1WC*};Y<6g@W?7>OA3cRVX^UUsB7K{e(R`7M6 zh8%@#tDaU8cLX#(>V`BfJvA^y#BnvqU~t$<9BHb`{l;DUKdusKV5ro_`rNluF8!`a zP{KIscIkJ894Y^U!@}2PHcjNG^$$h&pDEdkDh%Jxq`b%bk8q01B9$n`>a4nxDEpKDx1+Peqp#8XdHLjEpYy;K$TtK+A)%AS zGa{_T*cBU(=)t)M{kuB-^(%Z_i^UDvGZquk9unsFyPJIk&|F&gUx}`4A4hGR z#V$SVm9odjd&Prw4UQ(`v_z2=9Ae{0G=H2JeDbtDJY2-NK)=S6q_XetC#wnWp-Yf!^3Ph(0kV;05-$0fzLb$6`o7 z2)aD^CO=cyhtndig!e}o0`siobHX1}RbXTSIybnjw zS}O15MQ9!bVbqLcM|-vAroW#ozWbJ2q#79ubzzg4V*)S>=(A9>b46h#@XXyq8}-#I&zp`U#gghMQYgv>#6vm=y*U7nJ5v|j5hsE7{ld0o-z3S zvkR7z4Z&T2_atC7DuVPF+GOKUyJKBImJdByT2hU;BAQ8Or5?zi2V-zD#}WH9OPNAg zB4h!OmePP?N%)9e;Yub4Qn0F%cdcWbpwkWF_Dt?xd+lx|C>h|qBE3}bM8PAkJGO$I zjda@bC1IDvF$!}BJyRcJNS1n*8~Y1LDec2>ifEYhWYyC89t!qdto&fzT*hKOYScqK zNt=r*9Ft!423At_meR-=ae9i+u`+m4W5`KtT##HNi-dK7vLf{@c?BYE zsYctb@J+$WBcj&^#kGqWPFZJVicc4RhkQ}*NSoqmv!w~$1SfW?^AvTI_Om6-#Y0$^ z*OXP#DlSa3c2FMSflHIeeJEnC`Dh+?Uhfc8x|6m z?Q(;+mw!!inQ<eCn3ee|5mXs%A z>an)u%i_tbM%wClcu%(%`?gqGyZp`(B%a=7VFn9b9Eu$J&{nwn{dGEeq&zHn&Y05l@ z3)7*LqanJ?9ZY%MrE5EyW)yoNG7xqN@}X8E^y-87M8j6_1!9|0Xtt=VROstVVOQ=K z$!n$tKVPmbDLxE}!fULvb{X#ve}H)|Ep0vmY5n5+mXHG1U2$rH=BQcjT<6KcfOLA0 zF_$%H!qzJ$KwpUA_>6(+_x6~jwNf;f##^^V6va!pXbpq9i~FM;S|2CiA=sM{XI-bZ zS4`OPtb*^^vZF26+nx`rLP>?rGN$yD`weEQZ zQ5jn`8^ayn$a_9E6IA?0WSfDq_bJ3fEszmPR8;v+mc~=SSzmK*OZcvR`Y;`I3dH8{ z&z-PfKoELnQy1D*>SdWEKtVlk0N9bPB#F=ngRkw+TXWVIncOypilP5-eW=NiqaY`| zSDANs7$NB1{SHA)6LQZC^LUSFRDZmT%;XX8srLNXbqLtiVQs0Zw3!Bl3lNpr(2Z|* zUUcfwm9U($(lra`Uti#jPTH!DRb&=QSC>tASX0w6`4iG80#0bxxE!{}fIB#|&$vc2 z>Y5-#Py3E4rjSg|C}_;+TG+}n1eqjma1(S=6%J~m#3X-XeutAWG@@vX*ev z_X{|u z{kTbKIva=*P2AmpX~3wGV+g*w7a!!@c$Z z`(Q0o+{-#lcD#%C8aH+7oDDg-MuHpY5Gbr-O|PA)C+DYDkMt{q_p{$3#OX{$=%F{||0scWOZ z2m)tZ64a2)pfHLsFecBI167+qJy|NM{2-0FHtcvpFxO}OGQWlq3vriF=a}9=m9ARi z0Sl9vfR2)Y53HW|_lJr5?h5$FUe2X8J1J6sXm&S2oZQ*nUaK(a(jO^~dE*s;x(vPX zOU2--6iW>+<5vR~rlN@55;jkzTUj3AaMtK*Xy9MAL<5tJwLv3cAuI`|BE-|AXq_J4 z{E6vbzfa0!+IN!15vWR{;H7!8B67*daudA)d_X>c2EwT9l)UVygEJ{DBQ43^z!@&~ zG*`FZL%<1>r~THq%xK}P);{}q{B5Zc5``c2VK_sZ`SD7T4>NUJUu<{`0CMHi5Y_66 zIr76%Lu+KlVJ`+_gAPtC6@R>}EgXjrEb0pnY(t?B0%0Bm$YRrkrQuWJAKMT?MSA$~ z9P(cxT>`b}iJ1{O&`3+VL-m^VLOhYoO!K&A@1m#hvw0S(JgRR8$(XJ#TQ?2c5=Y^P z;D%Px71cg}G?|aBjrXLQv0h~Inyd$#>#;{=#IAo$(>I=zpgP0x7me#<@z6LC39x)L z2YOH7sLY%?T>Z8k1i!?87{YgWZj;n9!8g8KPq@CYooP|}4;X_X5RVu4kfb ziiG)1%=s;gYbtX1GZKw+V%ZSq=gP?lcoWGiJ>Vc8Fp;n=3guJ-&U2 zeb=B;Z^3-z>iqSu`;raq=}Vb7MUcIWL`&rj{07d)Jm(y+iu&@(QvOEUS^noRo^Z%I z8nmT}G0;&yEbahtxc?75o8)q|&r|=9w%4W``)inVPHkt;SYnx6ZbT0sQaC7$A`4~q z^PSomYU#4Tdt&_Z8nv3(crHE?Nvha>et94S@}fbwkM~2#4g-f5i`!0&g%^RrNJ-0s zu(lPzLLO2dt2NO7cc^ipB*#t9l(Sl!W2vUg9R28y2||21iW+x#COa;lPXF7H977q)K-PF0b_QXdbZ*ZuMw$a!@f1a+J#Xdr z`ERV7Bu3W%nb#rx)lR&LQ#Mv!;xgsqxU;f9e3Kw52(PKqHCSq6U`2a=P>>f3w?bIN zseMV{tagvVJa7s<(B0RTZ$|}KTMxkT{v=<(HKSkH%NAn=dlB?&-J6&2?lKL6>lYFx zC+sA+F(pS)`00qmYlEc>OSN>!LlYGEfhIM0fD6Zp<2B~-1J`PIb;{C48C!Jw4}Y}} zOmE#A9&u-cj;xA0{&dJ$fMDvWtxg_)q#T`X$7q9v;eUcve>f7sKqt5PD)+nYP)My?(OPiVLC82 zJ$6Q-TdO(vH`H0Jl^gFaZxLqFMMw7X}G^O zqxqH)_QKw1!X~CY@cj}&{3Qn;=nlBnBd$eT>lO!PaN*kzh(iG}YEWL&xW~?e}Y9#Q=jB;<)e^ zMbCeZF|Jo%tIwsLE#~G1$aFT1@%ZTkXBw{D_?r4DYdIOK>4e5uZ-j~F$@{WMIiG6j ztqO(4A~<>$@VD@#Q(d3+ zTWcyp1(lf!uO?4$jwWvTWy-i`iO+Vc@w*Hwz05E_E_&T-G$gL#I=mixw1xs-HTVJ&uS^)N~C zd3K+iMdG46Jcd8jB^V@&mF#aZjy+>D@R+NBaT6HM^BLHB@!UdnejxVjGOnjIp-W4= z245{LB5LWML}*N;BCt3M^J}1ze8nkkV3f>mrG!O$BL9-zxG<+N84;A?u?4kg-whs< z$jk2dOaX6pXYBR)OOslfrXV}U!&Cv;+_nRKFv5Od$XrLqThYd zSI73&SNapfsAt~)fPE2DKj5N!0`eXOJKsM_+$Zp5vtPGcp5XT1<2nnhoe(`{InQD) zCzR-FU@;pm<+ha$@2Bs1xr2j^6m*j-0d=2s5D&h6bHVbuU78b?I$U ze*h2`p{G<0x7Bm-(Qa?ttNbF>s0l8?WbvflEKBXze(NDm;&ZnF5-C|yN;-VI0`xmR z22D#*@u6sgBag2OG8IRUrM|#(M{AVhiY>@WmmUDx^K;pq3k%&v0_3e5PfxyUAPCvb z&otpFvFhHv!|p#Dq9${VARSAzN|W6R`BL-#?4_*pq-dQUgG zA+K)YH+mIIhjlhzpPjJtVPl>mcst2o0oL?!Q=6ut+B5;tbqUX6gXdVcr+r=Pysgdl zxhN69W3k^<11TC@S-opU-*Ee8ruFKrfvT+H>eBUv#e@gEtWK`$-0I!SkZfJQ`X0P> z*w?wn?zrqIOy#*3X>4r>%&ace@qZk|=YqXiIv*2U;Ga>MLeGe)bHwtU{na2|6;}|J zj+io!*M)FltH0e&%1JY?bl)S9>qfJBlB2iiTz#txiX;cCJ_v`_lKF9dU6VVv8D?u- zZ?rV9G9w(V!)msmFv7Ie`1m~nkH|yDl3vk<;)M=tFTlg&R$xZNwEXHY)$)O?NijOY|l>mfDS+1 zcMAi;3Z{})4qbUWSRLCglg>Vf)q1*LyfyijfpIaf^v0XHJxlVP;lG^%;!d_4-(Fm z1TWba1^lECaq6p%7ZhcSyzCr+kt8P3IL%Z1f3fyXL7GGjw`QTc&}G}~va8;*(Pi7V zZQHhO+qU_ZZKI2Q>i;Ju=9`&wF(=O4WMo8MWMo9<-aFS`>sgEIkWBYa(ouT(kl)qC zRR~NEs`NWgHNt+{3nIdcIvn(+fEJJ-g^4PHA^(0Q!ILBrGcG2637$ruu(vsSp+qUI zw)YpXH%7;hE&s=0da8k6R5CWIz5~O9V%^CJ&M%s+AIwnCK<%!b7ux1)`_604v-QU7 zCJSJv>H0f%^GR-&{X-NyEj}L-Iu~->i@uQj7}I>ZZjjgD=4dzx_LSh57i^}9f>aM1 z>xC&FG~smN_uJt9LI*V437J-N_?cR|OjK}rW|6?0T4x1UBJVJ0`iWC0RhPA1T%g@yvy(o0gmoY&K^p~`!xjkg8(aORSUFYN(HCbe zYu2%&vVDpxZz#y^e)5QmC1okd3%@T%1$P6z8N%E(KAhV~=LIJqmAUb|b-*~C1#8=y ztk5_&bgqC-&}o(S=e;TS6DS|m!-J!WHpsa@cGK%tu#149A`r`4;-1>kN!^xj5$RS{ zOg3qDWmM+m&7ot>L|Ao&@QLBcoH{iY`4AZP%6Ta`P}WvkF~VFlthvlaQHFs%p{yi{ z4>;$gI*IJ4^o5&MNZE2B8A8N)D=mQ(q@@6*2y8Jnz*EW{Pfq1v1T?;0Z8`8n3)z#dTTZNQr?5rl=6fKk+z?@wR+tUGN=sBAXFI z=Xl}G26Pl`)Z_KCWLn}#rOC*P4>hI4x)z=l1ZHrwub{P&95t8Zek_E!M$<_Yr|&*#^^E^kPEVT&Ks{nckV?#mrz@-6l z>CRj@V;`O=(`-0y-jr0lnReSO58}ngzj)zC)}DWdbTTh^(ie)ej%{TX!GQ#mjt_Z^ zIIfv9p#4M!X`Hzs8BB0hHJlg1E+z$x=u(KI)BYqzc1vKu0&8z2-O{@3TzUQ(M`uGd z9l{1yp1rd~@3$1cRW>WoB2+Z6bI z!GZc9d1eFF$XZ4{H!lujRX8+aq3^ZvZkE;5+#l7&XeARMC+6KpMTSIhGSGx#n{26sA&nzi1==cw02be@QYP7Rw#ENDk4;Z=C z%9m+6t#QDr{X@+JAD?GXbpbjw%ls3ua~|B!Dr|SNi*Y@bkq0Lr?G!0KBx}k*NORSN zaZ-@*rh@XVWCgmUy82Oh0N|%$?f)ZV)h~S&#pzmr|0++79TnwHAt44bZiNHB-%=l` zrZ*D>D+!@LhvX)Y7+364ZzmGJ=;;!ErlI;;jY{=X4H&T8?Z8bx&CXX6{jkCc+ZiTl zZ&ZGuF4Rs89WF z7wr&YQR5=?0h1kFM|5P{brkOHa5ui(_Su{igZ%=>B4Bd++!->NL{H!Ar5(2q_&S=Q zgM9+U?5^R3hH1nGvL=c~S@&(jdM@1Cf1TNUu&(|t)FE5d|ISjb3rtyGAyV}`?ZNr;~S&HK4)JB-2(qIw{8|zDy zebY7k)MH2hVY7Jsyni4#u6eqqxa*HxCr0{SO;3t=JRY;LVZS}YWV~WZ>*)R*@1z5z zc}lJB{?zEC64Yr%epK7V1M3on6kqDBL`PgiL?Ko*JtbZYZ;lGqnc@lsOB9m^2w1CWjK z$N&SzzD{J8UQ!7iW7GVFTIq$gLEWqwbNYkBAt<^s&w)@(DgqWA^Z2gm7GgHAn{R3+ z#xedEty=VTU;wo>-w~LoYcdvclXRX)IAEn_7`TJ%Ci<|Onr@ZFI$mSfgKa&0`e(pI z|D^5}F%T^}gI}LThCORH>rx$;_lE%K!k&p8Kw_}NZ4xUV1?hE<$*q^`gV&EdDAhlC zZb5cs@Q|)HlXfmDdHVEgYe=wtdlH=}v7sFu~8w)T_(eZK{9gXGn zdc8XsI3^?0(D<=LsGNG1YcQ zzeff*_Jlk6uJ*l}*~ySp};naWSJN>MGotGnJzJ|HiI(yO6N7_CtS5m7_42v{VbH6m;(B*%|`B? zWx}TcarV>IbuO~%PDsH3P;t$A}wW%9`fI6FcKKDqmj+A9)j0w^t#b8EJ95wNkP$e@7&+{@lwTgv*%a$e;^m_ zs|TMI&1&e$vx~UF8?fyqJsoB;!T!PLzlftEUwfSNX#$b(G0>eSon0Hnc6Lm42p#7KybA zyL7+H4=WHWN#g&?RiF?uKxczKYu9XiUT||ZXz)!*kXauuo(gfIr8)6rUGpv>x5?Ti zd`E254-}CFA&};`s0H!i?r&T>Ph4tQVseKSI7}z9I?oc9@MG-1d=yB08}rA_1$?3! zS>ob#RX@Sos}239T{K|uaF{h$tXrjrkgXrM%D5W6bp<}Isql3yu_b@1_PSr6ak z=cH*(Q%pAYa9u1eh0lg;XQs*mTKtI-FE)MzZ|$mz<|=I-idp|yHbml;j2Xx2F#TC3 zr(?ir_F*Z6F?(PYN)35h`?s-B2=;ek#B8zH&}B=uj+;9UhXm35stkX_d(q9QOo$IY z1=~%@th6-DmwJZz(;)IHw>pM#*AF z12))l894AVMSAQUwVKQ-O66###vCn{7xt^z5Nz;7djJwH5mV}1kAWRPGx*mL<8$Nn z;@e+YVfzKv$S85++4hSAJXdf9Mwaw8I#xO9E=XdOKJmpoM)Cq*Gx%s05YKa?a&fF;Io$ z))?C8WHZ>ignb~tM|$_Nj*WAN;erJGWSLRLCQA_1Z(oIN(F6Fo2MLl}4^QD{Ox@71MCe%#)AU)c?6PtH&+|=q$E@DN`W} zN1tyG_%lD`6jLrmAkVtgnYtWDul8Et=YO1$Uu+3Ya~XYcz{NYyPUW>K<#P}GbJY-W z4I@{mBV{ zGLK|M*W`j57QSmSbakRM*OoOAfrT}0`d6AW!r1(e04@8g8II$&dP&Q6`NV^^!A>#x zp|Z`k%D-$QvQ?eG+$yMeg6@8|%cBMr9qcxl<-@e*`MrLz{ZhIHyUDQ-e%kc%sgO+X z5Gy{+yfH4+%mz~EzmAkO8xOCW*4l71C6XH=VgCd1jgD69w7XE*;?Tdw&#f+70aYL` z)J9<2d@V{`<8gW+c8(S5)i0rzVtY};9-c$3PN&f10tP+c8mg&P|ID4bo@d%7YCHiC z;w%^#ENj~JDNg|uAH{46T5zK?ktG&2OC;I^CpwvZHL&|rECFC@ltUV!ajDJ>t8a*L z88TP#U@F8DA*5Y}ERzgXJOlGC;T4dO&+^yNLz<^>tDy8}nYbdY24*{6)`S`6iRV8? z4uEwOIp1D*G8^~7!qL*bvTk2&cz{*S9^R@)+#?v^tviE}GD zj2|?O*7Sc13$rb3f5TC26%|RvWU(DoUm7Kf&aMGQEmfl*+m;)3h&<*UU@ARPs?OD8 z4mfI>9SeU6`CI5~ZZ1%6QjIV*)74iyM{Sk)YQT&OZ9L_9OY=m56ea2b+2oV!=BJk4 zp~f{b(GP%Rzg#1!^WKXhw$AL~4SwW{L%z0oBmqJ&SYk;Nn>baD$Kpd^v#DhYQOI}c*p zZJF4I@^_A{?|zq1ZHGt+TY%-Bs`?QxT}e*Fv8x{1Jz_OVKVTC^@;=V$COK5I&RS2*%@yT zUSeF<43}Vjwc8qLXsLSt%YRWpLnn=%lc~#z72klG@z1cM6eI<-#DHeYo_kWfdj+0+fQBGlGFgNN2Gt_Wa;@JXRsvOQkW20Gn>n0J< z%M<~tq4}=TkZ^reTS>BbR3<`}jSk9Xd$try*_xF#EMuLD89KeJr%c_u*3y}W8pUqr zu}GF@&340n?jMNSvR+fx&t%nlwA>V`G}Lvm+>H|mmxD|yW~-$>R71-pm(+4CHiMzM z^kJA@t0eIveit|lu*e#plta4U^UBJ?u(S8)as0g;X#+fjS=<-9Y|BzqYX)`@TXrF921%)!%sY&%^ zSCSN=@2ZP2F-@#a+OtSr^U`HBAx^zGLKn}?<5zJ z)GH$3pO}sw<8b2RV#a*Wgv4uI*u2wv0=b%PscNZt>sUC^SZU_Sf6e)rL;2#2;ZUKp zu*yvzzS@rLripH5BpE0OK+33l5rC^>n?MwH&vvHSdkwUtAJ9f~&}BqFfQizv#8B>U zqT%pvIEjCY42Qf%3IO@*0C$hXf}r_coJCEEEVb)L?&wGo39IDaq=aRq4=c0);OD=s zQ_^K$!aYEj#Z&H=ZS`>ED=K=NdLC-9(Uj>UG=;-v#rUnKg+XNhROWR2LkAsI&hmH= zE7}muu5SO}uV|l0U5qWD0{mRYNC$e>`D0E%ozQA>d;ReU)t!c#i;{=@QthCpy^CeZ zeDZIo)RwIuJz{ny8f7T3m17^YJzNc7GIKzR*Syu^(kjS#vN7a-`sqiYOQ2>NPslGD zZf3bySwcq66p=}r82R%k$1={ZgI>XU&|nb&@JFVHpF?Ea4cH`1PY9^hg+zmRae4S@ zJx9MwDl_hZ|FaJ$R#n)%pzXTCJ<~sv4!6O6Gvh;_MORV6P`%v+yckZr4@pEga60Pl za!je-0xY*07njvlz`983VcYCyN2g}2fB)z&&hvmT2y5h88BFleA_Z@unQR!x#r5Ef zo~~EfV4M40mjdy}&m#x^{1E}xxH-;xXtj{JPP3_>_8ICme3kdKW51H$X=o}%WidkK z(}rLOY04*SA21CJml9be>dE&JpF4~gnYocW+CvSVl6s=jbBIuvs`=!n1}W>$BH!8B zgOPsP^LeN(1N3@{oJ*}3e;iG`X<%)sa$C-NEm9-cr>VK*2{aqivr=vQG*(xp4llwL zk&$ml#|*0VZ;;g_VT1<`pJlO7;vojxbI>pr=oMJ7_|V(`9_Qu|7k^HNHl@Y3%3@$9 z@q+efkJ#5dLdF~1@aCy~4OFn_u}X@IW+ce%9F^PSNt{fk`dIZw<$ zl!_yB_ju&T7=t#QL)@7D>#gMha|kYk7JF`Cc!P=A5>{An{a{b=+n){+|MPNC@>n~m zU&gfqcy{(@n?Sg(y^`G zrI8J~nRYK4Zv$bA#QJX6MfbbQrm!t&y0ArmoYbPG`Gq%fx)_7)#Nfytc{TNTK8WX$ zc~T4ae;{Px`h^LXlr6Qh*ILXj%~1+3qz_zw7L&8Vg&I!i<4!NWLfwJFrC`a+bZoaq zB&4J!hg4c-3pNjZ-@mI$tx*C?>{?55>_4K||Gl=ZXTFseK~xk{toGqVj3EE>OFwm| z-TE$GVd_O}jP-AdXJ)7E9UQLB^2?R$sbl5U=W+B#%R!9WQ9J~!o((FCnH#@#YD-Ln zz)*Cbn3rU0E+`B{f0vdbeWY*q;)(9B;hJayCiEQs(%%eWNIB1bd7sa*jHI4OU6%m0Y6+pY^^F?zBx3^xe6uAI!xn?8rsjQfP zAU-fBIWg(!Nl8=Ll`itVzlT|tBTaE$CjUO1=tKoFXb*W!=L+Y#6nm+Q`s2qxJ7EV(Gbr8%~+9hbJq|&g>-y(YiE(&AQaND;!0UjBp)Ef{j zxaAgO=^S$Qx^6RMN$dLVo5!n0%=f{}_lY5)55o|}%#J~h4vUT`MTg8NOb!b0**xC8 zHnRXK2*5e1r^!W4g-2)xi=>Ov`(zH=Mq!m_B*>rC+*ZeQQbO$pLHD_U<)Xs$O!v0KNlQWeMC1!}8??t%3%XVyiT7{+wxL`bl+;35#a+0#=@%^r z>j)tU_{&ZJ;(YxK6|Ss-iQSkXP7hYFSg@ClRz#!|2L-?+PTaLqORLc|$XP=zF_$ZG zabH`9_z$i%(YYn<5-%H#4e7~GCI?0`?tdWG%Zyi}EX~nw%bPI?Bx|mn0R_P(pPRg}a>sTCr^DSbOzVl06uxl&e z<#5uLY6+r@@+qh*1QcOYlUW_nnBFDZ=!|jR2P2>diM*Qtt4$ZVYJa?|JDJZf5Z`p0 zi(f>c!#xIP-!cj&6#V3e5=q%==VvEyi(?D@>VbmjSMM%^C3r>6``6)AQkm7sFIhEf zS0qo}IJByojw$m4ENNF=KKL{^xt5x)la^*r=$a9v`rG^Enp~W_*uN^%j2(5XZEF4b zSD}z_x8&%)6Fb~fo>7gtUawRXPMEVFJO?L8_Bre52adXbxhVgEM7{g^65ZJ_j=HeM ztT)|6+|;}p5rw0P5%D|y=njHr!oRL!TD6N!247@Tt}d%#+F5jV`{lq#(#1=Kq32jZ zvZ-mlMfi?ly?$f0obzt+bcFCHGZtiRaU`B8h^bJ{J$^`$T|QZZfWepaGy35L#czR@au?ipb@Iq#qsAZN~LSlbm;n(9-erV>O zov4~H8mdA@0<`X8Mw!7}u_XocQ$s|=F-#omG2ThCw7eyLA2ME%49idzK5YxMib-cX zeVAt})x0WdRZ^V;+LIf>f|J7t%z<~Y)Gxid_waRhjO~LOW@zhPq+4L_rD>&*w~}gI zxA4V`uRX2i>T6u~wp|K4M{xYepxhR?xzf|>%7sN6_7;r&X0n@QMMwshqk8(W9TSCx z2B?vaVQFPSE6IWoB$!*0jL~QBH0UaC1iII;ufVp*Uh`B|C3eAXW?4`b5Uok5BUi#Ub2(r71M+Hy$<&?D%n(oSP_PsthA z-qY}@^ADubm(H82OZF`Hlko=s^PauwAIOUACkDoq&)IeE8^IUJt*-q)kYc>gn~ybP zJ<5tYogJPY!VqRdbM?ByTk;~_FR8PnLZp_>|Gd+%(tEhQOWuL{cox3HDJfNJ@T6&;1=DpLTWehInzJQ>2k@`D%4pG-YyfqWsj=CtJV*o@AvD96%lZlikEo;anUr-fEBq1I# zcF-H5)G}hDEU<7O)H|gH2jw2&7vuFq1K$t&qmmU~gU@pf=~tN8H@eS`9@fp4SAv(G zo#D@8j*|m^>v6BgRb$f})=qXz#g7m3N!AB-tc7y5zaH>I^J*O8#e$3~BaNHle7NW~JvF)-5pyn&n8b)i{Q1*ClK!XfONag#Fj4l^tST5TD2oxDSh2wS!P$0N}NF4JV zqkkCqt*KN*FW zKXz)E3Fhbek2n`Zrk}>}CFBD&2Kh0~GR-X2`H>{!XBb`kVu-@WItIU7lH#{=UUnVk z=nq{7`Sc!4roMWsw|1@XA;I+LqZJhCAP!N+%bBvS+`m>t#eq9z8DTMnvK|uYkJ)2*(r^C zN@%)R_`|?*ayslaLd5%rCZ^ao;28wxhnkYoVLb`!rs$A+$S9ReNUvY?^>jDG1 zc>$d%tkX&2oaB6lt323 z8RJ`iD+*od(E+STE#_Q}IhO*Xj&fI}dE4SD-k>@wZ1CU@cQ#(v1XL@`bq21USSgIi zHw1mt&9MkNLD6P9Pv-OhdjOK{>pK~h)NRw3(_BPZeNwZ zVffIq&^S@?NW+t{DZ)MTKNNkNdwH;I78?)~AHR-YC!T+`5KiWY8~&(T#Q>2{7c+Y{ zr`-ngxn$k)cvk2-9S1%O{sRGt^g^RxWBU+in+inm#uisC--N%M7<)*X9CS5ho>BR+ zG82$E)`kQ&RtM+@>p;l^v&Ds{I3PX_9yEAu8mSF9bwDnpR4;3f)89MqQBd0_x-J(W zPo&{CIe=aET>m&S{bZVBuxFUj+2*aI%BvU3m|3or`A~ndD>}QYb}`mn?+1fej_)!9 zSsMRvYfc3rVfzEIDxWv_J3e&&z9yhuM~kt*Cfs`X5zbL1=z+G!nn`g!_y|3_v0)B% zKxG`iDdPB?C}eQ4R_aPp)V`h9hh~6lzt&v_cU*WA>GpZ4$}?|tWqrCK;Zu64%4y2+Fo4!RR!U zs$@5K@e8Jy@J4Y~HmZLguK-ArH(BWE3pgj&iw9^r&pigMlSa>nvM!yt{IF(#JZ2Io zjx-Zs>cfYyVB!a?7uh>{-(}ErSI;|FLlKh-Sx(GcCNgYpP&%npRaILS^s!_QPyoWC z(LRObA#yc?r-=%Hd^s6F2t0~iBje2SeeL*otF7)OETnDkDu{-%EHe{BK5CJM-?7ya zRxk?7jZ=jz*mZa4(sh0tW2{uPyq;uu2M|6ddef-mOpCQDjMp39GqoIMc!^@@POPlZ z+QDP+!_%UVu2`DyTc}(K#u#a$m69aBNdkj+}!t z+7B4oJF=Q01Op^3V35I!%8xuzH(gz# zU$sjLy=S>`3&N51Sa*)i9UT?ev4Uy66}}gUY??9r%mzmfgh1a?22RG5!-q1V)Ljy= zv-}WT%los-8WEp_43K}~r#RXbk`xo0Owz5hV`chq}qUt<4( z^!kXu+OF}`{sVc=xx4ZG2O@Ii`}N!#2-AAd;Z&RDqyAo-Lh12#^*XuTvoq@TqPOms zOY_lv#o@)??9KV2>+v#5fOkUxens=ydqD8x_J6*O^?x$a{=Z)RPXZE6z<wq44lW zWT2W*aynQSQ|sx1pI=?k2+6SWM?y26%~MBBs+vsQQXs6#^vwGn6qq=WUPKxFw+2sckk@~DK!l^GywS%4YEKoC1kKL+V}(G(4NLOZlmrnw#} z#2?7lY+AT;zOnew$+!p<>7>eF4UY09I@W5E$FN__5(d$2dxM`stx*=XzxVtsyI6HV zdN-#_Kf^amu&Wc?2=6;7rJ&!P@IyRV3S$bdL)Nh`Pq$$Ezf?h3b zN1Wgwhm---`oMD}qTn9eTRmDf%c5Y5V}CE67yJP?8?!~zu`86H!@@exF%Sq(he^Lw zvx>6g0X-U_e^Ry?-{qY-z1Y}Dj;P-yJ3DZHiln?N z`|>=aKz9ycQW1&nc2?6o9)4VpO5+1(!7BYZ_^ZR*^f?r7L2TYxWOfC2QC>sQ+5mrG zWySkS6(m=(kF1`Jy}FbH%5$TGw6@p~hdExWQsN45gL&5FlZ*AhIL-ln?+|{3 zGNHTT?ael`d7fQXS+QHOWdM-V)S@{LHvm&6i2TMUZ3#sX=NcR1F58oZ(skdAyN82E zMxg!2@qN0ZRfDss!B$2R(p44@jpt`jhnc5jQJ@Ee^w3VZZy5#qk*=*J3nULS{z+uT zUME75ZMj~;Tvrvy8Q?4;*#CHXFrQ)`mH@W5DUwUCc_nz?cA^wiEeXDx9WxM{f(+iA z6??bL&20WmSkE4I_c;|XInAyc8L>N`!Uah(?0Zxws^4KVw!Cv~+#+NWKn>POJpyiaT%qlXamqSrsdF#t~f z_T$UOJUwzuVq&uG(Dm=+!Y2A%K}Ws@jkvuKhavnld)#cpQC6-NN|#O+6Hui_H-_?% z=xvUR8*?WCaiS@8nDginqQxYY7dVSz<)&rQS1qcv@rNDug4C*$1uQtR@{cX+K+xIpzq8)?zC*HlA%0Z{FT zFoO2lv6EfG@eG9IF?dU0ugh5dOJfzz=m{%EsZW)rS=xo32^wcX-w+i$-BG zD6l*wzIy*CT;jCCHE)dt z*BttG@2r z!)aZsAke8jAvcVQ|J;Wgj9OB7Y}qbnT5mICvZyOhCPGEr)&Ertyay>=QG#L%D5EVw zah@_NqQQ;-;Gb!Im6rG^HjNBF*h6J9>4YL7ajN!t^VFGSSnB4`g^=Sd#BlUAT5_!R z&`e;cb%Ie{s<6hAtD^lg0rvRDQqb$s@0U^e3z6b5D+CSXvDVMQ?c^2mP{Y|nhdPmg zyfLx-jhlqO2=O-XE)ATVlg;*_VXJNe^7Xd@(r*tHojz`WY31aG5#z4n&sD@*HY`%D z?LbVxUlpV};=qF$-5IzRv`Htn#lq`sVtBo`t~N6Bu|W7hV!e|o9G8=lLVpfo2aJ6Z zMd1!_q4roKR0(J!x&E%aA>5=ATJ=f9kq`{NVEy)?M8@zJxaMK51)0B{#q@-gq&c%d zp)g^jLDjAOze-t-jxFw@q<(3(BEi+eK{Kr|yFGVuhE$7`L}=_tMBixa$x#k8qQk`>Q)&T3;8 z^}DpsT7|eK@&>`;^ZcHLf+l8tf5qk@Q@G*Ta83TMnZMSg62rsKyy@w@`L)|+xTRC zE8+M3oMn(>wY$-bA3n0Aa?t4@s!H^>z#K{?n~4neYO|xV&3t$D2iY(%0skt-M5 z<&fFtnC|sv>zqw)!yM_yqttA-?*Zd~E#Ug!n?e8YgW6s0s7P*6zi(Pyiok!ALkp1p zH%c^mycZApE0Ocdpj-WCEcu5M#hcW{Ys8b$cc%iVqv?{vWR3aN2cr?tbdS~AiFylk zW_-5Fpp4Kjza^&qp~z69BZrFI7waed)>Pz*6^l)r&yLPX4Uly;Mf2r+HCG}YlE}li z4nVD)cvilU$r+o?`cU%z{1rLxYwEI+wvbu$;jny5f6S#^9?$Nl9s7ZNw#ju=g3``$ z_uW=0o9(k#Oa8@~sJoW@S%=MMupCOXqV1(Nxj7tjac`zbxS+PvvjNI%_fS~`|Ue@(ZuE{C>Bc#I(VsZ4++pF}cAze~_u zs-z;wuCrKzn!Ys+8Y8Yz2~)>&;`Kj>&9KLvCwKxdG;JalG2pr@p|ehM*>USm1Jk_k z;zJaj;k9~eG-b45JGQvNw$|qElvm}J>$poQ>89CE-j8y`vic*aC6#RR3{e&+JeKJ(V?!mn7N0U zE2&(rh$x4v7VOrWSmRa`>*nod^em-7(-W5VkHPhpMHcn=-))Izqckid;*+Q=qEAwR z4m@;)I-IU}MNZH97d-N@2Dld)*4PK0t*}lVF>biGF@9$fz6#e>t`a~NXD{r5ti6xR zG#tsCSQ})65#FbTcV0}~CdpE>wTh>mX3HegyT*PMn_N4$0oh$%Y}O&EYNQLU-U1fa zpA*o`u8w@1DbZ?=Q>w7Gr}T=z*=WsF(CO7=t*LezTkJ5!lE+%RQIxIwlx0)KmH|d* zzQ8a%)oQ_nseQ7N%!vL&~voqr@E~(C+5?drHR3c{*e+7Iz*;}VB{2<6713Y(y)ezT?nQb zb8Iq2{yw1MRfOkd7zY4vr6Uuk|6#H6+CwPsz*{RyAC_ut(SR5RmMa(9>H;;P)!2AI zs4G69wj3DHy=Ow6#W9cm11)V2G#%+91Rz|qLgOTm-* zKag2Gk$)g((Py{SpKA*}zEZ#_+JY3lWyOu5r{XkgVudAqZq6PDYTBZy;=9TdnzhhI zeB7Bo>hqGL(HgO*=2?xQoGeYVHXIQPF(?(Y9{5A}am4Pd3(sXoJuWN8o0eg13GxwC z*(W2g!cC0fF)jlLcE-fN$?uv5&raT<5219<5^Yhh+0e<21s+UhOG!_D!}P`v($APY z{W$ig)Ax@?Jcqa1rYN7bx{lSucpR(2dJ#yW?bLLNzoa~R5Tk8CxYn7)K*Fe z-4rzIkm$tIC@6%!B$36|LH6D*!0=A_|7FnsFU8~k$wv6!nh3sCpJ>}%{|fIX#oz9s z%C=TJj9V0@4$~~ubKSqS{JOC+%&?d>7K5Fi=64IQuBr~1IA?um1-3&1m!H?$q3kHb zzNs!M(T;OJp|qO?jw*3}455_Re3>~TJ^|aIBmRJAmKH^@aLm`rq>XQBqmEJ9CCL9bMUpb;L<)Z+e^@S4o z6u23Td7fb9BrKtR_{b62!DSv}C)KBe7I1?r4N<{{RrOeiW~2ELI*JzNxvf3PS7cR% z+0Ac;`f#!s%OpE7R2o+{xgCowriQRNEhGhnUfPDs-p>nbO%Werh&;8^I;+X=`mD6@ zwnQR}dwRu`z&(EAK*Tz^`9vNudH6+!;W!~C<^`!JT1}0uxH11B%T(C08t`=>Y6d44 z$5>eugTFDi2vG@K=d^86#>iirt&ggdPj}8KhE!W=M^_fnekmtc0fp~6EJV4dO}(jV zpr!w^e360_i>1>WFZA8!2aLLFRmRoQ5m5ZIhP`#ZRp)Bsb~Oj!KDf!_I=*?^N2T!n zF@Cuf8q%aB$X=A=gq&AbvO!J}H4Sp4dZ1n|r69FSikBQ30uQtLFMW$+(od~x8kd6# zaa-4%1f)3!OG@3jEw~CK^a&#Xm@-(C*TE!y>%Clbg*bNCT;!@zxGoCnBkE6im2k7T zYk7gNjLmj|cj09E1g5er^BPl@FOOYX|3LWPEN2DAm{Wo?yROXL!qr}|w-^#56TSr( zcLKuUm8`~vqs*1ergp0X6?Mz4QWG48Ng9W{lc^`ehb?cL+!Zku8K$Gl?`XP6UUSo6 zOXv9to0U_RS6OpA71>@0FLaf~=aSDiskWZvOl{)Xk}vCr{<@{uIop0WUgIRpJ)@!8Ls5S)|$P zgfOKL}~qKLc`ie0*bqwir{fx z5;^DexFd(Dv$TL}n!&0a3vZ1c&=Y9$TSKL03w zc0Z_AKXGtO)3}H@!e7LkEX;T)*L)uFYmJrp#$gM5ncy{Kl< zBhs~o4rGE8Kt2q%slhSG9PCnIkkl_E6nS){Sr0BkWE)_t5?oMJh(cSAF~#M*^||20 zJ}Wb#We4*pqO32Mu8|!!aB=dx3}+S#=*WCyLvF0Z`QVH+L9!s4U&Le_Opr9*rhMV2=N)6Qr-sB>c}Zt7 zi{VTM0Vaa5_-8d(`s8&dE6FII~4*SXs@vyfn5xLp5xnzV=}(V zBdb8^wN&Mde&f7+OLafL1$rU~E>1AJ)tR*T(*lnyW~Y+lIoDqmhj5R@`Ba)(PNO0N ziVu)3WHAQ`Tc03AdFhE8o~0B~AN@1&a}HZH>>&3er0GosLy&Ly3{qC*$8th-^~y2W z7n_ctUGD0EO~)O!sU0B}}mDBkRnWdryJ} z+SWh(wO{5i{(CZH_BL_(HruWozI>=?-byOcbo!K(=YVi2v*sck7I^*->&q^sOG&`9J#)>b zP)AZwT8u48b<0V;0$kn3qMd`x;h9e6-}k<2e$Sk5 zX^r>7#cO5HGKtBaxf!h}3a|asXy3jcwL8%-(ANhCmFDMUv^{Ou5BB-HBV|Ar@oD;x zO4dWFyQ@kHN(yo>=Krfk?Z4sL-(o^__7jTMrPAg$q&oi~_+05d|AZ}U1~1E>pc60* za<(Ugr!r>cXY4CZMBu~qmv3m}tmO6dshK`-l9CK6x~DR}#wz($#C&=4^****#}S)n zQ@iA=rnr4tm|0G6abcw5X22qJL;2lr?#(uo_ zkl;Yx-z7v62wp0epvt-`?ZcL}B{$rsoVRRPh*O)eG{bKC&xn-vyiL`9rv=rz8dkYv zXlXfFD|lYLcHA3E%HmemEmVQ$%#nWeP_HNn6>9h0W+^O0|L?2jIEhH$P~nrYTKX zr&sRB!8AJnU+rllY&T=#1rN`fV<%gW0A7#z{T=H8h1rzQX8Zeb>21GBdx4h6P>XEU zE3q)-lOEAeHDAscGo+p32301!FR3u1vIffz=wlW;;`y2GScL8`M{*gBWqWOl{C~SRz(=piw5KUJO@<{ z_fJ3XqeUENIrcLt^J4zQ9Z*&|)R7Pq)E4tt80m?JZrlg&N znUsGt`iRbARaC)@;NOVs#_DmD{Wiq+1I|Qqt{7MyQM+!LR`1ruZ=!IMc3E@I{YJ3M z%c7Sh7quxk2sa2(itS#KkT&Bjto91gMrSHri%;P8eiZ0)R?-ku@0C&?caHY%zY8|M zk?-_zhFAs@-!daeHHLd&`RUvAzNoc?h^;bF;oZrQOhAlP*VS1oNG}14jCo9Fa zyz!DR6{6@$V^-R8{3a;7qli^+&eFV>#k2*4)~i&4@_tp``?ZUaRI*Sd7a_9Jn=jDN zrdOo*8)H&uQjYB7dyx^clk-Xs!~}pOD(>hr=#$kab}9FH@K=ip#x0YUL`put!~rNAg!d+uHf2A}*3$x(da0+XGQK;-P18q@3)AEL&hf3FyV{}lgZ<&q}y3$Q* zpb$oo8kw@V<}UB2fi3dlLuJslfC#|{-F;MC#$6Ho+FFqY)P8TOzCvQ&#nxVq(y<8W z`my)+C9*Ie-Sy%Rf|Tkt(ZJ$^!8; z^z0{vb=zT;n7eE@B@c|U;#l|j2F502Cv4x*!uMJ-5z)}%FaX(*KhrvaPkvyw)9>Go11WpC&+ZB+DpWNX`MV%jFh z{TUboM_Km^7kXju<4}vp3l`WLIGT(z%!#n-u$nNjLFo}}cTAU|8(s^U(D#-!oFOw0 z-@k;ve|;TwBHAP7Iit}Hqpz(89kcD)BHI5*i_e9eX-e}b1GN(G6YLMgZ&>ur%1c&+ z9n9{tT7wUsnB+X#SO*j*pqcX@#HJ9F1MkdaCH2KGTX6H3;&E#`$M6@FYoF^q{*|!2Ooo$KeN~1t#HqGtQipu z|Byln6+b=aEqEIA@h*4*9=MXgP=q2OBHuEyRLsZ0z;T6#BEeFt-BtoY>h~w>v}&Kp zY|YE;r#0nfXE91u9Z1lhU)S-P)WUR%Ef|tc=+NF1JD~N=CG$39i5;^Gk7mX2Xr+J= zn%?*c(jh6}lgRXz9G+=KwJ~C1&dRpPRWJ+RSQ7FaCFPsDhe#9lTd;OZ9 z!i?!9qZBJ+k8UHrJF%&4$gGh%SAz$hJD&3@camL)w_r4AgynLrFr?55Z9JcOn*0zx zN#O);Q=s9iOf5N%GfuL;QIOKC_`#JS4OL~cX!^+x!=GXL7`2YWEgbMzKp{3n{GE{C z01;P8OEOIE^6Z5^;mPG1qa3nW@<}YAMaDMTM&mLU=b3~o?ER}&j{G-;ILlI~oH7_2 zG1e9y*=QB03%5ys5U!Kg{~+8i!wbfaV%n6x5brQ*=~38``#7C7z2W?3$pZR;QbdbE zHa04%ln4z&u2)f*kIRZ}Y-FM@%)*(*a@dE|G? zFQIE5+yiTLrXK*U!+0Xbmp2f3B5vfPj!_ho)}Zi9tOB z_IcwxD^P1{t0#c4aAB)FVgajNW5lVQV7Lfr zSb(!L#nB!P>MZXUGrOEt!g4>Uv^D~=30zBy3_#GmZj$Yp4n(utF?kg+b7NCEU;=ckWH_7?cmq)vmO!2Q`;paVd0)0~^#^{9 z8{~~c-TFDqC$;x2rh5KO6q7?bnL3uM8O@eb7r6NDHDphtNQNRJCm;A)@*-|=9BATV za%>+2nwS4?>F>XxqlD{;e@8&XgNHW#Xw&U4ldX6fe6jy4Jzn1Wo}X!_yL2>2mFLQm z;p^CxV$}P0k|-Ku^H^LahYai1+5`+u%_}T@y`|nb4mA3cYAC7_) zTPhM9N80!!0C1`A{dR=_*>tx%Y4sc z?aG_xPud=ctFQj(Te+N3RUEQ;M5Y0(t9V%S5nv6wKG7!;FbV6m;d*=HZo==z!M_+q zGPYyAU1H79XxaZUb0%?ZSP<3>sd)sUgvQ|huaAxNWfA;a6R=@NT{ReFsdk$BbMokf zw{l|HJ!~K-C1hoy#~VA^X6kJ8J(KIj8D(QdR77$8gavl%Y=_`fMM+238ZPS zAr-9LIRC@cQu`&l792Or2qba;H{2{BJABSk_}_6eZ~6vzltyLp!+*!kIA4jZl}Y4{OcLW>-HQQoN%4`9s809py8En6zK$1~{*X((Oe^I*AYso|UYg zH1F3TeRSpdo%U>fHU0a`A8(f*ta6EU6=a2yQoSgPu6`O+oVi{(B@4(-Ef;S-8q7vf zxy&&`GXd9`?594)Fsc289t-+rEyGm7#&M<_`>!j_i3(9z;lTD0bQZz5%kQCr5p+1^ zmj5BT>&m$$H|#Os(4cP8tPv~UGrse|A?)KAVP;h4U>Cr}b+KHRUCQqKx?#H6IXb+% z^9La-%QowbO%z+8AYY*Ae`fjq+a~aL*0AJj7fzUu;$W}Tm5=BQB_b}{^-9**Cx{l} zazk;W>Xn22gCu(N3JOd&vQGbSZVA%*tbE%#U1gjP;Q~kS-{sCz9tyoLv0xK*Dgg|P zCe`koGU{k8apXf33XGGh>FtNb4_0GpupG}Fe7W{;d&-f@n;D^ihf0X#lZ=iqB{HNM0v#<9eD`mF9iT&0me=vZAdmb zg&ehzXg*u61Hjyw$_10b$~QtrjaH~yc)W(3)4KVKh5a=~0gEuJy(!8c1fwD)Bi|$$ zfnVdzlQ>Uun#9CzjdhzhLfDaR}g2;M&2l=4Z{{asxkeo9JeFP zA|TIp9*B8ZosT$kVK_<0sB9nG9kZ7mBt)0+fS&sYA%UWJIDhyfkTSK+)N9E6Trxl7 z{46jV@?*sWRhzBJjLG2$9pRQ>zk{RBn%uSDx%zym%9`XD3^X?8>bEAnyT~BjED#NV zW{_I!4eIVB7K(CS>9YRVNkGf;jl|e;S0SW7B}kV88O`Ul=f#1^R;8sfb3m2!3}K%G1mRY|kg%$~}L2s%1C(&st^j-&?lCA9RK z>b$ukt}H)Y?JNdIWCg5UA&9>F7oFS$c4_l*7%yFavtLkwr2+(O=JNqdUMxdyOX*5= z+;X!|oI-}Le-uwJS81qh>{D{4SF`3RAyEckT?zAdIXgPk+?>B_@jkT!aX6aMBIku8 zYO?{Om%bYs7q{T5n(Ap~eJ>gz-$rzr&_~4B!pGJ8*wqjawc4LfagySPrp7UK+7OXg zNjEu;VQI8p#y+|N2 zix3sINFaei2l=6iiBudH(4j;sVc+wvZ-0@-e`SsT5B~fE_uTl8K|tuVcyOQt#rYq& zXBNOe1OdUF&#&@5n-RU1GT@PlQ$DN)>IF0HPH)$#b@t7F9gAlW&qkBRB~KsIe#V;M zVxLI{#PPU}D(;SG=eoH=W$)mza}MkAKR?Yp99H`0QX&0ZYFkzpm%sX9 zh}UVvJGR%?J9VT~LY%3EyktWaVs=Pkzueq>V&L9SOYOqF zJtt=!Tq6;47L9B_q}yxEho-{Q{U!|(f=rx|#~xNhC&G~-*(FNaBt?T0cKgejlEEey z#E%@~DNHj)AZ5&xupmOA9i;tFt`wR0|;RFP&=(`O##?A8;z!tZM+%vag?JgmZ zO&zA&1)QWNNNuMK)-w8oFh+3!kkuY%$kI^Cg zHp%mbE%Ym%u(L1co3)^U`1j!`b)5-CKk9UCm^4cx6OKSnkj@Y(tlH8T&%8xx#eaVf z=^S*`55U2GRS~7E1lr+}b%-;4ZRN6B%Q!!_(0xIJ$ZWZ_1*N5@FEX>p8ju>`uzO8F zKs?=nHrzUhb=_n}Nzq%{rL4zc3DzzhC*z&3=&*ihKA44@`r(O&y~*MeD9fRjbfE)7 z0R*A(NC3s5JH{2x8HpyvZ>(yfhQ4V_D9h?c{9yb$9vF401jO2(!Wie?4Yzr9{icaKKuA4Od)T7(6r#?3E z(P*()hODQ=yqcqiAejHPwoR#5*xucsQdfr8+r`h#QVZUXIdm?{=knG=(l1q&2j=n; z7koN#2V}y3yK@U%gq+aJ>+;+=x3$o7-P;e8e4}^JcKcvic;rA?#-y-Jb5S=K$lgD= zQa|Yu$8iA^@dbb*^zJU8Cp!_HAg5o(q*lKJpb-Y%fT5v6lKZ8?LTZP}CTSFPRx$TD zqh!WZx@xB48>y0k>y?B9w--b$*kiYbDA186qmt~U$x)>J1XPVvV^J?t*R-yC5;a{ge`L-F}+em zc`H`_i-~Jo-e7czvk~R@`x9IX%`wX>ZC*AHPIe(Wx~2)~fY}weZVfi3%b)f&;gT9a zh-c{yo2`{RiczUyLr-2p-(s}nT z{9;5v$64>JD}c^O5h;^{l{r^4BXkzXMY$E=dhud+8T=Dk139tSL^qZ#QP#n%6Pyxg z_9>ts-`NB>o8io}!;=9BKn| zCRjeGOHnH-Gp}m4{1peX$%A?J_8zgnV$g-usw`+7E+aJ^)ytv9bY6;F|E{<<$rH5L zQB4+`BO=!}{Dbhu|7`3J!rN!XS6|rgU3OJ%sBX`!ZWEnv6V1Ndeu%vLVQ+T3j#19S z`F3rt#bH#*n!9g;_TJLn@S&~>TJpKn-DV5h*`Gqcq{j3&ZXl=LO<^_*y?XK=1dlba zmatgcWA#(G7QFyEW6m>l>XWJ1)l>8ha3w<48)Q;vv<7x#w$*T=#2uK zi=FQ5i79Q_=*~C9uqf)@FY$yq? z3HCu4+M`&9N7~%B$h_%uBSYQ@zIh%bW#s*I{j$_W`?$ej_=+E{i$aBX9a z4k?5Hhj=5u_?}H}t6ZVq_dVzeUe{w=m$-nV!sJDJQ?Q*pb)zUfc7!NRVz2B9u_ggndLzbgj0+o?=~Z zOUd*GYqts2qspUB$T(g`WM#9K_!(F7wIizBq-?J}+!vHo`5n^bQ=feenJmjkB>zBO z5bxVy_ks|8)^KXy>&VBL_tb}$mR-H*H0~NSOVV#$o$u7YLT_Zj+wYmpU^7RRiNjsh zh9vB-`oWEGQdZLkg7<0a6C=8fW~t?Gw4ny z!?zIwDSvQj`OY>qYn`)8+xD{y$KgrTfMTu`+Pu9NpXtNB?ep@J0lJBJYR963le73M zxy(MNgy`Vp&yCdDu8$ALQKB;KJ#61wK8p(~Ql*tvG%5`EbodT~#F|w1RjT);oIR>+ z*{Rgo-|foE^mKYzy0aRoF5k!<40weO4js4Gvb9j}`?F289cE`6oovamDCbUQ2vCaym+;AR>@)USGe>Nl@dAg*(H6!8YFhOdCvc;D+@Yq{`fNIPxCzk|fo039li zRcITO7cE?o$k6oNdB4OjNd_Y96qD0LOcdaXtkRhqkoda3pq!$_L7rSdEK#(Ttr=xe zF%D_A5CHY$c|}d#+-DQK`{ve*8P1?pa^tR{WFQ3gzztY>sYb8ag-G8@pXZFYwnK5P z#@!M$k2uL;%z=l{=?f_v#yIY62UndS$c?7T$a|@I*w8E==yOga*=iX9h^UTh3slFJ ztXV;8KF~xOAO$RtZ_izQB5M5zaec^~k`H@mEV^yJKhCGQ0bW4LmhWwWnCG3l^rQFEa? zgy9j_$=gU|1)$|l?{Z$ z-I$L4`_pdtWV75??|91RP-GiaT&qvf$~L|Pl47>n`A{s*G{nQ3qFhpZtPgK^ZyIm| znx=#ojR)(GD{xy^&1F;9@?W1A*-aA#74I3jp`1K=s$kfSu`DcgpZdEWVjREUcYFaDWPC*@f>oZ`WBN~!WJ-lbfK_v;w49ct}h^V4e+H{cHfI?=KZD$Z?< zlSG(jm%rPDr_~<>haS7;d-943z88e!g>&wV?j$eF++tvLVVB7II-J_=Ra3D_oCEXS zR&n&-^hdoFgd;eqY5^GrE_Am|JTkFS$>dI%V*UNFLaqlbul17iDZu_cXxxVHjf>Ih zo03Q?Z?k>Hr1F%bmsRVp#_UD2UU@?&Do)5Pb!PEtstK8BHQ;ZV1L5?!fX)<{z*R@4 zxgI8Q*Z#=$^(?n}Y(lkWyY2l1E$ID`}8t4>@9`FcU-sx)5(@tlZ0O*sm zXfkewZFe1?2c5Oypw!Bfbl9yk$Z0)GBT?&a3pK9>nL&`$w7?1DXkGOer*B|Q?SxZU zoW$YU;hsN!Z+>K*gZd?~G2lh$OPi6BEKKY%1>e|c{~uD6YhHk%vCp=i6apftzn%k_ zw65JmnACCCxu46hT4Ekb>^HHpnKAY$lqXL#-C0Xhj@kiiE9xx(da#7c_Jj%M~YustiAt^bS z61VzzW-b^Q;BNeh2B$WQvAL>st$bk@Qi;MD(zYz=c{V%Ox52*$hGlZFF4}$gGVL!F zM&kL21zgA`nG^VGjw!NY7^~8|X}tio87sOM)gfg287@@)dtiH`dM{Vu+#`U4zs#||;6w3=YHw4f#pwLK2Hy`lBM@5tqV6aT~h+3EBsRr@Ku_jmf z*l@@XnJ<(Z4bO0Nz)IW4+^J&3qu*Ae-a>_)za%}@^GA&kh!`R&N3j%V3|*RH-67HB zy82w^;*lO}D5W}Z5b>6jwE6z&5(r#E&2F+G+}IWx3GB6q4%swE+KfO6BDU>)$(?aO_8wWK%w2lt0h$fYKG5$ z5W2W-O!7s-c1_wc?mbpNhx$N#>=R0nza5#8r8Go;Mnl7TeS)0a82;rYXS+VYqigqM z=6l2}gUohHqH(+Dje={2x3RtQQT!PL14SDI=&Zr{>cZ3W*-7%L3f2Rbnt0lpkvRT1 zogjULopdL{QLui&PZU%Zqdk33v^MGCbaIT6Q%2yu^XY~}=S~EfK z_0;gmsu%6A_A6ssH0EQglFP(JiCo^e$bgqV)idHZ65!{)K+IQZXUK`dh)7Oy7bM(h zH}SwKgM}vJ4iJ+|xRL;~HApJA(UUghmthw`OkNA97|C%z#l;2C*bn4;k(o&9dr?r+ zy4hkJC=8GH_ctEnn8Xp=qqrFQKo#J7UkO4x<_he)=Ekj1^`G@IL-G_8!MZ3&k}ufF zg_41lB8lDZXUiX*Ut(*jmgVWnUT!H8PRtOsd&Kk6g%)Oba})#Gu>9NBXZqqp58qvO zvA0Z04Rw8Oyypt3uK}2N=Ox`&cUm{SDBIZi{Zd2u*pa$dho@ZbtOpAi8S8P}eR`4_ zy+mrqx7m}B0`j9#sJtd9qynCm*NaH!zYjWS+S+5uOxE)BA>5YjS!g8UwTW2evAuun zbq<`z(M%j^@;Cb^dXR#sTkYw{nx60=uEvbYG5wQ!d}tEbOHHk|zPRFRU@pd)*?bQq z|1F)My>fAOya!3`d0KvI3Jba?&53{^pHh2r?UE~M>qAr?&M)dPd7yh@{73y195A4@ zzc=6_F_GjBRkn1AAx~m`_DwC|Fc;W{~itr6@ym{ z`OrTI9A^J?;6#}bYtX7!e!Ps97A221B(g<2S3z-T{&Bl89GC1%6`ic*w$J0aiSU)@ z*jP!a<%vOK{JjcuX|)0Wdbi0?q2QI3+b7CHL2!xXsi;#&+W>D;!<&Nz9W7dRD~K1B zx?3I8iC=vGhSOE%Yu@#AZC+Qj=ZBVGtrduG$FaGE!9b;MAs(w@_Aikl(ut0%Uh}GI zFxhD5(L#X7AVkY6(ajWH19o%j!4QrCl2aAL)o5(mF71=UcqN(b@wgGA6~49{QI8TM zchp{sEja0=?`hqY&X+ZecE2>y7gV zZdN86MQ6NnYSGlHzNJC~8*dhVafa|HBiEDtMMD7#L|?F8aHRceDbwI!aWbp>%%qyZ zmD(4`VW@!m$L#x3a;fTZtG5`H zz|=;g!@d^@>UGCDRv$K7T(aG8cVa_1DH}sF8XoYw%M*`i449bRUTQzp7SR|mniU%n zIUMoIs))XYd*NBn{~&xo=H&@Kl+x5+U`USBviiakIkLsYxyM#4ORe%Q=kEt#IDeyC zmKgZi$9@U>h331f?$|jjFE=K3IU@opjqmKv%<_ED|FoAc=z6)Upbi^(4e5rI)#eF6 zTD;AEO{85Tc9h%wZg?BKOLjWAXG}+iEA(R=69Ee` z6)Gu|>>=@tHz}F1=Ne$sl;RhercZeFaIG?tT+^|C_6gy5R5Rh*Ib+RFGCh5od{5gn zC*y}Pq3o$cHFBm;xs?;#8bf||1l=@y9<&m+?u-dk3M50XxH3TxK}dPml+S@H5cJWZ zWBGQkb0Z&|&z>kmCX9ovb!Hz6ZQJj+1BR_bn!I;a$@9XsdC{B_BNkWj3MifIc2aeB zvr8ZNl>3DS7G)2=+{seOB?b^CI;Kk zqv#AOgck;?pvf=K+PHS9`@Ow)OUgN*k)b{r&gA9wz&(9lQQLmmNPq4&w<2I98PKiH zTL1RUaozh^7z%Wb*W1bH^sxQ^+R6T3BX<7&w)a1L`t_e4{}1CY6lwl3A&4dgZg0lJ zmEB%H79fD_Jr@YaTQh1f7hEj_LSP}=? z1~P=Hpbp@mv9Rw2 z)cC=j=l*A5E7Ko@r6fO@y@O%!9&zAC;U5HR)iQdMJiW)zpyzDV663(U_&_(CQ*?N` zlI6ed`PYB&ulL|zYvF%&KY&XEH+dO=i{I;1FC^+=dqd9!To=it}F+ zVmI(g2A8!G;T23Y$Y(aWZi3~xSM2u~h(!G^`B|vxXUkI6miOJtL4R3dNueMlfr@7{ tw}O=K19cnhFLYua>Yrc0WA@t(45j{yGa*g;Pxj@%qi+1u_e%U(`adduw>JO) literal 0 HcmV?d00001 diff --git a/assets/datagrabber.md b/assets/datagrabber.md new file mode 100644 index 0000000..a3161f6 --- /dev/null +++ b/assets/datagrabber.md @@ -0,0 +1,38 @@ +```mermaid +flowchart TD + subgraph Plugin + P[sn_plan41 Fachplugin] + A[Adapter Plan41LinklistAdapter] + PM[Pruefmanager] + LP[Layerpruefer] + KP[Linkpruefer] + SP[Stilpruefer] + end + + subgraph Core + DG[DataGrabber] + NL[normalized entries] + LL[Layer Loader Provider Dispatch] + SM[Spatial Matcher] + ST[Storage GPKG / PostGIS] + PR[Project QGIS - addMapLayer] + LOG[Log / Ergebnisstruktur] + end + + P -->|gibt Adapter, Prüfer, Pruefmanager| DG + A -->|load liefert Rohdaten| DG + DG -->|adapter.normalize| NL + NL --> DG + DG -->|für jeden Eintrag: _check_link -> KP.check| KP + DG -->|für jeden Eintrag: _check_style -> SP.check| SP + DG -->|prüfe vorhandene Layer| LP + DG -->|lade Layer via provider| LL + LL -->|Features| SM + SM -->|Abgleich| DG + DG -->|speichern| ST + ST --> PR + DG --> PR + DG -->|Ergebnis/Fehler| LOG + LOG --> PM + DG --> PM +``` \ No newline at end of file diff --git a/assets/datagrabber.pdf b/assets/datagrabber.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f79edb2131f8d9333a36f716b720626ab45bd9a0 GIT binary patch literal 32398 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 S@(~w@052yh4GmCH>VE;ie;=d( literal 0 HcmV?d00001 diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index 9e116dc..8346f8b 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -32,6 +32,7 @@ QTabWidget: type QToolButton: Type[Any] QSizePolicy: Type[Any] Qt: Type[Any] +ComboBox: Type[Any] YES: Optional[Any] = None NO: Optional[Any] = None @@ -68,6 +69,7 @@ try: QTabWidget as _QTabWidget,# type: ignore QToolButton as _QToolButton,#type:ignore QSizePolicy as _QSizePolicy,#type:ignore + QComboBox as _QComboBox, ) @@ -107,6 +109,7 @@ try: QTabWidget = _QTabWidget QToolButton=_QToolButton QSizePolicy=_QSizePolicy + QComboBox=_QComboBox YES = QMessageBox.StandardButton.Yes NO = QMessageBox.StandardButton.No @@ -148,7 +151,7 @@ try: except Exception: try: - from PyQt5.QtWidgets import ( + from PyQt5.QtWidgets import (# type: ignore QMessageBox as _QMessageBox, QFileDialog as _QFileDialog, QWidget as _QWidget, @@ -166,18 +169,20 @@ except Exception: QTabWidget as _QTabWidget, QToolButton as _QToolButton, QSizePolicy as _QSizePolicy, + QComboBox as _QComboBox, ) - from PyQt5.QtCore import ( + from PyQt5.QtCore import (# type: ignore QEventLoop as _QEventLoop, QUrl as _QUrl, QCoreApplication as _QCoreApplication, Qt as _Qt, ) - from PyQt5.QtNetwork import ( + from PyQt5.QtNetwork import (# type: ignore QNetworkRequest as _QNetworkRequest, QNetworkReply as _QNetworkReply, ) + QMessageBox = _QMessageBox QFileDialog = _QFileDialog QEventLoop = _QEventLoop @@ -203,6 +208,7 @@ except Exception: QTabWidget = _QTabWidget QToolButton=_QToolButton QSizePolicy=_QSizePolicy + ComboBox=_QComboBox YES = QMessageBox.Yes NO = QMessageBox.No @@ -210,6 +216,8 @@ except Exception: ICON_QUESTION = QMessageBox.Question QT_VERSION = 5 + + # then try next backend # --------------------------------------------------------- # Qt5 Enum-Aliase (vereinheitlicht) # --------------------------------------------------------- @@ -246,7 +254,7 @@ except Exception: QT_VERSION = 0 class FakeEnum(int): - def __or__(self, other: "FakeEnum") -> "FakeEnum": + def __or__(self, other: int) -> "FakeEnum": return FakeEnum(int(self) | int(other)) YES = FakeEnum(1) @@ -518,3 +526,55 @@ except Exception: self._tabs.append((widget, title)) QTabWidget = _MockTabWidget + # ------------------------- + # Mock ComboBox Implementation + # ------------------------- + class _MockSignal: + def __init__(self): + self._slots = [] + + def connect(self, cb): + self._slots.append(cb) + + def emit(self, value): + for s in list(self._slots): + try: + s(value) + except Exception: + pass + + class _MockComboBox: + def __init__(self, parent=None): + self._items = [] + self._index = -1 + self.currentTextChanged = _MockSignal() + + def addItem(self, text: str) -> None: + self._items.append(text) + + def addItems(self, items): + for it in items: + self.addItem(it) + + def findText(self, text: str) -> int: + try: + return self._items.index(text) + except ValueError: + return -1 + + def setCurrentIndex(self, idx: int) -> None: + if 0 <= idx < len(self._items): + self._index = idx + self.currentTextChanged.emit(self.currentText()) + + def setCurrentText(self, text: str) -> None: + idx = self.findText(text) + if idx >= 0: + self.setCurrentIndex(idx) + + def currentText(self) -> str: + if 0 <= self._index < len(self._items): + return self._items[self._index] + return "" + + ComboBox = _MockComboBox diff --git a/functions/test.md b/functions/test.md new file mode 100644 index 0000000..84240dc --- /dev/null +++ b/functions/test.md @@ -0,0 +1,14 @@ +mermaid´´´ +flowchart TD + A[Projekt] + + subgraph children[ ] + direction TB + B[src] + C[docs] + D[README.md] + end + + A --> B + A --> C + A --> D diff --git a/modules/DataGrabber.py b/modules/DataGrabber.py new file mode 100644 index 0000000..85ba2fe --- /dev/null +++ b/modules/DataGrabber.py @@ -0,0 +1,324 @@ +# sn_basis/modules/DataGrabber.py +""" +DataGrabber module +================== + +Leichter Orchestrator, der eine Quelle (Datei, Einzellink, Datenbank) +analysiert, passende Prüfer aufruft und die Ergebnisse an den +:class:`sn_basis.modules.Pruefmanager.Pruefmanager` delegiert. + +Dieses vereinfachte Modul geht davon aus, dass alle benötigten Prüfer +und der ExcelImporter vorhanden und importierbar sind. Es enthält +keine Fallbacks oder defensive Exception-Handling-Pfade für fehlende +Prüfer-Module — fehlende Komponenten führen zu Import- oder Laufzeitfehlern, +die bewusst nicht unterdrückt werden. +""" + +from __future__ import annotations + +from typing import ( + Optional, + Any, + Mapping, + Iterable, + Dict, + Protocol, + Literal, + Tuple, + List, +) +from pathlib import Path +import sqlite3 + +from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion + +# In dieser vereinfachten Variante werden die Prüfer und der ExcelImporter +# direkt importiert. Fehlende Module führen zu ImportError (gewollt). +from sn_basis.modules.Dateipruefer import Dateipruefer +from sn_basis.modules.linkpruefer import Linkpruefer +from sn_basis.modules.layerpruefer import Layerpruefer +from sn_basis.modules.stilpruefer import Stilpruefer +from sn_basis.modules.excel_importer import ExcelImporter + + +SourceType = Literal["file", "link", "database", "unknown"] + + +class LinklistAdapter(Protocol): + """ + Minimal-Protokoll für Adapter, die Linklisten liefern/normalisieren. + + Implementierende Klassen sollten: + - load() -> Iterable[Mapping[str, Any]] + - normalize(raw_item) -> Mapping[str, Any] + """ + def load(self) -> Iterable[Mapping[str, Any]]: + ... + def normalize(self, raw_item: Mapping[str, Any]) -> Mapping[str, Any]: + ... + + +class DataGrabber: + """ + DataGrabber orchestriert das Einlesen einer Quelle und die Übergabe an Prüfer. + + Diese vereinfachte Implementierung erwartet, dass alle Prüferklassen und + der ExcelImporter vorhanden sind. Es gibt keine defensive Logik für + fehlende Komponenten. + + Konstruktor-Parameter + -------------------- + :param pruefmanager: Instanz des Pruefmanagers (verpflichtend). + :param datei_pruefer_cls: Klasse des Dateipruefers (Standard: Dateipruefer). + :param link_pruefer: Instanz des Linkpruefers. + :param layer_pruefer: Instanz des Layerpruefers. + :param stil_pruefer: Instanz des Stilpruefers. + """ + + def __init__( + self, + pruefmanager: Pruefmanager, + *, + datei_pruefer_cls=Dateipruefer, + link_pruefer: Linkpruefer, + layer_pruefer: Layerpruefer, + stil_pruefer: Stilpruefer, + ) -> None: + # Pruefmanager ist verpflichtend + self.pruefmanager: Pruefmanager = pruefmanager + + # Dateipruefer-Klasse (wird zur Laufzeit mit einem Pfad instanziert) + self._datei_pruefer_cls = datei_pruefer_cls + + # Prüfer-Instanzen (werden direkt verwendet) + self.link_pruefer: Linkpruefer = link_pruefer + self.layer_pruefer: Layerpruefer = layer_pruefer + self.stil_pruefer: Stilpruefer = stil_pruefer + + # Quelle (wird später gesetzt) + self.source: Optional[str] = None + + # ------------------------------------------------------------------ # + # Source Management + # ------------------------------------------------------------------ # + def set_source(self, source: str) -> None: + """ + Setzt die Quelle für den DataGrabber. + + Die Quelle ist ein String, der entweder ein lokaler Dateipfad, + ein Einzellink (URL/URI) oder ein Pfad zu einer Datenbank/GeoPackage ist. + """ + self.source = source + + def analyze_source(self, source: str) -> SourceType: + """ + Klassifiziert die angegebene Quelle ausschließlich anhand des Dateipruefers. + + Ablauf + ------ + 1. Instanziere den Dateipruefer mit `pfad=source` und `temporaer_erlaubt=False`. + 2. Rufe `pruefe()` auf und werte das zurückgegebene :class:`pruef_ergebnis` aus. + 3. Bei `ok==True` wird anhand der Dateiendung zwischen "database" (gpkg/sqlite/db) + und "file" unterschieden. + 4. Bei `ok==False` werden typische Aktionen wie "datei_nicht_gefunden" als "link" + interpretiert; bei "falsche_endung" wird anhand der Endung klassifiziert. + """ + dp = self._datei_pruefer_cls(pfad=source, temporaer_erlaubt=False) + pe: pruef_ergebnis = dp.pruefe() + + if getattr(pe, "ok", False): + suffix = Path(source).suffix.lower() + if suffix in (".gpkg", ".sqlite", ".db"): + return "database" + return "file" + + aktion = getattr(pe, "aktion", None) + if aktion in ("datei_nicht_gefunden", "pfad_nicht_gefunden", "kein_dateipfad"): + return "link" + if aktion == "falsche_endung": + lower = source.lower() + for db_ext in (".gpkg", ".sqlite", ".db"): + if lower.endswith(db_ext): + return "database" + for file_ext in (".xlsx", ".xls", ".csv"): + if lower.endswith(file_ext): + return "file" + return "unknown" + + # ------------------------------------------------------------------ # + # Excel-Verarbeitung + #Es werden alle Werte ohne Prüfung der Links, Pfade oder Stile geladen, da verschiedene Plugins verschiedene xlsx-Strukturen haben können + # ------------------------------------------------------------------ # + def process_excel_source(self, filepath: str) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: + """ + Liest eine Excel-Datei (.xlsx/.xls) mit dem ExcelImporter und gibt ein Dict + mit den Zeilen zurück sowie das vom Pruefmanager verarbeitete pruef_ergebnis. + + Rückgabe + ------- + - (data_dict, processed_pruef_ergebnis) + data_dict: {'rows': [Mapping,...]} oder None bei Fehlern + processed_pruef_ergebnis: das Ergebnis, nachdem der Pruefmanager das + interne pruef_ergebnis verarbeitet hat. + """ + importer = ExcelImporter(filepath=filepath, pruefmanager=self.pruefmanager) + rows = importer.import_xlsx() # erwartet: List[Mapping[str, Any]] + data = {"rows": rows} + pe_ok = pruef_ergebnis(ok=True, meldung="Excel erfolgreich gelesen", aktion="ok", kontext=filepath) + processed = self.pruefmanager.verarbeite(pe_ok) + return data, processed + + # ------------------------------------------------------------------ # + # Einzellink-Verarbeitung + # ------------------------------------------------------------------ # + def process_single_link(self, link: str) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: + """ + Verarbeitet einen Einzellink. + + Ablauf + ------ + 1. Führt die fachliche Prüfung über self.link_pruefer.pruefe(link) aus. + 2. Übergibt das Ergebnis an den Pruefmanager (self.pruefmanager.verarbeite). + 3. Wenn die Prüfung nicht OK ist, wird nur das verarbeitete pruef_ergebnis zurückgegeben. + 4. Wenn die Prüfung OK ist, erwartet diese Implementierung, dass der Prüfer + die Link-Parameter im pruef_ergebnis.kontext als Mapping bereitstellt. + Dieses Mapping wird unverändert in ein Dict {'rows': [kontext]} überführt + und zusammen mit dem verarbeiteten pruef_ergebnis zurückgegeben. + + Hinweis + ------ + Diese Funktion enthält keine Fallbacks, keine normalize-/load-Aufrufe und + keine zusätzlichen Validierungen. Der Linkpruefer ist verantwortlich dafür, + bei OK ein geeignetes Mapping im pruef_ergebnis.kontext bereitzustellen. + """ + # 1) Fachliche Prüfung durch den Linkpruefer + pe = self.link_pruefer.pruefe(link) + + # 2) Pruefmanager verarbeiten lassen (Logging / UI / Entscheidung) + processed = self.pruefmanager.verarbeite(pe) + + # 3) Wenn Prüfung nicht OK -> nur das verarbeitete pruef_ergebnis zurückgeben + if not getattr(processed, "ok", False): + return None, processed + + # 4) Prüfung OK -> Prüfer liefert die Link-Parameter im pruef_ergebnis.kontext + kontext = getattr(pe, "kontext", None) + data = {"rows": [kontext]} + # Erwartung: kontext ist ein Mapping mit den Link-Parametern. + # Wir übergeben es unverändert in das rows-Format. + return data, processed + + + # ------------------------------------------------------------------ # + # Datenbank-Verarbeitung + # ------------------------------------------------------------------ # + #def process_database_table(self, db_path: str, table: Optional[str] = None) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: + #noch nicht implementiert + """ + Liest eine Tabelle aus einer SQLite/GeoPackage-Datei. + + Verhalten + --------- + 1. Validiert die Datei mit dem Dateipruefer. + 2. Falls OK, versucht es, die angegebene Tabelle zu lesen; falls keine Tabelle + angegeben ist, wird nach einer typischen Metadaten-Tabelle 'layer_metadaten' + gesucht und diese gelesen. + 3. Gibt die Zeilen als Liste von Dicts zurück. + """ + dp = self._datei_pruefer_cls(pfad=db_path, temporaer_erlaubt=False) + pe = dp.pruefe() + processed = self.pruefmanager.verarbeite(pe) + if not getattr(processed, "ok", False): + return None, processed + + conn = sqlite3.connect(db_path) + cur = conn.cursor() + if table: + cur.execute(f"SELECT * FROM {table}") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + else: + cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='layer_metadaten'") + if cur.fetchone(): + cur.execute("SELECT * FROM layer_metadaten") + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + else: + rows = [] + conn.close() + + pe_ok = pruef_ergebnis(ok=True, meldung="DB gelesen", aktion="ok", kontext=db_path) + processed_ok = self.pruefmanager.verarbeite(pe_ok) + return {"rows": rows}, processed_ok + + # ------------------------------------------------------------------ # + # Hauptlauf / Dispatch + # ------------------------------------------------------------------ # + def run(self) -> Dict[str, Any]: + """ + Hauptmethode des DataGrabbers. + + Ablauf + ------ + 1. Prüft, ob eine Quelle gesetzt ist. + 2. Klassifiziert die Quelle via :meth:`analyze_source`. + 3. Dispatch: + - file (.xlsx/.xls) -> :meth:`process_excel_source` + - link -> :meth:`process_single_link` + - database -> :meth:`process_database_table` + - unknown -> Fehler + 4. Aggregiert geladene Einträge in einem Ergebnis-Dict und gibt dieses zurück. + + Rückgabeformat + ------------- + Ein Dict mit den Schlüsseln: + - 'geladen' : Liste der geladenen Themen/Namen + - 'fehler' : Mapping Thema -> Fehlermeldung + - 'ausserhalb': Liste der als ausserhalb klassifizierten Themen + - 'relevant' : Liste der relevanten Themen + - 'details' : zusätzliche Detailinformationen (z. B. Anzahl Zeilen) + """ + result: Dict[str, Any] = {"geladen": [], "fehler": {}, "ausserhalb": [], "relevant": [], "details": {}} + + if not self.source: + pe = pruef_ergebnis(ok=False, meldung="Keine Quelle gesetzt", aktion="kein_dateipfad", kontext=None) + processed = self.pruefmanager.verarbeite(pe) + result["fehler"]["source"] = getattr(processed, "meldung", "Keine Quelle") + return result + + src_type = self.analyze_source(self.source) + + if src_type == "file": + suffix = Path(self.source).suffix.lower() + if suffix in (".xlsx", ".xls"): + data_dict, pe = self.process_excel_source(self.source) + else: + pe = pruef_ergebnis(ok=False, meldung="Dateityp nicht unterstützt", aktion="falsche_endung", kontext=self.source) + pe = self.pruefmanager.verarbeite(pe) + data_dict = None + + elif src_type == "link": + data_dict, pe = self.process_single_link(self.source) + + #elif src_type == "database": + #data_dict, pe = self.process_database_table(self.source, table=None) + + else: + pe = pruef_ergebnis(ok=False, meldung="Quelle unbekannt", aktion="kein_dateipfad", kontext=self.source) + pe = self.pruefmanager.verarbeite(pe) + data_dict = None + + # Falls Daten vorhanden: fülle result['geladen'] und details + if data_dict and "rows" in data_dict: + rows = data_dict["rows"] + for r in rows: + thema = r.get("Inhalt") or r.get("ident") or r.get("Link") or "unbenannt" + result["geladen"].append(thema) + result["details"]["source_rows"] = len(rows) + + # Falls das letzte pruef_ergebnis einen Fehler enthält, übernehme es + if not getattr(pe, "ok", False): + result["fehler"]["source"] = getattr(pe, "meldung", "Fehler bei Quelle") + + return result diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index cb4e6af..31e5c25 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -39,7 +39,7 @@ class Dateipruefer: def _pfad(self, relativer_pfad: str) -> Path: """ - Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis. + Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis. """ return join_path(self.basis_pfad, relativer_pfad) @@ -119,7 +119,7 @@ class Dateipruefer: ok=False, meldung=( "Es wurde keine Datei angegeben. " - "Soll eine temporäre Datei erzeugt werden?" + "Sollen temporäre Layer erzeugt werden?" ), aktion="temporaer_erlaubt", kontext=None, diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index 4a0a85c..a7a8910 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,7 +1,5 @@ -""" -sn_basis/modules/Pruefmanager.py – zentrale Verarbeitung von pruef_ergebnis-Objekten. -Steuert die Nutzerinteraktion über Wrapper. -""" +from __future__ import annotations +from typing import Optional, Any from sn_basis.functions import ( ask_yes_no, @@ -11,150 +9,196 @@ from sn_basis.functions import ( set_layer_visible, ) -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion class Pruefmanager: """ - Verarbeitet pruef_ergebnis-Objekte und steuert die Nutzerinteraktion. + Zentrale Verarbeitung von pruef_ergebnis-Objekten. + + Erwartete öffentliche API (verwendet von Core-Komponenten wie DataGrabber): + - report_error(thema, meldung, *, aktion: Optional[PruefAktion]=None, kontext=None) -> None + - request_decision(pruef_res) -> str + - report_summary(summary: dict) -> None + - verarbeite(ergebnis: pruef_ergebnis) -> pruef_ergebnis """ - def __init__(self, ui_modus: str = "qgis"): + def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None): self.ui_modus = ui_modus + self.parent = parent - # --------------------------------------------------------- - # Hauptfunktion - # --------------------------------------------------------- + # --------------------------------------------------------------------- + # Basis-API: Meldungen / Zusammenfassungen + # --------------------------------------------------------------------- + def report_error(self, thema: str, meldung: str, *, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None) -> None: + """ + Einheitliche Meldung für Fehler/Warnungen aus dem Core. + Keine Rückgabe; dient als zentraler Hook für Logging/UI. + """ + critical_actions = { + "netzwerkfehler", + "pruefe_exception", + "save_exception", + "layer_create_failed", + "read_error", + "open_error", + } + warn_actions = { + "datei_nicht_gefunden", + "pfad_nicht_gefunden", + "url_nicht_erreichbar", + "falsche_endung", + "kein_header", + "kein_arbeitsblatt", + } + if aktion in critical_actions: + error(thema, meldung) + return + + if aktion in warn_actions: + warning(thema, meldung) + return + + # Default: informative Warnung + warning(thema, meldung) + + def report_summary(self, summary: dict) -> None: + geladen = summary.get("geladen", []) + fehler = summary.get("fehler", {}) + ausserhalb = summary.get("ausserhalb", []) + relevant = summary.get("relevant", []) + + message = ( + f"Geladene Dienste: {len(geladen)}\n" + f"Relevante Dienste: {len(relevant)}\n" + f"Dienste ausserhalb: {len(ausserhalb)}\n" + f"Fehler: {len(fehler)}" + ) + + info("DataGrabber Zusammenfassung", message) + + # --------------------------------------------------------------------- + # Entscheidungs-API + # --------------------------------------------------------------------- + def request_decision(self, pruef_res: Any) -> str: + """ + Synchronously request a decision from the user (or return a default in headless mode). + + Returns one of: + - "abort" + - "continue" + - "temporaer_erzeugen" + - "ignore" + """ + aktion = getattr(pruef_res, "aktion", None) + meldung = getattr(pruef_res, "meldung", str(pruef_res)) + + interactive_actions = { + "leereingabe_erlaubt", + "standarddatei_vorschlagen", + "temporaer_erlaubt", + "layer_unsichtbar", + } + + if aktion in interactive_actions: + if self.ui_modus == "qgis": + title_map = { + "leereingabe_erlaubt": "Ohne Eingabe fortfahren", + "standarddatei_vorschlagen": "Standarddatei verwenden", + "temporaer_erlaubt": "Temporäre Datei erzeugen", + "layer_unsichtbar": "Layer einblenden", + } + title = title_map.get(aktion, "Entscheidung erforderlich") + try: + yes = ask_yes_no(title, meldung, default=False, parent=self.parent) + except Exception: + return "abort" + if yes: + if aktion == "temporaer_erlaubt": + return "temporaer_erzeugen" + return "continue" + return "abort" + + if self.ui_modus == "headless": + return "abort" + + informational_actions = { + "leer", + "datei_nicht_gefunden", + "pfad_nicht_gefunden", + "url_nicht_erreichbar", + "netzwerkfehler", + "falscher_geotyp", + "layer_leer", + "falscher_layertyp", + "falsches_crs", + "felder_fehlen", + "datenquelle_unerwartet", + "layer_nicht_editierbar", + "kein_header", + "kein_arbeitsblatt", + "read_error", + "open_error", + } + if aktion in informational_actions: + return "abort" + + return "abort" + + # --------------------------------------------------------------------- + # Höhere Abstraktion: verarbeite + # --------------------------------------------------------------------- def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: """ - Verarbeitet ein pruef_ergebnis und führt ggf. Nutzerinteraktion durch. - Rückgabe: neues oder unverändertes pruef_ergebnis. + Verarbeitet ein pruef_ergebnis-Objekt und führt ggf. Nutzerinteraktion durch. + Liefert ein ggf. modifiziertes pruef_ergebnis zurück. """ - if ergebnis.ok: return ergebnis aktion = ergebnis.aktion kontext = ergebnis.kontext + meldung = ergebnis.meldung - # ----------------------------------------------------- - # Allgemeine Aktionen - # ----------------------------------------------------- + # Zentrale Meldung + self.report_error(aktion or "pruefung", meldung or "", aktion=aktion, kontext=kontext) - if aktion == "leer": - warning("Eingabe fehlt", ergebnis.meldung) + # Interaktive Entscheidungen + if aktion in ("leereingabe_erlaubt", "standarddatei_vorschlagen", "temporaer_erlaubt", "layer_unsichtbar"): + decision = self.request_decision(ergebnis) + if decision == "temporaer_erzeugen": + return pruef_ergebnis(ok=True, meldung="Temporäre Datei soll erzeugt werden.", aktion="temporaer_erzeugen", kontext=None) + if decision == "continue": + return pruef_ergebnis(ok=True, meldung="Fortgefahren.", aktion="ok", kontext=kontext) + return ergebnis # abort / unverändert + + # Spezielle Excel/Importer-Fälle: klare Meldungen, keine interaktive Entscheidung + if aktion == "kein_header": + warning("Excel-Import", meldung or "") return ergebnis - if aktion == "leereingabe_erlaubt": - if ask_yes_no("Ohne Eingabe fortfahren", ergebnis.meldung): - return pruef_ergebnis( - ok=True, - meldung="Ohne Eingabe fortgefahren.", - aktion="ok", - kontext=None, - ) + if aktion == "kein_arbeitsblatt": + warning("Excel-Import", meldung or "") return ergebnis - if aktion == "leereingabe_nicht_erlaubt": - warning("Eingabe erforderlich", ergebnis.meldung) - return ergebnis - - if aktion == "standarddatei_vorschlagen": - if ask_yes_no("Standarddatei verwenden", ergebnis.meldung): - return pruef_ergebnis( - ok=True, - meldung="Standarddatei wird verwendet.", - aktion="ok", - kontext=kontext, - ) - return ergebnis - - if aktion == "temporaer_erlaubt": - if ask_yes_no("Temporäre Datei erzeugen", ergebnis.meldung): - return pruef_ergebnis( - ok=True, - meldung="Temporäre Datei soll erzeugt werden.", - aktion="temporaer_erzeugen", - kontext=None, - ) + if aktion in ("read_error", "open_error"): + error("Excel-Import", meldung or "") return ergebnis if aktion == "datei_nicht_gefunden": - warning("Datei nicht gefunden", ergebnis.meldung) - return ergebnis - - if aktion == "kein_dateipfad": - warning("Ungültiger Pfad", ergebnis.meldung) - return ergebnis - - if aktion == "pfad_nicht_gefunden": - warning("Pfad nicht gefunden", ergebnis.meldung) - return ergebnis - - if aktion == "url_nicht_erreichbar": - warning("URL nicht erreichbar", ergebnis.meldung) - return ergebnis - - if aktion == "netzwerkfehler": - error("Netzwerkfehler", ergebnis.meldung) - return ergebnis - - # ----------------------------------------------------- - # Layer-Aktionen - # ----------------------------------------------------- - - if aktion == "layer_nicht_gefunden": - error("Layer fehlt", ergebnis.meldung) + warning("Datei nicht gefunden", meldung or "") return ergebnis + # Spezieller Fall: layer_unsichtbar (falls nicht interaktiv behandelt) if aktion == "layer_unsichtbar": - if ask_yes_no("Layer einblenden", ergebnis.meldung): - if kontext is not None: - try: - set_layer_visible(kontext, True) - except Exception: - pass - - return pruef_ergebnis( - ok=True, - meldung="Layer wurde eingeblendet.", - aktion="ok", - kontext=kontext, - ) + if kontext is not None: + try: + set_layer_visible(kontext, True) + return pruef_ergebnis(ok=True, meldung="Layer wurde eingeblendet.", aktion="ok", kontext=kontext) + except Exception: + return ergebnis return ergebnis - if aktion == "falscher_geotyp": - warning("Falscher Geometrietyp", ergebnis.meldung) - return ergebnis - - if aktion == "layer_leer": - warning("Layer enthält keine Objekte", ergebnis.meldung) - return ergebnis - - if aktion == "falscher_layertyp": - warning("Falscher Layertyp", ergebnis.meldung) - return ergebnis - - if aktion == "falsches_crs": - warning("Falsches CRS", ergebnis.meldung) - return ergebnis - - if aktion == "felder_fehlen": - warning("Fehlende Felder", ergebnis.meldung) - return ergebnis - - if aktion == "datenquelle_unerwartet": - warning("Unerwartete Datenquelle", ergebnis.meldung) - return ergebnis - - if aktion == "layer_nicht_editierbar": - warning("Layer nicht editierbar", ergebnis.meldung) - return ergebnis - - # ----------------------------------------------------- - # Fallback - # ----------------------------------------------------- - - warning("Unbekannte Aktion", f"Unbekannte Aktion: {aktion}") + # Standard: keine Änderung return ergebnis diff --git a/modules/excel_importer.py b/modules/excel_importer.py new file mode 100644 index 0000000..50f8913 --- /dev/null +++ b/modules/excel_importer.py @@ -0,0 +1,91 @@ +# sn_plan41/modules/excel_importer.py +import os +from typing import Optional, Iterable, Mapping, Any, List, cast + +from openpyxl import load_workbook +from openpyxl.workbook.workbook import Workbook +from openpyxl.worksheet.worksheet import Worksheet + +from sn_basis.modules.Dateipruefer import Dateipruefer +from sn_basis.modules.Pruefmanager import Pruefmanager +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +class ExcelImporter: + """ + Excel-Importer für Linklisten, verwendet Dateipruefer und Pruefmanager zur Meldungsbehandlung. + + - Der Aufrufer übergibt einen konkreten Dateipfad. + - Vor dem Öffnen wird der Pfad mit Dateipruefer geprüft. + - Link- und Stilprüfungen erfolgen nicht hier, sondern im DataGrabber. + - Nach dem Ladevorgang wird die Arbeitsmappe geschlossen, damit die Datei vom OS freigegeben wird. + """ + + def __init__(self, filepath: str, pruefmanager: Pruefmanager): + if not filepath: + raise ValueError("ExcelImporter benötigt einen gültigen Dateipfad.") + if pruefmanager is None: + raise ValueError("ExcelImporter benötigt einen Pruefmanager.") + self.filepath = filepath + self.pruefmanager = pruefmanager + + def import_xlsx(self) -> List[Mapping[str, Any]]: + """ + Liest die Excel-Datei und gibt eine Liste von Dicts (Zeilen) zurück. + Bei Prüf- oder Leseproblemen wird der Pruefmanager zur Verarbeitung des pruef_ergebnis aufgerufen. + Im Fehlerfall wird eine leere Liste zurückgegeben. + """ + # 1) Dateiprüfung über Dateipruefer + datei_pruefer = Dateipruefer(pfad=self.filepath, temporaer_erlaubt=False) + ergebnis: pruef_ergebnis = datei_pruefer.pruefe() + ergebnis = self.pruefmanager.verarbeite(ergebnis) + + if not ergebnis.ok: + return [] + + workbook: Optional[Workbook] = None + try: + workbook = load_workbook(filename=self.filepath, data_only=True) + + # workbook.active kann typmäßig als Optional angesehen werden; cast/prüfen, damit Pylance weiß, dass sheet ein Worksheet ist + sheet = workbook.active + if sheet is None: + pe = pruef_ergebnis(ok=False, meldung=f"Kein aktives Blatt in der Arbeitsmappe: {self.filepath}", aktion="kein_arbeitsblatt", kontext=self.filepath) + self.pruefmanager.verarbeite(pe) + return [] + + # Typengranularität für den Linter + sheet = cast(Worksheet, sheet) + + # Header aus erster Zeile (als Werte) + header_row = next(sheet.iter_rows(min_row=1, max_row=1, values_only=True), None) + if not header_row: + pe = pruef_ergebnis(ok=False, meldung=f"Excel-Datei enthält keine Header-Zeile: {self.filepath}", aktion="kein_header", kontext=self.filepath) + self.pruefmanager.verarbeite(pe) + return [] + + header = list(header_row) + if not header or all(h is None for h in header): + pe = pruef_ergebnis(ok=False, meldung=f"Excel-Header ist leer oder ungültig: {self.filepath}", aktion="kein_header", kontext=self.filepath) + self.pruefmanager.verarbeite(pe) + return [] + + ergebnis_list: List[Mapping[str, Any]] = [] + # Werte-only lesen für Performance und Einfachheit + for row in sheet.iter_rows(min_row=2, values_only=True): + if row is None: + continue + # zip stoppt bei kürzerer Länge; das ist beabsichtigt + attributes = dict(zip(header, row)) + ergebnis_list.append(attributes) + + return ergebnis_list + + except Exception as exc: + pe = pruef_ergebnis(ok=False, meldung=f"Fehler beim Lesen der Excel-Datei '{self.filepath}': {exc}", aktion="read_error", kontext=self.filepath) + self.pruefmanager.verarbeite(pe) + return [] + + finally: + if workbook is not None: + workbook.close() diff --git a/modules/layerpruefer.py b/modules/layerpruefer.py index 164f9cf..3718a31 100644 --- a/modules/layerpruefer.py +++ b/modules/layerpruefer.py @@ -2,7 +2,7 @@ sn_basis/modules/layerpruefer.py – Prüfung von QGIS-Layern. Verwendet ausschließlich Wrapper und gibt pruef_ergebnis zurück. """ - +from typing import Optional, Any from sn_basis.functions import ( layer_exists, get_layer_geometry_type, @@ -26,7 +26,7 @@ class Layerpruefer: def __init__( self, - layer, + layer:Optional[Any]=None, erwarteter_geotyp: str | None = None, muss_sichtbar_sein: bool = False, erwarteter_layertyp: str | None = None, diff --git a/modules/linkpruefer.py b/modules/linkpruefer.py index 6f59306..a94e863 100644 --- a/modules/linkpruefer.py +++ b/modules/linkpruefer.py @@ -32,7 +32,7 @@ class Linkpruefer: def _pfad(self, relativer_pfad: str) -> Path: """ - Erzeugt einen OS‑unabhängigen Pfad relativ zum Basisverzeichnis. + Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis. """ if not self.basis: return Path(relativer_pfad) @@ -79,7 +79,7 @@ class Linkpruefer: def _pruefe_url(self, url: str) -> pruef_ergebnis: """ - Prüft eine URL über einen HEAD‑Request. + Prüft eine URL über einen HEAD-Request. """ reply = network_head(url) diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index 084f314..c7313be 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,14 +1,8 @@ -""" -sn_basis/modules/pruef_ergebnis.py – Ergebnisobjekt für alle Prüfer. -""" - +from __future__ import annotations from dataclasses import dataclass -from pathlib import Path from typing import Any, Optional, Literal - -# Alle möglichen Aktionen, die ein Prüfer auslösen kann. -# Erweiterbar ohne Umbau der Klasse. +# Erweitertes Literal mit allen erlaubten Aktionen (PruefAktion) PruefAktion = Literal[ "ok", "leer", @@ -16,34 +10,52 @@ PruefAktion = Literal[ "leereingabe_nicht_erlaubt", "standarddatei_vorschlagen", "temporaer_erlaubt", + "temporaer_erzeugen", "datei_nicht_gefunden", "kein_dateipfad", "pfad_nicht_gefunden", "url_nicht_erreichbar", "netzwerkfehler", - "falscher_layertyp", + "layer_nicht_gefunden", + "layer_unsichtbar", "falscher_geotyp", "layer_leer", + "falscher_layertyp", "falsches_crs", "felder_fehlen", "datenquelle_unerwartet", "layer_nicht_editierbar", - "temporaer_erzeugen", - "stil_nicht_anwendbar", - "layer_unsichtbar", - "layer_nicht_gefunden", - "unbekannt", - "stil_anwendbar", "falsche_endung", + # Excel / Import-spezifische Aktionen + "kein_header", + "kein_arbeitsblatt", + "read_error", + "open_error", + # Generische Prüf-/Speicher-Aktionen + "pruefe_exception", + "save_exception", + "save_not_implemented", + "stil_not_implemented", + "datei_unbekannt", + "needs_user_action", ] - - -@dataclass(slots=True) +@dataclass class pruef_ergebnis: + """ + Einheitliches Ergebnisobjekt für Prüfer. + - ok: True wenn Prüfung bestanden + - meldung: menschenlesbare Meldung + - aktion: maschinenlesbarer Aktionscode (PruefAktion) + - kontext: optionaler Zusatzkontext (z. B. Pfad, Layer-Objekt) + """ ok: bool - meldung: str - aktion: PruefAktion + meldung: Optional[str] = None + aktion: Optional[PruefAktion] = None kontext: Optional[Any] = None - + def __init__(self, ok: bool, meldung: Optional[str] = None, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None): + self.ok = ok + self.meldung = meldung + self.aktion = aktion + self.kontext = kontext From f8be65f6f62b070cae2473424affd362ffeecf69 Mon Sep 17 00:00:00 2001 From: daniel Date: Sat, 14 Feb 2026 22:14:33 +0100 Subject: [PATCH 09/11] DataGrabber aktualisiert, grabberfunktionen aus dem Prototyp implementiert --- functions/qgiscore_wrapper.py | 230 +++++++++++++++++- functions/qgisui_wrapper.py | 46 ++++ functions/qt_wrapper.py | 2 +- modules/DataGrabber.py | 174 +++++++++++--- modules/Datenabruf.py | 405 +++++++++++++++++++++++++++++++ modules/Datenschreiber.py | 435 ++++++++++++++++++++++++++++++++++ modules/Pruefmanager.py | 72 ++++++ modules/pruef_ergebnis.py | 2 + 8 files changed, 1324 insertions(+), 42 deletions(-) create mode 100644 modules/Datenabruf.py create mode 100644 modules/Datenschreiber.py diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py index a554e88..4a3905e 100644 --- a/functions/qgiscore_wrapper.py +++ b/functions/qgiscore_wrapper.py @@ -2,8 +2,7 @@ sn_basis/functions/qgiscore_wrapper.py – zentrale QGIS-Core-Abstraktion """ -from typing import Type, Any - +from typing import Type, Any, Optional from sn_basis.functions.qt_wrapper import ( QUrl, QEventLoop, @@ -16,9 +15,11 @@ from sn_basis.functions.qt_wrapper import ( QgsProject: Type[Any] QgsVectorLayer: Type[Any] +QgsRasterLayer: Type[Any] QgsNetworkAccessManager: Type[Any] Qgis: Type[Any] QgsMapLayerProxyModel: Type[Any] +QgsVectorFileWriter: Type[Any] # neu: Schreib-API QGIS_AVAILABLE = False @@ -30,16 +31,20 @@ try: from qgis.core import ( QgsProject as _QgsProject, QgsVectorLayer as _QgsVectorLayer, + QgsRasterLayer as _QgsRasterLayer, QgsNetworkAccessManager as _QgsNetworkAccessManager, Qgis as _Qgis, - QgsMapLayerProxyModel as _QgsMaplLayerProxyModel + QgsMapLayerProxyModel as _QgsMaplLayerProxyModel, + QgsVectorFileWriter as _QgsVectorFileWriter, ) QgsProject = _QgsProject QgsVectorLayer = _QgsVectorLayer + QgsRasterLayer = _QgsRasterLayer QgsNetworkAccessManager = _QgsNetworkAccessManager Qgis = _Qgis - QgsMapLayerProxyModel=_QgsMaplLayerProxyModel + QgsMapLayerProxyModel = _QgsMaplLayerProxyModel + QgsVectorFileWriter = _QgsVectorFileWriter QGIS_AVAILABLE = True @@ -76,6 +81,9 @@ except Exception: def triggerRepaint(self) -> None: pass + def dataProvider(self): + return None + QgsVectorLayer = _MockQgsVectorLayer class _MockQgsNetworkAccessManager: @@ -86,6 +94,28 @@ except Exception: def head(self, request: Any): return None + class _MockQgsRasterLayer: + """ + Minimaler Mock für QgsRasterLayer, ausreichend für Tests und + um im Datenabruf ein Raster-Layer-Objekt im pruef_ergebnis kontext mitzugeben. + """ + def __init__(self, source: str, name: str = "Raster", provider: str = "wms"): + self.source = source + self._name = name + self.provider = provider + self._valid = True + + def isValid(self) -> bool: + return self._valid + + def name(self) -> str: + return self._name + + def dataProvider(self): + return None + + QgsRasterLayer = _MockQgsRasterLayer + QgsNetworkAccessManager = _MockQgsNetworkAccessManager class _MockQgis: @@ -112,6 +142,63 @@ except Exception: QgsMapLayerProxyModel = _MockQgsMapLayerProxyModel + # --------------------------------------------------------- + # Mock für QgsVectorFileWriter + # --------------------------------------------------------- + + class _MockSaveVectorOptions: + """ + Minimaler Ersatz für QgsVectorFileWriter.SaveVectorOptions. + Felder werden als einfache Attribute bereitgestellt. + """ + def __init__(self): + self.driverName: str = "GPKG" + self.layerName: Optional[str] = None + self.fileEncoding: str = "UTF-8" + # Action-Konstanten werden symbolisch verwendet + self.actionOnExistingFile: Optional[int] = None + + class _MockQgsVectorFileWriter: + """ + Minimaler Mock für QgsVectorFileWriter mit der benötigten API: + - SaveVectorOptions (als Klasse) + - writeAsVectorFormatV3(layer, path, transformContext, options) -> error_code + - NoError (Konstante) + - CreateOrOverwriteFile / CreateOrOverwriteLayer (Konstanten) + """ + + # Fehlerkonstanten (0 = NoError) + NoError = 0 + + # Action-Konstanten (Werte nur symbolisch) + CreateOrOverwriteFile = 1 + CreateOrOverwriteLayer = 2 + + # SaveVectorOptions-Klasse + SaveVectorOptions = _MockSaveVectorOptions + + @staticmethod + def writeAsVectorFormatV3(layer: Any, path: str, transform_context: Any, options: Any) -> int: + """ + Mock-Schreibfunktion. + + Verhalten im Mock: + - Wenn 'layer' None oder options.layerName fehlt, geben wir NoError zurück, + aber schreiben nichts (Tests erwarten nur Rückgabecode). + - Diese Implementierung versucht nicht, echte Dateien zu schreiben. + - Rückgabewert: 0 (NoError) bei Erfolg, sonst eine positive Fehlernummer. + """ + try: + # Sehr einfache Validierung: wenn path leer -> Fehler + if not path: + return 999 + # Simuliere Erfolg + return _MockQgsVectorFileWriter.NoError + except Exception: + return 999 # generischer Fehlercode + + QgsVectorFileWriter = _MockQgsVectorFileWriter + # --------------------------------------------------------- # Netzwerk # --------------------------------------------------------- @@ -154,3 +241,138 @@ def network_head(url: str) -> NetworkReply | None: return NetworkReply(error=reply.error()) except Exception: return None + +# --------------------------------------------------------- +# Layer-Geometrie / Extent +# --------------------------------------------------------- + +def get_layer_extent(layer: Any) -> Any: + """ + Gibt die Ausdehnung (Extent) eines Layers zurück. + + Diese Funktion kapselt den Zugriff auf ``layer.extent()`` und dient als + zentrale Abstraktion für alle Stellen, die die Bounding Box eines Layers + benötigen (z.B. für räumliche Filter im Datenabruf). + + Verhalten + --------- + - Wenn QGIS verfügbar ist und der Layer eine ``extent()``-Methode besitzt, + wird deren Rückgabewert zurückgegeben. + - Wenn QGIS nicht verfügbar ist oder der Layer keine ``extent()``-Methode + hat, wird ``None`` zurückgegeben. + """ + if not QGIS_AVAILABLE or layer is None: + return None + + extent_func = getattr(layer, "extent", None) + if callable(extent_func): + try: + return extent_func() + except Exception: + return None + + return None + +# --------------------------------------------------------- +# Buffer-Layer erzeugen +# --------------------------------------------------------- + +def create_buffer_layer( + source_layer: Any, + distance_m: float, + layer_name: str = "BufferLayer" +) -> Optional[Any]: + """ + Erzeugt einen Pufferlayer um alle Features eines Quelllayers. + + Diese Funktion dient als zentrale Abstraktion für die Erzeugung eines + Pufferlayers in QGIS. Sie wird z.B. im Datenabruf verwendet, wenn der + Raumfilter ``"Pufferlayer"`` aktiv ist. + + Verhalten + --------- + - Wenn QGIS verfügbar ist und der ``source_layer`` gültig ist, wird ein + temporärer Vektorlayer erzeugt, der die gepufferten Geometrien enthält. + - Der Puffer wird in Metern angegeben. + - Der zurückgegebene Layer ist **nicht gespeichert**, sondern ein + temporärer Speicherlayer, der anschließend über den UI‑Wrapper ins + Projekt geladen werden kann. + - Wenn QGIS nicht verfügbar ist oder ein Fehler auftritt, wird ``None`` + zurückgegeben. + """ + if not QGIS_AVAILABLE: + return None + + if source_layer is None or not hasattr(source_layer, "getFeatures"): + return None + + try: + # Geometrien puffern + buffered_geoms = [] + for feat in source_layer.getFeatures(): + geom = feat.geometry() + if geom is None: + continue + buf = geom.buffer(distance_m, 8) + if buf is not None: + buffered_geoms.append(buf) + + if not buffered_geoms: + return None + + # Neuen Memory-Layer erzeugen + crs = source_layer.crs().authid() if hasattr(source_layer, "crs") else "EPSG:4326" + mem_layer = QgsVectorLayer(f"Polygon?crs={crs}", layer_name, "memory") + + prov = mem_layer.dataProvider() + prov.addAttributes([]) + mem_layer.updateFields() + + # Features hinzufügen + from qgis.core import QgsFeature + for geom in buffered_geoms: + f = QgsFeature() + f.setGeometry(geom) + prov.addFeature(f) + + mem_layer.updateExtents() + return mem_layer + + except Exception: + return None + +#Hilfsfunktion, keine qgiscore-Entsprechung + +def layer_exists_in_gpkg(gpkg_path: str, layer_name: str) -> bool: + """ + Prüft, ob ein Layer mit dem Namen `layer_name` in `gpkg_path` existiert. + - bevorzugt: SQLite-Abfrage auf gpkg_contents + - fallback: kurzer Versuch, mit QgsVectorLayer zu laden (wenn QGIS verfügbar) + """ + import os, sqlite3 + if not gpkg_path or not layer_name or not os.path.exists(gpkg_path): + return False + + # 1) SQLite-Check (schnell) + try: + conn = sqlite3.connect(gpkg_path) + cur = conn.cursor() + cur.execute("SELECT COUNT(1) FROM gpkg_contents WHERE table_name = ?", (layer_name,)) + row = cur.fetchone() + conn.close() + if row and row[0] > 0: + return True + except Exception: + # falls sqlite fehlschlägt, weiter zum QGIS-Fallback + pass + + # 2) QGIS-Fallback: versuche kurz, den Layer zu laden + try: + if getattr(QgsVectorLayer, "__call__", None) and QGIS_AVAILABLE: + uri = f"{gpkg_path}|layername={layer_name}" + layer = QgsVectorLayer(uri, layer_name, "ogr") + return bool(layer and getattr(layer, "isValid", lambda: False)()) + except Exception: + pass + + return False diff --git a/functions/qgisui_wrapper.py b/functions/qgisui_wrapper.py index 77b945d..7156afa 100644 --- a/functions/qgisui_wrapper.py +++ b/functions/qgisui_wrapper.py @@ -8,6 +8,7 @@ from typing import Any, List, Type from sn_basis.functions.qt_wrapper import QDockWidget +from sn_basis.functions.qgiscore_wrapper import QgsProject, QGIS_AVAILABLE iface: Any @@ -199,3 +200,48 @@ def remove_toolbar(toolbar: Any) -> None: iface.removeToolBar(toolbar) except Exception: pass +# --------------------------------------------------------- +# Layer zum Projekt hinzufügen +# --------------------------------------------------------- + +def add_layer_to_project(layer: Any) -> bool: + """ + Fügt einen Layer dem aktuellen QGIS-Projekt hinzu. + + Diese Funktion kapselt den Zugriff auf ``QgsProject.instance().addMapLayer`` + und dient als zentrale Abstraktion für alle Stellen, die Layer dynamisch + ins Projekt einfügen möchten (z.B. Pufferlayer im Datenabruf). + + Verhalten + --------- + - Wenn QGIS verfügbar ist und der Layer gültig ist, wird er dem Projekt + hinzugefügt und ``True`` zurückgegeben. + - Wenn QGIS nicht verfügbar ist oder der Layer ungültig ist, wird + ``False`` zurückgegeben. + - Im Mock-Modus wird kein Layer hinzugefügt, aber ``True`` zurückgegeben, + damit Tests ohne QGIS nicht fehlschlagen. + + Parameters + ---------- + layer: + Ein QGIS-Layer (typischerweise ``QgsVectorLayer``), der dem Projekt + hinzugefügt werden soll. + + Returns + ------- + bool + ``True`` bei Erfolg oder im Mock-Modus, sonst ``False``. + """ + if layer is None: + return False + + # Mock-Modus: Erfolg simulieren + if not QGIS_AVAILABLE: + return True + + try: + project = QgsProject.instance() + project.addMapLayer(layer) + return True + except Exception: + return False diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index 8346f8b..706c6b0 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -32,7 +32,7 @@ QTabWidget: type QToolButton: Type[Any] QSizePolicy: Type[Any] Qt: Type[Any] -ComboBox: Type[Any] +QComboBox: Type[Any] YES: Optional[Any] = None NO: Optional[Any] = None diff --git a/modules/DataGrabber.py b/modules/DataGrabber.py index 85ba2fe..b077dee 100644 --- a/modules/DataGrabber.py +++ b/modules/DataGrabber.py @@ -148,68 +148,168 @@ class DataGrabber: # ------------------------------------------------------------------ # # Excel-Verarbeitung - #Es werden alle Werte ohne Prüfung der Links, Pfade oder Stile geladen, da verschiedene Plugins verschiedene xlsx-Strukturen haben können + #Es werden alle Werte mit gültigem Link übernommen. Die restliche Struktur + #wird nicht überprüft, da alle Fachplugins unterschiedliche Strukturen haben können # ------------------------------------------------------------------ # - def process_excel_source(self, filepath: str) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: + def process_excel_source( + self, + filepath: str + ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]: """ - Liest eine Excel-Datei (.xlsx/.xls) mit dem ExcelImporter und gibt ein Dict - mit den Zeilen zurück sowie das vom Pruefmanager verarbeitete pruef_ergebnis. + Liest eine Excel-Datei ein und übernimmt ausschließlich die Zeilen, + deren Link durch den Linkpruefer als gültig eingestuft wurde. - Rückgabe + Ablauf + ------ + 1. Die Excel-Datei wird mit dem ``ExcelImporter`` eingelesen. + Erwartet wird eine Liste von Mappings (z.B. dicts), die jeweils + die Linkparameter enthalten. + + 2. Für jede Zeile wird der Wert ``row["Link"]`` extrahiert und durch + ``self.link_pruefer.pruefe(...)`` geprüft. + + 3. Das Prüfergebnis wird durch ``self.pruefmanager.verarbeite(...)`` + geleitet, der UI-Interaktion, Logging und finale Entscheidung übernimmt. + + 4. Nur Zeilen, deren verarbeitete Prüfergebnisse ``ok == True`` liefern, + werden in die Ergebnisliste übernommen. + + 5. Wenn mindestens eine Zeile gültig ist, wird ein Dict der Form:: + + {"rows": [row1, row2, ...]} + + zurückgegeben. + Wenn keine Zeile gültig ist, wird ``None`` zurückgegeben. + + Parameter + --------- + filepath: + Pfad zur Excel-Datei, die eingelesen werden soll. + + Returns ------- - - (data_dict, processed_pruef_ergebnis) - data_dict: {'rows': [Mapping,...]} oder None bei Fehlern - processed_pruef_ergebnis: das Ergebnis, nachdem der Pruefmanager das - interne pruef_ergebnis verarbeitet hat. + Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis] + - ``data``: ``{"rows": [...]} `` wenn gültige Zeilen existieren, + sonst ``None``. + - ``pruef_ergebnis``: ein zusammenfassendes Prüfergebnis, das + den Lesevorgang beschreibt (nicht die Einzelprüfungen). + + Hinweise + -------- + - Diese Methode führt **keine Normalisierung** durch. + - Die Verantwortung für die Struktur der Excel-Zeilen liegt beim Fachplugin. + - Der Linkpruefer prüft ausschließlich den Wert ``row["Link"]``. """ + + # 1) Excel einlesen importer = ExcelImporter(filepath=filepath, pruefmanager=self.pruefmanager) rows = importer.import_xlsx() # erwartet: List[Mapping[str, Any]] - data = {"rows": rows} - pe_ok = pruef_ergebnis(ok=True, meldung="Excel erfolgreich gelesen", aktion="ok", kontext=filepath) - processed = self.pruefmanager.verarbeite(pe_ok) - return data, processed + + valid_rows: List[Mapping[str, Any]] = [] + + # 2) Jede Zeile einzeln prüfen + for row in rows: + raw_link = row.get("Link") + + # 2a) Fachliche Prüfung + pe = self.link_pruefer.pruefe(raw_link) + + # 2b) Verarbeitung durch den Pruefmanager + processed = self.pruefmanager.verarbeite(pe) + + # 2c) Nur gültige Zeilen übernehmen + if getattr(processed, "ok", False): + valid_rows.append(row) + + # 3) Zusammenfassendes Prüfergebnis erzeugen + if valid_rows: + pe_ok = pruef_ergebnis( + ok=True, + meldung=f"{len(valid_rows)} gültige Zeilen aus Excel gelesen", + aktion="ok", + kontext=filepath, + ) + processed_summary = self.pruefmanager.verarbeite(pe_ok) + return {"rows": valid_rows}, processed_summary + + # Keine gültigen Zeilen + pe_fail = pruef_ergebnis( + ok=False, + meldung="Keine gültigen Links in der Excel-Datei gefunden", + aktion="read_error", + kontext=filepath, + ) + processed_summary = self.pruefmanager.verarbeite(pe_fail) + return None, processed_summary + # ------------------------------------------------------------------ # # Einzellink-Verarbeitung # ------------------------------------------------------------------ # - def process_single_link(self, link: str) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: + def process_single_link( + self, + link: Mapping[str, Any] + ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]: """ - Verarbeitet einen Einzellink. + Prüft einen einzelnen Link anhand der im Link-Dict enthaltenen Link-URL. Ablauf ------ - 1. Führt die fachliche Prüfung über self.link_pruefer.pruefe(link) aus. - 2. Übergibt das Ergebnis an den Pruefmanager (self.pruefmanager.verarbeite). - 3. Wenn die Prüfung nicht OK ist, wird nur das verarbeitete pruef_ergebnis zurückgegeben. - 4. Wenn die Prüfung OK ist, erwartet diese Implementierung, dass der Prüfer - die Link-Parameter im pruef_ergebnis.kontext als Mapping bereitstellt. - Dieses Mapping wird unverändert in ein Dict {'rows': [kontext]} überführt - und zusammen mit dem verarbeiteten pruef_ergebnis zurückgegeben. + 1. Erwartet wird ein Mapping (z.B. dict), das die Linkparameter enthält. + Mindestens der Schlüssel ``"Link"`` muss vorhanden sein. - Hinweis - ------ - Diese Funktion enthält keine Fallbacks, keine normalize-/load-Aufrufe und - keine zusätzlichen Validierungen. Der Linkpruefer ist verantwortlich dafür, - bei OK ein geeignetes Mapping im pruef_ergebnis.kontext bereitzustellen. + 2. Der eigentliche Link (z.B. URL) wird aus ``link["Link"]`` extrahiert + und an ``self.link_pruefer.pruefe(...)`` übergeben. + + 3. Das Prüfergebnis wird anschließend durch ``self.pruefmanager.verarbeite(...)`` + geleitet, der UIInteraktion, Logging und finale Entscheidung übernimmt. + + 4. Wenn das verarbeitete Prüfergebnis **nicht OK** ist, wird + ``(None, pruef_ergebnis)`` zurückgegeben. + + 5. Wenn das Prüfergebnis **OK** ist, wird das unveränderte LinkDict + in der Struktur ``{"rows": [link]}`` zurückgegeben. + + Parameter + --------- + link: + Ein Mapping mit den Linkparametern (z.B. id, Thema, Gruppe, Link, + Anbieter, Stildatei). Diese Methode verändert das Mapping nicht. + + Returns + ------- + Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis] + - ``data``: ``{"rows": [link]}`` wenn gültig, sonst ``None`` + - ``pruef_ergebnis``: das vom Pruefmanager verarbeitete Ergebnis + + Hinweise + -------- + - Diese Methode führt **keine Normalisierung** durch. + - Die Verantwortung für die Struktur des Link-Dicts liegt beim Fachplugin. + - Der Linkpruefer prüft ausschließlich den Wert ``link["Link"]``. """ - # 1) Fachliche Prüfung durch den Linkpruefer - pe = self.link_pruefer.pruefe(link) - # 2) Pruefmanager verarbeiten lassen (Logging / UI / Entscheidung) - processed = self.pruefmanager.verarbeite(pe) + # 1) Link extrahieren (Fachplugin garantiert, dass "Link" existiert) + raw_link = link.get("Link") - # 3) Wenn Prüfung nicht OK -> nur das verarbeitete pruef_ergebnis zurückgeben + # 2) Fachliche Prüfung durch den Linkpruefer + pruef_ergebnis = self.link_pruefer.pruefe(raw_link) + + # 3) Verarbeitung durch den Pruefmanager + processed = self.pruefmanager.verarbeite(pruef_ergebnis) + + # 4) Wenn Prüfung nicht OK → keine Daten zurückgeben if not getattr(processed, "ok", False): return None, processed - # 4) Prüfung OK -> Prüfer liefert die Link-Parameter im pruef_ergebnis.kontext - kontext = getattr(pe, "kontext", None) - data = {"rows": [kontext]} - # Erwartung: kontext ist ein Mapping mit den Link-Parametern. - # Wir übergeben es unverändert in das rows-Format. + # 5) Prüfung OK → unverändertes Link-Dict zurückgeben + data = {"rows": [link]} + return data, processed + + # ------------------------------------------------------------------ # # Datenbank-Verarbeitung # ------------------------------------------------------------------ # diff --git a/modules/Datenabruf.py b/modules/Datenabruf.py new file mode 100644 index 0000000..7ff9036 --- /dev/null +++ b/modules/Datenabruf.py @@ -0,0 +1,405 @@ +# sn_basis/modules/Datenabruf.py +""" +Modul ``datenabruf`` + +Enthält die Klasse :class:`Datenabruf`, die für eine Menge bereits +validierter Links (aus ``validate_rows``) die Fachdaten abruft und +aggregierte Prüfergebnisse liefert. + +Designprinzipien +---------------- +- Die BBOX wird serverseitig angewendet: wenn ein Raumfilter aktiv ist, + wird die BBOX in die Abruf-URL eingebettet (außer bei WMS). +- Alle QGIS-Interaktionen laufen über die Wrapper `qgiscore_wrapper` und + `qgisui_wrapper`. +- Fehler werden als kurze Strings zurückgegeben und zentral in `log_fehler` + gesammelt; erfolgreiche Aufrufe werden in `log_geladen` protokolliert. +- Die Methode ist pdoc-kompatibel dokumentiert und bewusst einfach gehalten. +""" + +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from urllib.parse import urlparse, parse_qsl, urlencode, urlunparse +import json + +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis +from sn_basis.functions import qgiscore_wrapper as qgiscore +from sn_basis.functions import qgisui_wrapper as qgisui +from sn_basis.functions import qt_wrapper as qt + +DataDict = Dict[str, List[Mapping[str, Any]]] + + +class Datenabruf: + """ + Führt den eigentlichen Fachdatenabruf für eine Menge validierter Links durch. + + Erwartet ein ``DataDict`` der Form ``{"rows": [row1, row2, ...]}``. + """ + + def __init__(self, pruefmanager: Any) -> None: + """ + Initialisiert eine neue Instanz des Datenabrufs. + + Parameters + ---------- + pruefmanager: + Instanz des Pruefmanagers, der :class:`pruef_ergebnis` verarbeitet. + """ + self.pruefmanager = pruefmanager + + # ------------------------------------------------------------------ # + # Öffentliche API + # ------------------------------------------------------------------ # + + def datenabruf( + self, + result_dict: DataDict, + raumfilter: str, + verfahrensgebiet_layer: Any, + speicherort: str, + pruef_ergebnisse: Optional[List[Any]] = None, + ) -> Tuple[Dict[str, Any], List[Any]]: + """ + Ruft für alle Zeilen in ``result_dict["rows"]`` die Fachdaten ab und + liefert ein Daten‑Dict sowie die Liste verarbeiteter Pruefergebnisse. + + Logging / Aggregation + --------------------- + Am Ende enthält das zusammenfassende PruefErgebnis im Kontext: + - geladen: dict(dienst -> anzahl geladen) + - fehler: dict(dienst -> fehlermeldung) + - relevant: dict(dienst -> anzahl relevant) + - ausserhalb: dict(dienst -> anzahl geladen, aber ausserhalb) + """ + if pruef_ergebnisse is None: + processed_results: List[Any] = [] + else: + processed_results = list(pruef_ergebnisse) + + rows = result_dict.get("rows", []) + daten: Dict[str, List[Any]] = {} + + # 1) Räumliche Filtergeometrie bestimmen (BBox oder None) + bbox_geom = self._determine_spatial_filter(raumfilter, verfahrensgebiet_layer) + + # Globale Logs über alle Dienste hinweg + log_geladen: Dict[str, int] = {} + log_fehler: Dict[str, str] = {} + log_relevant: Dict[str, int] = {} + log_ausserhalb: Dict[str, int] = {} + + # 2) Über alle Zeilen iterieren + for row in rows: + ident = row.get("ident") + link = row.get("Link") + provider = row.get("Provider") + + if not ident or not link or not provider: + pe = pruef_ergebnis( + ok=False, + meldung="Ungültige Zeile im Datenabruf (fehlende Pflichtfelder)", + aktion="pflichtfelder_fehlen", + kontext=row, + ) + processed_results.append(self.pruefmanager.verarbeite(pe)) + continue + + # Lesbarer Dienstname für Logs + thema = row.get("Inhalt") or row.get("Thema") or row.get("Titel") or str(ident) + + # 2a) Provider-spezifische URL zusammenbauen + # Wenn Raumfilter aktiv ist, übergeben wir bbox_geom an _build_provider_url, + # außer bei WMS (WMS bleibt unverändert). + use_bbox = (raumfilter != "ohne") and (str(provider).upper() != "WMS") + url = self._build_provider_url(link=link, provider=str(provider), bbox_geom=bbox_geom if use_bbox else None) + + # 2b) Fachdaten abrufen + features, error_msg = self._fetch_features(url=url, provider=str(provider)) + + # 2c) Logs und Aggregation + if error_msg: + # Fehler beim Abruf + log_fehler[thema] = error_msg + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Fehler beim Abruf von {thema}: {error_msg}", + aktion="url_nicht_erreichbar", + kontext={"ident": ident, "thema": thema, "url": url, "error": error_msg}, + ) + processed_results.append(self.pruefmanager.verarbeite(pe_err)) + # daten[ident] bleibt nicht gesetzt oder leer + daten[str(ident)] = [] + continue + + # Erfolgreich aufgerufen (auch wenn features == []) + anzahl_geladen = len(features) + log_geladen[thema] = anzahl_geladen + + # Da die BBOX serverseitig angewendet wurde: + # - anzahl_geladen > 0 -> relevant + # - anzahl_geladen == 0 -> ausserhalb + if anzahl_geladen > 0: + log_relevant[thema] = anzahl_geladen + daten[str(ident)] = features + else: + log_ausserhalb[thema] = 0 + daten[str(ident)] = [] + + # 2d) Kurzes Prüfergebnis pro Zeile + pe_row = pruef_ergebnis( + ok=True, + meldung=( + f"Datenabruf für ident={ident}: {anzahl_geladen} geladene Objekte" + ), + aktion="datenabruf", + kontext={ + "ident": ident, + "thema": thema, + "anzahl_gesamt": anzahl_geladen, + "url": url, + }, + ) + processed_results.append(self.pruefmanager.verarbeite(pe_row)) + + # 3) Zusammenfassendes Prüfergebnis (wie alter DataGrabber) + summary_kontext = { + "geladen": log_geladen, + "fehler": log_fehler, + "relevant": log_relevant, + "ausserhalb": log_ausserhalb, + } + + pe_summary = pruef_ergebnis( + ok=(len(log_fehler) == 0), + meldung=( + f"Datenabruf abgeschlossen: {len(log_geladen)} Dienste geladen, " + f"{len(log_fehler)} Fehler" + ), + aktion="datenabruf", + kontext=summary_kontext, + ) + processed_results.append(self.pruefmanager.verarbeite(pe_summary)) + + daten_dict: Dict[str, Any] = { + "speicherort": speicherort, + "daten": daten, + } + return daten_dict, processed_results + + # ------------------------------------------------------------------ # + # Hilfsmethoden: räumlicher Filter + # ------------------------------------------------------------------ # + + def _determine_spatial_filter(self, raumfilter: str, verfahrensgebiet_layer: Any) -> Optional[Any]: + """ + Bestimmt die räumliche Filtergeometrie (BBox) abhängig vom Raumfilter. + + Returns + ------- + Optional[Any] + Eine Geometrie/Extent (z. B. QgsRectangle) oder ``None``. + """ + if raumfilter == "ohne": + return None + + if verfahrensgebiet_layer is None: + return None + + if raumfilter == "Verfahrensgebiet": + return qgiscore.get_layer_extent(verfahrensgebiet_layer) + + if raumfilter == "Pufferlayer": + buffer_layer = qgiscore.create_buffer_layer( + source_layer=verfahrensgebiet_layer, + distance_m=1000.0, + layer_name="Verfahrensgebiet_Puffer_1km", + ) + if buffer_layer is not None: + qgisui.add_layer_to_project(buffer_layer) + return qgiscore.get_layer_extent(buffer_layer) + + return None + + # ------------------------------------------------------------------ # + # Hilfsmethoden: Provider-URL und Datenabruf + # ------------------------------------------------------------------ # + + def _build_provider_url(self, link: str, provider: str, bbox_geom: Optional[Any]) -> str: + """ + Baut eine Provider-spezifische Abruf-URL. Wenn `bbox_geom` übergeben + wird, wird sie in die URL eingebettet (außer bei WMS). + + Erwartet: provider ist gesetzt (z. B. "WFS", "REST", "OGR", "WMS"). + """ + provider_norm = (provider or "").upper() + base_link = link or "" + + # WMS: niemals BBOX anhängen + if provider_norm == "WMS": + return base_link + + if bbox_geom is None: + return base_link + + # Versuche bbox-String zu erzeugen (nutzt qgiscore.extent_to_bbox_string wenn vorhanden) + bbox_str: Optional[str] = None + try: + extent_to_bbox = getattr(__import__("sn_basis.functions.qgiscore_wrapper", fromlist=["qgiscore_wrapper"]), "extent_to_bbox_string", None) + if callable(extent_to_bbox): + bbox_str = extent_to_bbox(bbox_geom) + else: + # Fallback: einfache xmin/ymin/xmax/ymax-Extraktion (duck-typing) + if hasattr(bbox_geom, "xmin") and callable(getattr(bbox_geom, "xmin")): + bbox_str = f"{bbox_geom.xmin()},{bbox_geom.ymin()},{bbox_geom.xmax()},{bbox_geom.ymax()}" + elif isinstance(bbox_geom, (tuple, list)) and len(bbox_geom) == 4: + bbox_str = f"{bbox_geom[0]},{bbox_geom[1]},{bbox_geom[2]},{bbox_geom[3]}" + else: + bbox_str = str(bbox_geom) + except Exception: + bbox_str = None + + if not bbox_str: + return base_link + + parsed = urlparse(base_link) + query_params = dict(parse_qsl(parsed.query, keep_blank_values=True)) + + if provider_norm == "WFS": + query_params.setdefault("BBOX", bbox_str) + new_query = urlencode(query_params, doseq=True) + rebuilt = parsed._replace(query=new_query) + return urlunparse(rebuilt) + + if provider_norm in ("REST", "ARCGIS", "ARCGISFEATURESERVER", "ARCGIS_FEATURESERVER"): + query_params.setdefault("geometry", bbox_str) + query_params.setdefault("geometryType", "esriGeometryEnvelope") + query_params.setdefault("spatialRel", "esriSpatialRelIntersects") + query_params.setdefault("f", query_params.get("f", "json")) + new_query = urlencode(query_params, doseq=True) + rebuilt = parsed._replace(query=new_query) + return urlunparse(rebuilt) + + # Default: generischer bbox-Parameter + query_params.setdefault("bbox", bbox_str) + new_query = urlencode(query_params, doseq=True) + rebuilt = parsed._replace(query=new_query) + return urlunparse(rebuilt) + + def _fetch_features(self, url: str, provider: str) -> Tuple[List[Any], Optional[str]]: + """ + Führt den eigentlichen Abruf der Fachdaten durch. + + Returns + ------- + Tuple[List[Any], Optional[str]] + - features: Liste der geladenen Features (ggf. leer) + - error_msg: None bei Erfolg, sonst kurzer Fehlertext + """ + features: List[Any] = [] + prov = str(provider).upper() + + # WMS: kein Featureabruf; caller behandelt WMS separat (hier defensiv) + if prov == "WMS": + return [], None + + # OGR / lokale Dateien: versuche QGIS-Layer (wenn QGIS verfügbar) + if prov in ("OGR", "GPKG", "SHP", "GEOJSON"): + if getattr(qgiscore, "QGIS_AVAILABLE", False): + try: + layer = qgiscore.QgsVectorLayer(url, "tmp", "ogr") + if not layer or not getattr(layer, "isValid", lambda: False)(): + return [], "Layer ungültig oder konnte nicht geladen werden" + for feat in layer.getFeatures(): + features.append(feat) + return features, None + except FileNotFoundError: + return [], "Lokale Datei nicht gefunden" + except Exception as exc: + return [], f"Fehler beim Laden der OGR-Quelle: {exc}" + else: + # Mock: falls GeoJSON-Datei vorhanden, versuche lokale Datei zu lesen + try: + if url.lower().endswith(".geojson"): + with open(url, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict) and data.get("type") == "FeatureCollection": + return data.get("features", []), None + return [], "Keine QGIS-Umgebung und keine lesbare lokale GeoJSON" + except FileNotFoundError: + return [], "Lokale Datei nicht gefunden" + except Exception as exc: + return [], f"Fehler beim Lesen lokaler GeoJSON (Mock): {exc}" + + # HTTP-basierte Dienste (WFS, REST/ArcGIS, generisch) + response_text: Optional[str] = None + http_error: Optional[str] = None + + # QGIS NetworkAccessManager bevorzugen + if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsNetworkAccessManager", None) is not None: + try: + manager = qgiscore.QgsNetworkAccessManager.instance() + QUrl = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QUrl", None) + QNetworkRequest = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QNetworkRequest", None) + QEventLoop = getattr(__import__("sn_basis.functions.qt_wrapper", fromlist=["qt_wrapper"]), "QEventLoop", None) + if QUrl is not None and QNetworkRequest is not None: + req = QNetworkRequest(QUrl(url)) + reply = manager.get(req) + if QEventLoop is not None: + loop = QEventLoop() + reply.finished.connect(loop.quit) + loop.exec() + try: + raw = reply.readAll() + data_bytes = bytes(raw) if hasattr(raw, "__bytes__") else raw + response_text = data_bytes.decode("utf-8", errors="replace") + except Exception: + try: + response_text = reply.text() + except Exception: + response_text = None + except Exception as exc: + http_error = f"QgsNetworkAccessManager error: {exc}" + response_text = None + + # Fallback: requests + if response_text is None: + try: + import requests # lokal import, keine harte Abhängigkeit + r = requests.get(url, timeout=30) + r.raise_for_status() + response_text = r.text + except Exception as exc: + http_error = f"requests error: {exc}" + response_text = None + + if response_text is None: + return [], http_error or "keine Antwort vom Server" + + # Versuche JSON/GeoJSON zu parsen + try: + parsed = json.loads(response_text) + if isinstance(parsed, dict) and parsed.get("type") == "FeatureCollection": + return parsed.get("features", []), None + if isinstance(parsed, dict) and "features" in parsed: + return parsed.get("features", []), None + # Sonst: gib das gesamte JSON als einzelnes Objekt zurück + return [parsed], None + except json.JSONDecodeError: + # Nicht-JSON-Antwort (z. B. GML). Wenn QGIS verfügbar, versuche GML via temporärer Datei + OGR + if getattr(qgiscore, "QGIS_AVAILABLE", False): + try: + import tempfile + with tempfile.NamedTemporaryFile(suffix=".gml", delete=False, mode="w", encoding="utf-8") as fh: + fh.write(response_text) + tmp_path = fh.name + layer = qgiscore.QgsVectorLayer(tmp_path, "tmp_gml", "ogr") + if layer and getattr(layer, "isValid", lambda: False)(): + for feat in layer.getFeatures(): + features.append(feat) + return features, None + return [], "GML-Antwort konnte nicht als Layer geladen werden" + except Exception as exc: + return [], f"Fehler beim Parsen von GML: {exc}" + # Wenn alles fehlschlägt: + return [], "Antwort konnte nicht als JSON oder GML geparst werden" diff --git a/modules/Datenschreiber.py b/modules/Datenschreiber.py new file mode 100644 index 0000000..143e090 --- /dev/null +++ b/modules/Datenschreiber.py @@ -0,0 +1,435 @@ +# sn_basis/modules/Datenschreiber.py +""" +Modul Datenschreiber + +Enthält die Klasse Datenschreiber mit drei Hauptmethoden: + +- schreibe_Daten: schreibt die abgerufenen Daten in die Ziel-GPKG/Dateien, + fragt bei vorhandenen Layern nach Überschreiben/Anhängen/Abbrechen und + legt Stile in der Datenbank ab. +- lade_Layer: lädt die erzeugten/aktualisierten Layer ins Projekt und + wendet die Vorgabestile an; sortiert abschließend die Layer. +- schreibe_log: schreibt die verarbeiteten Pruefergebnisse strukturiert in + eine Log-Datei im angegebenen Speicherort. + +Die Implementierung verwendet die Wrapper-APIs: +- qgiscore_wrapper als qgiscore +- qgisui_wrapper als qgisui (nur wenn nötig) +- qt_wrapper als qt + +Wichtig +------ +Alle Nutzerinteraktionen (z. B. Überschreiben / Anhängen / Abbrechen) werden +zentral über den Pruefmanager gebündelt. Die Methode `ask_overwrite_append_cancel` +des Pruefmanagers wird verwendet, damit UI-Interaktionen an einer Stelle +konsolidiert und testbar sind. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional +import os +import json +import datetime + +from sn_basis.functions import qgiscore_wrapper as qgiscore +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis + + +class Datenschreiber: + """ + Schreibt abgerufene Fachdaten in die Zieldatenbank/Dateien und lädt + die Layer ins Projekt. + + Konstruktor + ---------- + pruefmanager: + Instanz des Pruefmanagers; wird verwendet, um Pruefergebnisse zu + verarbeiten und Nutzerinteraktionen zu zentralisieren. + gpkg_path: + Pfad zur Ziel-GPKG-Datei (oder Verzeichnis). Wenn None, muss der + Aufrufer einen Speicherort übergeben. + """ + + def __init__(self, pruefmanager: Any, gpkg_path: Optional[str] = None) -> None: + self.pruefmanager = pruefmanager + self.gpkg_path = gpkg_path + + # ------------------------------------------------------------------ # + # Schreibe Daten + # ------------------------------------------------------------------ # + def schreibe_Daten( + self, + daten_dict: Dict[str, Any], + processed_results: List[Any], + speicherort: str, + ) -> List[Dict[str, Any]]: + """ + Schreibt die abgerufenen Daten in die Zieldatenbank/Dateien. + + Ablauf + ------ + Für jede Zeile (ident) in ``daten_dict["daten"]``: + 1. Bestimme Ziel-Layername (z. B. Thema oder ident). + 2. Prüfe, ob ein Layer mit diesem Namen bereits existiert (Wrapper). + 3. Falls vorhanden, frage den Benutzer (Überschreiben / Anhängen / Abbrechen) + über die zentrale Pruefmanager-Methode `ask_overwrite_append_cancel`. + 4. Führe die gewählte Operation aus oder schreibe den Layer, wenn er noch nicht existiert. + 5. Schreibe ggf. den Stil in die GPKG und setze ihn als Vorgabe. + 6. Sammle und gib eine Liste der angelegten/geänderten Layer zurück. + + Returns + ------- + List[Dict[str, Any]] + Liste von Dicts mit Informationen zu jedem angelegten/geänderten Layer. + """ + if not speicherort: + raise ValueError("Ein gültiger Speicherort (speicherort) muss übergeben werden.") + + # Setze gpkg_path falls noch nicht vorhanden + if not self.gpkg_path: + self.gpkg_path = speicherort + + results: List[Dict[str, Any]] = [] + daten_map: Dict[str, List[Any]] = daten_dict.get("daten", {}) + + # Iteriere über alle Einträge + for ident, features in daten_map.items(): + # Thema/Name ableiten (falls vorhanden in processed_results oder ident) + thema = None + for pe in processed_results: + try: + kontext = getattr(pe, "kontext", None) or {} + if kontext and kontext.get("ident") == ident: + thema = kontext.get("thema") + break + except Exception: + continue + if not thema: + thema = str(ident) + + layer_name = thema + + # Prüfe, ob Layer bereits existiert in der Ziel-GPKG + layer_exists = False + try: + layer_exists_fn = getattr(qgiscore, "layer_exists_in_gpkg", None) + if callable(layer_exists_fn): + layer_exists = layer_exists_fn(self.gpkg_path, layer_name) + else: + # Fallback: QGIS-Fallback-Check via QgsVectorLayer + if getattr(qgiscore, "QgsVectorLayer", None) is not None and qgiscore.QGIS_AVAILABLE: + uri = f"{self.gpkg_path}|layername={layer_name}" + layer = qgiscore.QgsVectorLayer(uri, layer_name, "ogr") + layer_exists = bool(layer and getattr(layer, "isValid", lambda: False)()) + except Exception: + layer_exists = False + + operation = "created" + + if layer_exists: + # Zentrale Nutzerabfrage über Pruefmanager + # Erwartet Rückgabe: "overwrite" | "append" | "cancel" + try: + user_choice = self.pruefmanager.ask_overwrite_append_cancel(layer_name) + except Exception: + # Fallback: overwrite, falls Pruefmanager nicht verfügbar + user_choice = "overwrite" + + if user_choice == "cancel": + operation = "skipped" + results.append({ + "ident": ident, + "thema": thema, + "operation": operation, + "layer_path": f"{self.gpkg_path}|layername={layer_name}", + "feature_count": 0, + }) + continue + + if user_choice == "overwrite": + write_err = self._write_layer_to_gpkg(layer_name, features, mode="overwrite") + if write_err: + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Fehler beim Überschreiben von {layer_name}: {write_err}", + aktion="save_exception", + kontext={"ident": ident, "thema": thema, "error": write_err}, + ) + self.pruefmanager.verarbeite(pe_err) + operation = "skipped" + results.append({ + "ident": ident, + "thema": thema, + "operation": operation, + "layer_path": f"{self.gpkg_path}|layername={layer_name}", + "feature_count": 0, + }) + continue + else: + operation = "overwritten" + + elif user_choice == "append": + write_err = self._write_layer_to_gpkg(layer_name, features, mode="append") + if write_err: + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Fehler beim Anhängen an {layer_name}: {write_err}", + aktion="save_exception", + kontext={"ident": ident, "thema": thema, "error": write_err}, + ) + self.pruefmanager.verarbeite(pe_err) + operation = "skipped" + results.append({ + "ident": ident, + "thema": thema, + "operation": operation, + "layer_path": f"{self.gpkg_path}|layername={layer_name}", + "feature_count": 0, + }) + continue + else: + operation = "appended" + + else: + # Layer existiert nicht -> neu anlegen + write_err = self._write_layer_to_gpkg(layer_name, features, mode="create") + if write_err: + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Fehler beim Erstellen von {layer_name}: {write_err}", + aktion="save_exception", + kontext={"ident": ident, "thema": thema, "error": write_err}, + ) + self.pruefmanager.verarbeite(pe_err) + operation = "skipped" + results.append({ + "ident": ident, + "thema": thema, + "operation": operation, + "layer_path": f"{self.gpkg_path}|layername={layer_name}", + "feature_count": 0, + }) + continue + else: + operation = "created" + + # Stilbehandlung (falls in processed_results referenziert) + style_written = False + style_path = None + for pe in processed_results: + try: + kontext = getattr(pe, "kontext", None) or {} + if kontext and kontext.get("ident") == ident: + style_path = kontext.get("stildatei") or kontext.get("Stildatei") + break + except Exception: + continue + + if style_path: + if not os.path.isabs(style_path): + base_dir = os.path.dirname(__file__) + style_path = os.path.join(base_dir, style_path) + write_style_fn = getattr(qgiscore, "write_style_to_gpkg", None) + if callable(write_style_fn): + try: + write_style_fn(self.gpkg_path, style_path, layer_name) + style_written = True + except Exception: + style_written = False + + feature_count = len(features) if isinstance(features, list) else 0 + + results.append({ + "ident": ident, + "thema": thema, + "operation": operation, + "layer_path": f"{self.gpkg_path}|layername={layer_name}", + "feature_count": feature_count, + "style_written": style_written, + }) + + return results + + # ------------------------------------------------------------------ # + # Lade Layer ins Projekt + # ------------------------------------------------------------------ # + def lade_Layer(self, layer_infos: List[Dict[str, Any]]) -> None: + """ + Lädt die in schreibe_Daten erzeugten/aktualisierten Layer ins Projekt + und wendet die Vorgabestile an. + """ + loaded_layers = [] + + for info in layer_infos: + layer_path = info.get("layer_path") + thema = info.get("thema") + if not layer_path: + continue + + try: + layer = qgiscore.QgsVectorLayer(layer_path, thema, "ogr") + if not layer or not getattr(layer, "isValid", lambda: False)(): + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Layer {thema} konnte nicht geladen werden", + aktion="layer_nicht_gefunden", + kontext={"layer_path": layer_path}, + ) + self.pruefmanager.verarbeite(pe_err) + continue + except Exception as exc: + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Fehler beim Erzeugen des Layers {thema}: {exc}", + aktion="layer_nicht_gefunden", + kontext={"layer_path": layer_path, "error": str(exc)}, + ) + self.pruefmanager.verarbeite(pe_err) + continue + + try: + apply_style_fn = getattr(qgiscore, "apply_default_style_from_gpkg", None) + if callable(apply_style_fn): + apply_style_fn(self.gpkg_path, layer) + except Exception: + pe_warn = pruef_ergebnis( + ok=True, + meldung=f"Style konnte für {thema} nicht automatisch angewendet werden", + aktion="stil_not_implemented", + kontext={"thema": thema}, + ) + self.pruefmanager.verarbeite(pe_warn) + + try: + # qgisui wrapper wird hier nicht direkt für die Abfrage verwendet; + # qgisui.add_layer_to_project sollte aber vorhanden sein. + from sn_basis.functions import qgisui_wrapper as qgisui + add_fn = getattr(qgisui, "add_layer_to_project", None) + if callable(add_fn): + add_fn(layer) + else: + # Fallback: falls wrapper nicht vorhanden, versuche QGIS-API direkt + if getattr(qgiscore, "QgsProject", None) is not None and qgiscore.QGIS_AVAILABLE: + qgiscore.QgsProject.instance().addMapLayer(layer) + loaded_layers.append(layer) + except Exception: + pe_err = pruef_ergebnis( + ok=False, + meldung=f"Layer {thema} konnte nicht ins Projekt geladen werden", + aktion="layer_nicht_gefunden", + kontext={"thema": thema}, + ) + self.pruefmanager.verarbeite(pe_err) + continue + + # Sortiere Layer im Projekt nach ID (Wrapper-Funktion bevorzugt) + sort_fn = getattr(qgiscore, "sort_layers_by_id", None) + if callable(sort_fn): + try: + sort_fn() + except Exception: + pass + + # ------------------------------------------------------------------ # + # Schreibe Log + # ------------------------------------------------------------------ # + def schreibe_log(self, processed_results: List[Any], speicherort: str) -> str: + """ + Schreibt die verarbeiteten Pruefergebnisse strukturiert in eine Log-Datei. + """ + if not speicherort: + raise ValueError("Ein gültiger Speicherort muss übergeben werden.") + + log_dir = speicherort + os.makedirs(log_dir, exist_ok=True) + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + log_path = os.path.join(log_dir, f"datenabruf_log_{timestamp}.json") + + serializable: List[Dict[str, Any]] = [] + for pe in processed_results: + try: + entry = {} + entry["ok"] = getattr(pe, "ok", None) if hasattr(pe, "ok") else None + entry["meldung"] = getattr(pe, "meldung", None) if hasattr(pe, "meldung") else None + kontext = getattr(pe, "kontext", None) if hasattr(pe, "kontext") else None + entry["kontext"] = kontext + serializable.append(entry) + except Exception: + serializable.append({"raw": str(pe)}) + + with open(log_path, "w", encoding="utf-8") as fh: + json.dump(serializable, fh, ensure_ascii=False, indent=2) + + pe_log = pruef_ergebnis( + ok=True, + meldung=f"Log geschrieben: {os.path.basename(log_path)}", + aktion="standarddatei_vorschlagen", + kontext={"log_path": log_path}, + ) + self.pruefmanager.verarbeite(pe_log) + + return log_path + + # ------------------------------------------------------------------ # + # Hilfsfunktionen intern + # ------------------------------------------------------------------ # + def _write_layer_to_gpkg(self, layer_name: str, features: List[Any], mode: str = "create") -> Optional[str]: + """ + Interne Hilfsfunktion zum Schreiben eines Layers in das GPKG. + + Erwartete qgiscore-Funktion: + qgiscore.write_features_to_gpkg(gpkg_path, layer_name, features, mode) + """ + write_fn = getattr(qgiscore, "write_features_to_gpkg", None) + if callable(write_fn): + try: + write_fn(self.gpkg_path, layer_name, features, mode) + return None + except Exception as exc: + return str(exc) + + # Fallback: Verwende QgsVectorFileWriter, falls QGIS verfügbar + if getattr(qgiscore, "QGIS_AVAILABLE", False) and getattr(qgiscore, "QgsVectorFileWriter", None) is not None: + try: + # Minimaler Fallback: erwarte, dass 'features' eine Liste von QgsFeature ist + if not features: + # Erstelle leeren Layer-Eintrag (GPKG erlaubt leere Layer) + # Hier vereinfachen wir: writeAsVectorFormatV3 benötigt ein Layer-Objekt. + return None + + # Versuche, ein Memory-Layer aus dem ersten Feature zu ermitteln + first = features[0] + mem_layer = None + if hasattr(first, "fields") and hasattr(first, "geometry"): + # Wenn Features QgsFeature sind, versuchen wir, das zugehörige Layer zu nutzen + try: + mem_layer = first.layer() if hasattr(first, "layer") else None + except Exception: + mem_layer = None + + if mem_layer is None: + return "Keine Feld-/Geometrie-Informationen zum Schreiben vorhanden" + + opts = qgiscore.QgsVectorFileWriter.SaveVectorOptions() + opts.driverName = "GPKG" + opts.layerName = layer_name + opts.fileEncoding = "UTF-8" + if mode == "overwrite": + opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteFile + else: + opts.actionOnExistingFile = qgiscore.QgsVectorFileWriter.CreateOrOverwriteLayer + + err = qgiscore.QgsVectorFileWriter.writeAsVectorFormatV3( + mem_layer, + self.gpkg_path, + qgiscore.QgsProject.instance().transformContext(), + opts + ) + if err != qgiscore.QgsVectorFileWriter.NoError: + return f"Fehler beim Schreiben (Code {err})" + return None + except Exception as exc: + return str(exc) + + return "Keine Schreib-Funktion verfügbar (Wrapper nicht implementiert)" diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index a7a8910..12d8669 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -140,6 +140,7 @@ class Pruefmanager: "kein_arbeitsblatt", "read_error", "open_error", + "pflichtfelder_fehlen", } if aktion in informational_actions: return "abort" @@ -202,3 +203,74 @@ class Pruefmanager: # Standard: keine Änderung return ergebnis + def ask_overwrite_append_cancel(self, layer_name: str, default: str = "overwrite") -> str: + """ + Zeigt dem Nutzer eine Auswahl für einen bereits existierenden Layer an. + + Rückgabe + ------- + str + Einer der Werte: "overwrite", "append", "cancel". + + Verhalten + -------- + - Verwendet bevorzugt die UI-Wrapper-Funktion `qt_wrapper` / `qgisui_wrapper`, + falls vorhanden (z. B. ein QMessageBox-Dialog mit drei Buttons). + - Im Mock- oder Headless-Modus (kein Qt/QGIS verfügbar) wird der übergebene + `default`-Wert zurückgegeben. + - Alle Nutzerinteraktionen laufen über diese zentrale Methode, damit das + Plugin an einer Stelle gesteuert und ggf. getested werden kann. + + Parameter + --------- + layer_name: + Anzeigename des Layers, der bereits existiert (wird im Dialog angezeigt). + default: + Rückgabewert im Headless/Mock-Modus oder wenn der Dialog nicht verfügbar ist. + Gültige Werte: "overwrite", "append", "cancel". Standard: "overwrite". + """ + # Validierung des Defaults + if default not in ("overwrite", "append", "cancel"): + default = "overwrite" + + # Versuche, eine UI-Wrapper-Funktion zu verwenden, falls vorhanden + try: + # qgisui_wrapper kann eine spezialisierte Dialogfunktion bereitstellen + from sn_basis.functions import qgisui_wrapper as qgisui + ask_fn = getattr(qgisui, "ask_overwrite_append_cancel", None) + if callable(ask_fn): + # Die Wrapper-Funktion soll genau die drei Strings zurückgeben + choice = ask_fn(layer_name) + if choice in ("overwrite", "append", "cancel"): + return choice + except Exception: + # Falls Import/Wrapper fehlschlägt, weiter zum Qt-Fallback + pass + + # Fallback: direkte Qt-Dialoge über qt_wrapper (wenn verfügbar) + try: + from sn_basis.functions import qt_wrapper as qt + QMessageBox = getattr(qt, "QMessageBox", None) + if QMessageBox is not None: + # Erzeuge und konfiguriere Dialog + msg = QMessageBox() + msg.setWindowTitle("Layer bereits vorhanden") + msg.setText(f"Der Layer '{layer_name}' existiert bereits. Was möchten Sie tun?") + overwrite_btn = msg.addButton("Überschreiben", QMessageBox.AcceptRole) + append_btn = msg.addButton("Anhängen", QMessageBox.AcceptRole) + cancel_btn = msg.addButton("Abbrechen", QMessageBox.RejectRole) + msg.setDefaultButton(overwrite_btn) + # Blockierend anzeigen + msg.exec_() + clicked = msg.clickedButton() + if clicked == overwrite_btn: + return "overwrite" + if clicked == append_btn: + return "append" + return "cancel" + except Exception: + # Qt nicht verfügbar oder Fehler beim Dialogaufbau + pass + + # Headless / Mock: gib Default zurück + return default diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index c7313be..af0054d 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -26,11 +26,13 @@ PruefAktion = Literal[ "datenquelle_unerwartet", "layer_nicht_editierbar", "falsche_endung", + "pflichtfelder_fehlen", # Excel / Import-spezifische Aktionen "kein_header", "kein_arbeitsblatt", "read_error", "open_error", + "datenabruf", # Generische Prüf-/Speicher-Aktionen "pruefe_exception", "save_exception", From 3b56725e4f4f718703c9b0b41d286509bd481b58 Mon Sep 17 00:00:00 2001 From: daniel Date: Wed, 4 Mar 2026 15:32:49 +0100 Subject: [PATCH 10/11] =?UTF-8?q?qt=5Fwrapper,=20dialog;wrapper,=20Pruef?= =?UTF-8?q?=5Fergebnis=20und=20Pruefmanager=20=C3=BCberarbeitet,=20so=20da?= =?UTF-8?q?ss=20die=20=C3=9Cbergaben=20jetzt=20stimmen.=20Nutzerabfragen?= =?UTF-8?q?=20werden=20tats=C3=A4chlich=20ausgel=C3=B6st-=20Nutzerabfrage?= =?UTF-8?q?=20Datei=20=C3=BCberschreiebn...=20ist=20noch=20Bl=C3=B6dsinn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pdoc__.py | 3 + functions/dialog_wrapper.py | 65 ++--- functions/qt_wrapper.py | 543 +++++++++++++----------------------- modules/DataGrabber.py | 486 ++++++++------------------------ modules/Dateipruefer.py | 180 ++++++++---- modules/Pruefmanager.py | 398 +++++++++++--------------- modules/pruef_ergebnis.py | 134 ++++++++- 7 files changed, 756 insertions(+), 1053 deletions(-) create mode 100644 __pdoc__.py diff --git a/__pdoc__.py b/__pdoc__.py new file mode 100644 index 0000000..bfecc06 --- /dev/null +++ b/__pdoc__.py @@ -0,0 +1,3 @@ +__pdoc__ = { + "main": False, +} diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py index 3ed9c41..f91c4bf 100644 --- a/functions/dialog_wrapper.py +++ b/functions/dialog_wrapper.py @@ -1,62 +1,37 @@ """ -sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge - -Dieser Wrapper kapselt alle Benutzer-Dialoge (z. B. Ja/Nein-Abfragen) -und sorgt dafür, dass sie sowohl in QGIS als auch im Mock-/Testmodus -einheitlich funktionieren. +sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge (Qt5/6/Mock-kompatibel) """ - from typing import Any - -# Import der abstrahierten Qt-Klassen aus dem qt_wrapper. -# QMessageBox, YES und NO sind bereits kompatibel zu Qt5/Qt6 -# und im Mock-Modus durch Dummy-Objekte ersetzt. from sn_basis.functions.qt_wrapper import ( - QMessageBox, - YES, - NO, + QMessageBox, YES, NO, QT_VERSION ) - -# --------------------------------------------------------- -# Öffentliche API -# --------------------------------------------------------- - def ask_yes_no( title: str, message: str, - default: bool = False, + default: bool = True, parent: Any = None, ) -> bool: """ - Stellt dem Benutzer eine Ja/Nein-Frage. - - - In einer echten QGIS-Umgebung wird ein QMessageBox-Dialog angezeigt. - - Im Mock-/Testmodus wird kein Dialog geöffnet, sondern der Default-Wert - zurückgegeben, damit Tests ohne UI laufen können. - - :param title: Titel des Dialogs - :param message: Nachrichtentext - :param default: Rückgabewert im Fehler- oder Mock-Fall - :param parent: Optionales Parent-Widget - :return: True bei "Ja", False bei "Nein" + Stellt Ja/Nein-Frage. Funktioniert in PyQt5/6 UND Mock-Modus. """ try: - # Definiert die beiden Buttons, die angezeigt werden sollen. - buttons = QMessageBox.Yes | QMessageBox.No - - # Öffnet den Dialog (oder im Mock-Modus: simuliert ihn). + if QT_VERSION == 0: # Mock-Modus + print(f"🔍 Mock-Modus: ask_yes_no('{title}') → {default}") + return default + + # ✅ KORREKT: Verwende YES/NO-Aliase aus qt_wrapper! + buttons = YES | NO + default_button = YES if default else NO + result = QMessageBox.question( - parent, - title, - message, - buttons, - YES if default else NO, # Vorauswahl abhängig vom Default + parent, title, message, buttons, default_button ) - - # Gibt True zurück, wenn der Benutzer "Ja" gewählt hat. - return result == YES - - except Exception: - # Falls Qt nicht verfügbar ist (Mock/CI), wird der Default-Wert genutzt. + + # ✅ int(result) == int(YES) funktioniert Qt5/6/Mock + print(f"DEBUG ask_yes_no: result={result}, YES={YES}, match={int(result) == int(YES)}") + return int(result) == int(YES) + + except Exception as e: + print(f"⚠️ ask_yes_no Fehler: {e}") return default diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index 706c6b0..09dfa40 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -1,90 +1,95 @@ """ -sn_basis/functions/qt_wrapper.py – zentrale Qt-Abstraktion (PyQt5 / PyQt6 / Mock) +sn_basis/functions/qt_wrapper.py – zentrale Qt-Abstraktion (PyQt6 primär / PyQt5 Fallback / Mock) """ -from typing import Optional, Type, Any - -# --------------------------------------------------------- -# Qt-Symbole (werden dynamisch gesetzt) -# --------------------------------------------------------- - -QDockWidget: Type[Any] -QMessageBox: Type[Any] -QFileDialog: Type[Any] -QEventLoop: Type[Any] -QUrl: Type[Any] -QNetworkRequest: Type[Any] -QNetworkReply: Type[Any] -QCoreApplication: Type[Any] - -QWidget: Type[Any] -QGridLayout: Type[Any] -QLabel: Type[Any] -QLineEdit: Type[Any] -QGroupBox: Type[Any] -QVBoxLayout: Type[Any] -QPushButton: Type[Any] -QAction: Type[Any] -QMenu: Type[Any] -QToolBar: Type[Any] -QActionGroup: Type[Any] -QTabWidget: type -QToolButton: Type[Any] -QSizePolicy: Type[Any] -Qt: Type[Any] -QComboBox: Type[Any] +from typing import Optional, Type, Any, Callable +# Globale Qt-Symbole (werden dynamisch gesetzt) +QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 YES: Optional[Any] = None NO: Optional[Any] = None CANCEL: Optional[Any] = None ICON_QUESTION: Optional[Any] = None -QT_VERSION = 0 # 0 = Mock, 5 = PyQt5, 6 = PyQt6 - +# Qt-Klassen (werden dynamisch gesetzt) +QDockWidget: Type[Any] = object +QMessageBox: Type[Any] = object +QFileDialog: Type[Any] = object +QEventLoop: Type[Any] = object +QUrl: Type[Any] = object +QNetworkRequest: Type[Any] = object +QNetworkReply: Type[Any] = object +QCoreApplication: Type[Any] = object +QWidget: Type[Any] = object +QGridLayout: Type[Any] = object +QLabel: Type[Any] = object +QLineEdit: Type[Any] = object +QGroupBox: Type[Any] = object +QVBoxLayout: Type[Any] = object +QPushButton: Type[Any] = object +QAction: Type[Any] = object +QMenu: Type[Any] = object +QToolBar: Type[Any] = object +QActionGroup: Type[Any] = object +QTabWidget: Type[Any] = object +QToolButton: Type[Any] = object +QSizePolicy: Type[Any] = object +Qt: Type[Any] = object +QComboBox: Type[Any] = object def exec_dialog(dialog: Any) -> Any: - raise NotImplementedError + """Führt Dialog modal aus (Qt6: exec(), Qt5: exec_(), Mock: YES)""" + raise NotImplementedError("Qt nicht initialisiert") +def debug_qt_status() -> None: + """Debug: Zeigt Qt-Status für Troubleshooting.""" + print(f"🔍 QT_VERSION: {QT_VERSION}") + print(f"🔍 QMessageBox Typ: {getattr(QMessageBox, '__name__', type(QMessageBox).__name__)}") + print(f"🔍 YES Wert: {YES} (Typ: {type(YES) if YES is not None else 'None'})") + + if QT_VERSION == 0: + print("❌ MOCK-MODUS AKTIV! Keine Dialoge möglich!") + elif QT_VERSION == 5: + print("✅ PyQt5 geladen (Fallback) – Dialoge sollten funktionieren!") + elif QT_VERSION == 6: + print("✅ PyQt6 geladen (primär) – Dialoge sollten funktionieren!") + else: + print("❓ Unbekannte Qt-Version!") -# --------------------------------------------------------- -# Versuch: PyQt6 -# --------------------------------------------------------- - +# --------------------------- PYQT6 PRIMÄR --------------------------- try: - from qgis.PyQt.QtWidgets import ( # type: ignore - QMessageBox as _QMessageBox,# type: ignore - QFileDialog as _QFileDialog,# type: ignore - QWidget as _QWidget,# type: ignore - QGridLayout as _QGridLayout,# type: ignore - QLabel as _QLabel,# type: ignore - QLineEdit as _QLineEdit,# type: ignore - QGroupBox as _QGroupBox,# type: ignore - QVBoxLayout as _QVBoxLayout,# type: ignore - QPushButton as _QPushButton,# type: ignore - QAction as _QAction, - QMenu as _QMenu,# type: ignore - QToolBar as _QToolBar,# type: ignore - QActionGroup as _QActionGroup,# type: ignore - QDockWidget as _QDockWidget,# type: ignore - QTabWidget as _QTabWidget,# type: ignore - QToolButton as _QToolButton,#type:ignore - QSizePolicy as _QSizePolicy,#type:ignore - QComboBox as _QComboBox, - -) - - - - from qgis.PyQt.QtCore import ( # type: ignore - QEventLoop as _QEventLoop,# type: ignore - QUrl as _QUrl,# type: ignore - QCoreApplication as _QCoreApplication,# type: ignore - Qt as _Qt#type:ignore + from qgis.PyQt.QtWidgets import ( + QMessageBox as _QMessageBox, + QFileDialog as _QFileDialog, + QWidget as _QWidget, + QGridLayout as _QGridLayout, + QLabel as _QLabel, + QLineEdit as _QLineEdit, + QGroupBox as _QGroupBox, + QVBoxLayout as _QVBoxLayout, + QPushButton as _QPushButton, + QAction as _QAction, + QMenu as _QMenu, + QToolBar as _QToolBar, + QActionGroup as _QActionGroup, + QDockWidget as _QDockWidget, + QTabWidget as _QTabWidget, + QToolButton as _QToolButton, + QSizePolicy as _QSizePolicy, + QComboBox as _QComboBox, ) - from qgis.PyQt.QtNetwork import ( # type: ignore - QNetworkRequest as _QNetworkRequest,# type: ignore - QNetworkReply as _QNetworkReply,# type: ignore + from qgis.PyQt.QtCore import ( + QEventLoop as _QEventLoop, + QUrl as _QUrl, + QCoreApplication as _QCoreApplication, + Qt as _Qt, ) + from qgis.PyQt.QtNetwork import ( + QNetworkRequest as _QNetworkRequest, + QNetworkReply as _QNetworkReply, + ) + + # ✅ ALLE GLOBALS ZUWEISEN QT_VERSION = 6 QMessageBox = _QMessageBox QFileDialog = _QFileDialog @@ -93,7 +98,7 @@ try: QNetworkRequest = _QNetworkRequest QNetworkReply = _QNetworkReply QCoreApplication = _QCoreApplication - Qt=_Qt + Qt = _Qt QDockWidget = _QDockWidget QWidget = _QWidget QGridLayout = _QGridLayout @@ -107,51 +112,37 @@ try: QToolBar = _QToolBar QActionGroup = _QActionGroup QTabWidget = _QTabWidget - QToolButton=_QToolButton - QSizePolicy=_QSizePolicy - QComboBox=_QComboBox - + QToolButton = _QToolButton + QSizePolicy = _QSizePolicy + QComboBox = _QComboBox + + # ✅ QT6 ENUMS YES = QMessageBox.StandardButton.Yes NO = QMessageBox.StandardButton.No CANCEL = QMessageBox.StandardButton.Cancel ICON_QUESTION = QMessageBox.Icon.Question - # --------------------------------------------------------- - # Qt6 Enum-Aliase (vereinheitlicht) - # --------------------------------------------------------- - + + # Qt6 Enum-Aliase ToolButtonTextBesideIcon = Qt.ToolButtonStyle.ToolButtonTextBesideIcon ArrowDown = Qt.ArrowType.DownArrow ArrowRight = Qt.ArrowType.RightArrow - # QSizePolicy Enum-Aliase (Qt6) SizePolicyPreferred = QSizePolicy.Policy.Preferred SizePolicyMaximum = QSizePolicy.Policy.Maximum - # --------------------------------------------------------- - # QDockWidget Feature-Aliase (Qt6) - # --------------------------------------------------------- - DockWidgetMovable = QDockWidget.DockWidgetFeature.DockWidgetMovable DockWidgetFloatable = QDockWidget.DockWidgetFeature.DockWidgetFloatable DockWidgetClosable = QDockWidget.DockWidgetFeature.DockWidgetClosable - # --------------------------------------------------------- - # Dock-Area-Aliase (Qt6) - # --------------------------------------------------------- - DockAreaLeft = Qt.DockWidgetArea.LeftDockWidgetArea DockAreaRight = Qt.DockWidgetArea.RightDockWidgetArea - - - def exec_dialog(dialog: Any) -> Any: return dialog.exec() + + print(f"✅ qt_wrapper: PyQt6 geladen (QT_VERSION={QT_VERSION})") -# --------------------------------------------------------- -# Versuch: PyQt5 -# --------------------------------------------------------- - -except Exception: +# --------------------------- PYQT5 FALLBACK --------------------------- +except (ImportError, AttributeError): try: - from PyQt5.QtWidgets import (# type: ignore + from PyQt5.QtWidgets import ( QMessageBox as _QMessageBox, QFileDialog as _QFileDialog, QWidget as _QWidget, @@ -171,18 +162,19 @@ except Exception: QSizePolicy as _QSizePolicy, QComboBox as _QComboBox, ) - from PyQt5.QtCore import (# type: ignore + from PyQt5.QtCore import ( QEventLoop as _QEventLoop, QUrl as _QUrl, QCoreApplication as _QCoreApplication, Qt as _Qt, ) - from PyQt5.QtNetwork import (# type: ignore + from PyQt5.QtNetwork import ( QNetworkRequest as _QNetworkRequest, QNetworkReply as _QNetworkReply, ) - - + + # ✅ ALLE GLOBALS ZUWEISEN + QT_VERSION = 5 QMessageBox = _QMessageBox QFileDialog = _QFileDialog QEventLoop = _QEventLoop @@ -190,10 +182,8 @@ except Exception: QNetworkRequest = _QNetworkRequest QNetworkReply = _QNetworkReply QCoreApplication = _QCoreApplication - Qt=_Qt + Qt = _Qt QDockWidget = _QDockWidget - - QWidget = _QWidget QGridLayout = _QGridLayout QLabel = _QLabel @@ -206,55 +196,41 @@ except Exception: QToolBar = _QToolBar QActionGroup = _QActionGroup QTabWidget = _QTabWidget - QToolButton=_QToolButton - QSizePolicy=_QSizePolicy - ComboBox=_QComboBox - + QToolButton = _QToolButton + QSizePolicy = _QSizePolicy + QComboBox = _QComboBox + + # ✅ PYQT5 ENUMS YES = QMessageBox.Yes NO = QMessageBox.No CANCEL = QMessageBox.Cancel ICON_QUESTION = QMessageBox.Question - - QT_VERSION = 5 - - # then try next backend - # --------------------------------------------------------- - # Qt5 Enum-Aliase (vereinheitlicht) - # --------------------------------------------------------- - + + # PyQt5 Enum-Aliase ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon ArrowDown = Qt.DownArrow ArrowRight = Qt.RightArrow - # QSizePolicy Enum-Aliase (Qt5) SizePolicyPreferred = QSizePolicy.Preferred SizePolicyMaximum = QSizePolicy.Maximum - # --------------------------------------------------------- - # QDockWidget Feature-Aliase (Qt5) - # --------------------------------------------------------- - DockWidgetMovable = QDockWidget.DockWidgetMovable DockWidgetFloatable = QDockWidget.DockWidgetFloatable DockWidgetClosable = QDockWidget.DockWidgetClosable - # --------------------------------------------------------- - # Dock-Area-Aliase (Qt5) - # --------------------------------------------------------- - DockAreaLeft = Qt.LeftDockWidgetArea DockAreaRight = Qt.RightDockWidgetArea - - + def exec_dialog(dialog: Any) -> Any: return dialog.exec_() + + print(f"✅ qt_wrapper: PyQt5 Fallback geladen (QT_VERSION={QT_VERSION})") -# --------------------------------------------------------- -# Mock-Modus -# --------------------------------------------------------- - +# --------------------------- MOCK-MODUS --------------------------- except Exception: QT_VERSION = 0 + print("⚠️ qt_wrapper: Mock-Modus aktiviert (QT_VERSION=0)") + # Fake Enum für Bit-Operationen class FakeEnum(int): - def __or__(self, other: int) -> "FakeEnum": + def __or__(self, other: Any) -> "FakeEnum": return FakeEnum(int(self) | int(other)) YES = FakeEnum(1) @@ -262,103 +238,72 @@ except Exception: CANCEL = FakeEnum(4) ICON_QUESTION = FakeEnum(8) + # Im Mock-Block von qt_wrapper.py: class _MockQMessageBox: Yes = YES No = NO Cancel = CANCEL Question = ICON_QUESTION + + @classmethod + def question(cls, parent, title, message, buttons, default_button): + """Mock: Gibt immer default_button zurück""" + print(f"🔍 Mock QMessageBox.question: '{title}' → {default_button}") + return default_button QMessageBox = _MockQMessageBox + class _MockQFileDialog: @staticmethod - def getOpenFileName(*args, **kwargs): - return ("", "") - + def getOpenFileName(*args, **kwargs): return ("", "") @staticmethod - def getSaveFileName(*args, **kwargs): - return ("", "") + def getSaveFileName(*args, **kwargs): return ("", "") QFileDialog = _MockQFileDialog class _MockQEventLoop: - def exec(self) -> int: - return 0 - - def quit(self) -> None: - pass + def exec(self) -> int: return 0 + def quit(self) -> None: pass QEventLoop = _MockQEventLoop class _MockQUrl(str): - def isValid(self) -> bool: - return True + def isValid(self) -> bool: return True QUrl = _MockQUrl class _MockQNetworkRequest: - def __init__(self, url: Any): - self.url = url + def __init__(self, url: Any): self.url = url QNetworkRequest = _MockQNetworkRequest class _MockQNetworkReply: - def error(self) -> int: - return 0 - - def errorString(self) -> str: - return "" - - def readAll(self) -> bytes: - return b"" - - def deleteLater(self) -> None: - pass + def error(self) -> int: return 0 + def errorString(self) -> str: return "" + def readAll(self) -> bytes: return b"" + def deleteLater(self) -> None: pass QNetworkReply = _MockQNetworkReply - class _MockWidget: - def __init__(self, *args, **kwargs): - pass - + class _MockWidget: pass class _MockLayout: - def __init__(self, *args, **kwargs): - self._widgets = [] - - def addWidget(self, widget): - self._widgets.append(widget) - - def addLayout(self, layout): - pass - - def addStretch(self, *args, **kwargs): - pass - - def setSpacing(self, *args, **kwargs): - pass - - def setContentsMargins(self, *args, **kwargs): - pass - - - - class _MockLabel: - def __init__(self, text: str = ""): - self._text = text + def __init__(self, *args, **kwargs): self._widgets = [] + def addWidget(self, widget): self._widgets.append(widget) + def addLayout(self, layout): pass + def addStretch(self, *args, **kwargs): pass + def setSpacing(self, *args, **kwargs): pass + def setContentsMargins(self, *args, **kwargs): pass + class _MockLabel: + def __init__(self, text: str = ""): self._text = text class _MockLineEdit: - def __init__(self, *args, **kwargs): - self._text = "" + def __init__(self, *args, **kwargs): self._text = "" + def text(self) -> str: return self._text + def setText(self, value: str) -> None: self._text = value - def text(self) -> str: - return self._text - - def setText(self, value: str) -> None: - self._text = value - - class _MockButton: - def __init__(self, *args, **kwargs): - self.clicked = lambda *a, **k: None + class _MockButton: + def __init__(self, *args, **kwargs): self.clicked = lambda *a, **k: None QWidget = _MockWidget QGridLayout = _MockLayout @@ -367,101 +312,61 @@ except Exception: QGroupBox = _MockWidget QVBoxLayout = _MockLayout QPushButton = _MockButton - - class _MockQCoreApplication: - pass - - QCoreApplication = _MockQCoreApplication + QCoreApplication = object() + class _MockQt: - # ToolButtonStyle ToolButtonTextBesideIcon = 0 - - # ArrowType ArrowDown = 1 ArrowRight = 2 + LeftDockWidgetArea = 1 + RightDockWidgetArea = 2 - Qt=_MockQt + Qt = _MockQt() ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon ArrowDown = Qt.ArrowDown ArrowRight = Qt.ArrowRight + DockAreaLeft = Qt.LeftDockWidgetArea + DockAreaRight = Qt.RightDockWidgetArea class _MockQDockWidget(_MockWidget): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) self._object_name = "" + def setObjectName(self, name: str) -> None: self._object_name = name + def objectName(self) -> str: return self._object_name + def show(self) -> None: pass + def deleteLater(self) -> None: pass - def setObjectName(self, name: str) -> None: - self._object_name = name - - def objectName(self) -> str: - return self._object_name - - def show(self) -> None: - pass - - def deleteLater(self) -> None: - pass QDockWidget = _MockQDockWidget + class _MockAction: def __init__(self, *args, **kwargs): self._checked = False self.triggered = lambda *a, **k: None - - def setToolTip(self, text: str) -> None: - pass - - def setCheckable(self, value: bool) -> None: - pass - - def setChecked(self, value: bool) -> None: - self._checked = value - + def setToolTip(self, text: str) -> None: pass + def setCheckable(self, value: bool) -> None: pass + def setChecked(self, value: bool) -> None: self._checked = value class _MockMenu: - def __init__(self, *args, **kwargs): - self._actions = [] - - def addAction(self, action): - self._actions.append(action) - - def removeAction(self, action): - if action in self._actions: - self._actions.remove(action) - - def clear(self): - self._actions.clear() - - def menuAction(self): - return self - + def __init__(self, *args, **kwargs): self._actions = [] + def addAction(self, action): self._actions.append(action) + def removeAction(self, action): + if action in self._actions: self._actions.remove(action) + def clear(self): self._actions.clear() + def menuAction(self): return self class _MockToolBar: - def __init__(self, *args, **kwargs): - self._actions = [] - - def setObjectName(self, name: str) -> None: - pass - - def addAction(self, action): - self._actions.append(action) - - def removeAction(self, action): - if action in self._actions: - self._actions.remove(action) - - def clear(self): - self._actions.clear() - + def __init__(self, *args, **kwargs): self._actions = [] + def setObjectName(self, name: str) -> None: pass + def addAction(self, action): self._actions.append(action) + def removeAction(self, action): + if action in self._actions: self._actions.remove(action) + def clear(self): self._actions.clear() class _MockActionGroup: - def __init__(self, *args, **kwargs): - self._actions = [] + def __init__(self, *args, **kwargs): self._actions = [] + def setExclusive(self, value: bool) -> None: pass + def addAction(self, action): self._actions.append(action) - def setExclusive(self, value: bool) -> None: - pass - - def addAction(self, action): - self._actions.append(action) QAction = _MockAction QMenu = _MockMenu QToolBar = _MockToolBar @@ -469,112 +374,58 @@ except Exception: class _MockToolButton(_MockWidget): def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) self._checked = False self.toggled = lambda *a, **k: None + def setText(self, text: str) -> None: pass + def setCheckable(self, value: bool) -> None: pass + def setChecked(self, value: bool) -> None: self._checked = value + def setToolButtonStyle(self, *args, **kwargs): pass + def setArrowType(self, *args, **kwargs): pass + def setStyleSheet(self, *args, **kwargs): pass - def setText(self, text: str) -> None: - pass - - def setCheckable(self, value: bool) -> None: - pass - - def setChecked(self, value: bool) -> None: - self._checked = value - - def setToolButtonStyle(self, *args, **kwargs): - pass - - def setArrowType(self, *args, **kwargs): - pass - - def setStyleSheet(self, *args, **kwargs): - pass - - QToolButton=_MockToolButton + QToolButton = _MockToolButton class _MockQSizePolicy: - # horizontale Policies - Fixed = 0 - Minimum = 1 - Maximum = 2 Preferred = 3 - Expanding = 4 - MinimumExpanding = 5 - Ignored = 6 + Maximum = 2 - # vertikale Policies (Qt nutzt dieselben Werte) - def __init__(self, horizontal=None, vertical=None): - self.horizontal = horizontal - self.vertical = vertical - QSizePolicy=_MockQSizePolicy + QSizePolicy = _MockQSizePolicy SizePolicyPreferred = QSizePolicy.Preferred SizePolicyMaximum = QSizePolicy.Maximum DockWidgetMovable = 1 DockWidgetFloatable = 2 DockWidgetClosable = 4 - DockAreaLeft = 1 - DockAreaRight = 2 - def exec_dialog(dialog: Any) -> Any: - return YES class _MockTabWidget: - def __init__(self, *args, **kwargs): - self._tabs = [] + def __init__(self, *args, **kwargs): self._tabs = [] + def addTab(self, widget, title: str): self._tabs.append((widget, title)) - def addTab(self, widget, title: str): - self._tabs.append((widget, title)) QTabWidget = _MockTabWidget - # ------------------------- - # Mock ComboBox Implementation - # ------------------------- - class _MockSignal: - def __init__(self): - self._slots = [] - - def connect(self, cb): - self._slots.append(cb) - - def emit(self, value): - for s in list(self._slots): - try: - s(value) - except Exception: - pass - class _MockComboBox: def __init__(self, parent=None): self._items = [] self._index = -1 - self.currentTextChanged = _MockSignal() - - def addItem(self, text: str) -> None: - self._items.append(text) - - def addItems(self, items): - for it in items: - self.addItem(it) - - def findText(self, text: str) -> int: - try: - return self._items.index(text) - except ValueError: - return -1 - + self.currentTextChanged = type('Signal', (), {'connect': lambda s, cb: None, 'emit': lambda s, v: None})() + def addItem(self, text: str) -> None: self._items.append(text) + def addItems(self, items): [self.addItem(it) for it in items] + def findText(self, text: str) -> int: + return self._items.index(text) if text in self._items else -1 def setCurrentIndex(self, idx: int) -> None: if 0 <= idx < len(self._items): self._index = idx self.currentTextChanged.emit(self.currentText()) - def setCurrentText(self, text: str) -> None: idx = self.findText(text) - if idx >= 0: - self.setCurrentIndex(idx) - + if idx >= 0: self.setCurrentIndex(idx) def currentText(self) -> str: - if 0 <= self._index < len(self._items): - return self._items[self._index] - return "" + return self._items[self._index] if 0 <= self._index < len(self._items) else "" - ComboBox = _MockComboBox + QComboBox = _MockComboBox + + def exec_dialog(dialog: Any) -> Any: + return YES + +# --------------------------- TEST --------------------------- +if __name__ == "__main__": + debug_qt_status() diff --git a/modules/DataGrabber.py b/modules/DataGrabber.py index b077dee..d78ce61 100644 --- a/modules/DataGrabber.py +++ b/modules/DataGrabber.py @@ -1,40 +1,27 @@ -# sn_basis/modules/DataGrabber.py """ DataGrabber module ================== -Leichter Orchestrator, der eine Quelle (Datei, Einzellink, Datenbank) -analysiert, passende Prüfer aufruft und die Ergebnisse an den -:class:`sn_basis.modules.Pruefmanager.Pruefmanager` delegiert. +UI‑freier Orchestrator für die Prüfung und Klassifikation von Datenquellen. -Dieses vereinfachte Modul geht davon aus, dass alle benötigten Prüfer -und der ExcelImporter vorhanden und importierbar sind. Es enthält -keine Fallbacks oder defensive Exception-Handling-Pfade für fehlende -Prüfer-Module — fehlende Komponenten führen zu Import- oder Laufzeitfehlern, -die bewusst nicht unterdrückt werden. +Der DataGrabber: +- klassifiziert die übergebene Quelle (Datei, Dienst, Datenbank, Excel), +- ruft passende Prüfer (Dateipruefer, Linkpruefer, Layerpruefer, Stilpruefer) auf, +- sammelt alle rohen ``pruef_ergebnis``‑Objekte, +- aggregiert diese zu einem zusammenfassenden Ergebnis, +- **löst selbst keinerlei UI‑Interaktion aus**. + +Alle Nutzerinteraktionen (MessageBar, QMessageBox, Logging) erfolgen +ausschließlich über den ``Pruefmanager`` im aufrufenden Kontext (UI / Pipeline). """ from __future__ import annotations -from typing import ( - Optional, - Any, - Mapping, - Iterable, - Dict, - Protocol, - Literal, - Tuple, - List, -) -from pathlib import Path -import sqlite3 +from typing import Any, Dict, List, Mapping, Optional, Tuple, Literal +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis from sn_basis.modules.Pruefmanager import Pruefmanager -from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion -# In dieser vereinfachten Variante werden die Prüfer und der ExcelImporter -# direkt importiert. Fehlende Module führen zu ImportError (gewollt). from sn_basis.modules.Dateipruefer import Dateipruefer from sn_basis.modules.linkpruefer import Linkpruefer from sn_basis.modules.layerpruefer import Layerpruefer @@ -42,383 +29,144 @@ from sn_basis.modules.stilpruefer import Stilpruefer from sn_basis.modules.excel_importer import ExcelImporter -SourceType = Literal["file", "link", "database", "unknown"] +SourceType = Literal["service", "database", "excel", "unknown"] - -class LinklistAdapter(Protocol): - """ - Minimal-Protokoll für Adapter, die Linklisten liefern/normalisieren. - - Implementierende Klassen sollten: - - load() -> Iterable[Mapping[str, Any]] - - normalize(raw_item) -> Mapping[str, Any] - """ - def load(self) -> Iterable[Mapping[str, Any]]: - ... - def normalize(self, raw_item: Mapping[str, Any]) -> Mapping[str, Any]: - ... +SourceDict = Dict[str, List[Mapping[str, Any]]] class DataGrabber: """ - DataGrabber orchestriert das Einlesen einer Quelle und die Übergabe an Prüfer. + Analysiert und prüft Datenquellen für den Fachdatenabruf. - Diese vereinfachte Implementierung erwartet, dass alle Prüferklassen und - der ExcelImporter vorhanden sind. Es gibt keine defensive Logik für - fehlende Komponenten. - - Konstruktor-Parameter - -------------------- - :param pruefmanager: Instanz des Pruefmanagers (verpflichtend). - :param datei_pruefer_cls: Klasse des Dateipruefers (Standard: Dateipruefer). - :param link_pruefer: Instanz des Linkpruefers. - :param layer_pruefer: Instanz des Layerpruefers. - :param stil_pruefer: Instanz des Stilpruefers. + Der DataGrabber ist **UI‑frei**. Er erzeugt ausschließlich rohe + ``pruef_ergebnis``‑Objekte und überlässt deren Verarbeitung + vollständig dem aufrufenden Code. """ def __init__( self, pruefmanager: Pruefmanager, *, - datei_pruefer_cls=Dateipruefer, - link_pruefer: Linkpruefer, - layer_pruefer: Layerpruefer, - stil_pruefer: Stilpruefer, + datei_pruefer_cls: type[Dateipruefer] = Dateipruefer, + link_pruefer: Optional[Linkpruefer] = None, + layer_pruefer: Optional[Layerpruefer] = None, + stil_pruefer: Optional[Stilpruefer] = None, + excel_importer_cls: type[ExcelImporter] = ExcelImporter, ) -> None: - # Pruefmanager ist verpflichtend - self.pruefmanager: Pruefmanager = pruefmanager - - # Dateipruefer-Klasse (wird zur Laufzeit mit einem Pfad instanziert) + self.pruefmanager = pruefmanager self._datei_pruefer_cls = datei_pruefer_cls + self.link_pruefer = link_pruefer + self.layer_pruefer = layer_pruefer + self.stil_pruefer = stil_pruefer + self._excel_importer_cls = excel_importer_cls - # Prüfer-Instanzen (werden direkt verwendet) - self.link_pruefer: Linkpruefer = link_pruefer - self.layer_pruefer: Layerpruefer = layer_pruefer - self.stil_pruefer: Stilpruefer = stil_pruefer + self._source: Optional[str] = None - # Quelle (wird später gesetzt) - self.source: Optional[str] = None - - # ------------------------------------------------------------------ # - # Source Management - # ------------------------------------------------------------------ # + # ------------------------------------------------------------------ + # Öffentliche API + # ------------------------------------------------------------------ def set_source(self, source: str) -> None: + """Setzt die aktuell zu untersuchende Rohquelle.""" + self._source = source + + def analyze_source_type(self, source: str) -> SourceType: """ - Setzt die Quelle für den DataGrabber. + Klassifiziert die Quelle. - Die Quelle ist ein String, der entweder ein lokaler Dateipfad, - ein Einzellink (URL/URI) oder ein Pfad zu einer Datenbank/GeoPackage ist. + Aktuell Platzhalter – liefert ``"unknown"``. """ - self.source = source - - def analyze_source(self, source: str) -> SourceType: - """ - Klassifiziert die angegebene Quelle ausschließlich anhand des Dateipruefers. - - Ablauf - ------ - 1. Instanziere den Dateipruefer mit `pfad=source` und `temporaer_erlaubt=False`. - 2. Rufe `pruefe()` auf und werte das zurückgegebene :class:`pruef_ergebnis` aus. - 3. Bei `ok==True` wird anhand der Dateiendung zwischen "database" (gpkg/sqlite/db) - und "file" unterschieden. - 4. Bei `ok==False` werden typische Aktionen wie "datei_nicht_gefunden" als "link" - interpretiert; bei "falsche_endung" wird anhand der Endung klassifiziert. - """ - dp = self._datei_pruefer_cls(pfad=source, temporaer_erlaubt=False) - pe: pruef_ergebnis = dp.pruefe() - - if getattr(pe, "ok", False): - suffix = Path(source).suffix.lower() - if suffix in (".gpkg", ".sqlite", ".db"): - return "database" - return "file" - - aktion = getattr(pe, "aktion", None) - if aktion in ("datei_nicht_gefunden", "pfad_nicht_gefunden", "kein_dateipfad"): - return "link" - if aktion == "falsche_endung": - lower = source.lower() - for db_ext in (".gpkg", ".sqlite", ".db"): - if lower.endswith(db_ext): - return "database" - for file_ext in (".xlsx", ".xls", ".csv"): - if lower.endswith(file_ext): - return "file" return "unknown" - # ------------------------------------------------------------------ # - # Excel-Verarbeitung - #Es werden alle Werte mit gültigem Link übernommen. Die restliche Struktur - #wird nicht überprüft, da alle Fachplugins unterschiedliche Strukturen haben können - # ------------------------------------------------------------------ # - def process_excel_source( - self, - filepath: str - ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]: + def run(self, source: str) -> Tuple[SourceDict, pruef_ergebnis]: """ - Liest eine Excel-Datei ein und übernimmt ausschließlich die Zeilen, - deren Link durch den Linkpruefer als gültig eingestuft wurde. + Führt die vollständige Quellprüfung aus. - Ablauf - ------ - 1. Die Excel-Datei wird mit dem ``ExcelImporter`` eingelesen. - Erwartet wird eine Liste von Mappings (z.B. dicts), die jeweils - die Linkparameter enthalten. - - 2. Für jede Zeile wird der Wert ``row["Link"]`` extrahiert und durch - ``self.link_pruefer.pruefe(...)`` geprüft. - - 3. Das Prüfergebnis wird durch ``self.pruefmanager.verarbeite(...)`` - geleitet, der UI-Interaktion, Logging und finale Entscheidung übernimmt. - - 4. Nur Zeilen, deren verarbeitete Prüfergebnisse ``ok == True`` liefern, - werden in die Ergebnisliste übernommen. - - 5. Wenn mindestens eine Zeile gültig ist, wird ein Dict der Form:: - - {"rows": [row1, row2, ...]} - - zurückgegeben. - Wenn keine Zeile gültig ist, wird ``None`` zurückgegeben. - - Parameter - --------- - filepath: - Pfad zur Excel-Datei, die eingelesen werden soll. - - Returns - ------- - Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis] - - ``data``: ``{"rows": [...]} `` wenn gültige Zeilen existieren, - sonst ``None``. - - ``pruef_ergebnis``: ein zusammenfassendes Prüfergebnis, das - den Lesevorgang beschreibt (nicht die Einzelprüfungen). - - Hinweise - -------- - - Diese Methode führt **keine Normalisierung** durch. - - Die Verantwortung für die Struktur der Excel-Zeilen liegt beim Fachplugin. - - Der Linkpruefer prüft ausschließlich den Wert ``row["Link"]``. + Diese Methode ist **UI‑frei**. Sie gibt rohe Ergebnisse zurück, + die vom Aufrufer über den ``Pruefmanager`` verarbeitet werden. """ + self.set_source(source) + source_type = self.analyze_source_type(source) - # 1) Excel einlesen - importer = ExcelImporter(filepath=filepath, pruefmanager=self.pruefmanager) - rows = importer.import_xlsx() # erwartet: List[Mapping[str, Any]] + source_dict: SourceDict = {} + partial_results: List[pruef_ergebnis] = [] - valid_rows: List[Mapping[str, Any]] = [] - - # 2) Jede Zeile einzeln prüfen - for row in rows: - raw_link = row.get("Link") - - # 2a) Fachliche Prüfung - pe = self.link_pruefer.pruefe(raw_link) - - # 2b) Verarbeitung durch den Pruefmanager - processed = self.pruefmanager.verarbeite(pe) - - # 2c) Nur gültige Zeilen übernehmen - if getattr(processed, "ok", False): - valid_rows.append(row) - - # 3) Zusammenfassendes Prüfergebnis erzeugen - if valid_rows: - pe_ok = pruef_ergebnis( - ok=True, - meldung=f"{len(valid_rows)} gültige Zeilen aus Excel gelesen", - aktion="ok", - kontext=filepath, + if source_type == "excel": + source_dict, partial_results = self._process_excel_source(source) + elif source_type == "database": + source_dict, partial_results = self._process_database_source(source) + elif source_type == "service": + source_dict, partial_results = self._process_service_source(source) + else: + partial_results.append( + pruef_ergebnis( + ok=False, + meldung="Quelle konnte nicht klassifiziert werden", + aktion="kein_dateipfad", + kontext={"source": source}, + ) ) - processed_summary = self.pruefmanager.verarbeite(pe_ok) - return {"rows": valid_rows}, processed_summary - # Keine gültigen Zeilen - pe_fail = pruef_ergebnis( - ok=False, - meldung="Keine gültigen Links in der Excel-Datei gefunden", - aktion="read_error", - kontext=filepath, - ) - processed_summary = self.pruefmanager.verarbeite(pe_fail) - return None, processed_summary + summary = self._aggregate_results(source, source_dict, partial_results) + return source_dict, summary + # ------------------------------------------------------------------ + # Excel‑Quellen + # ------------------------------------------------------------------ + def _process_excel_source( + self, filepath: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results - # ------------------------------------------------------------------ # - # Einzellink-Verarbeitung - # ------------------------------------------------------------------ # - def process_single_link( + # ------------------------------------------------------------------ + # Datenbank‑Quellen + # ------------------------------------------------------------------ + def _process_database_source( + self, db_path: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results + + # ------------------------------------------------------------------ + # Dienst‑Quellen + # ------------------------------------------------------------------ + def _process_service_source( + self, link: str + ) -> Tuple[SourceDict, List[pruef_ergebnis]]: + source_dict: SourceDict = {} + results: List[pruef_ergebnis] = [] + return source_dict, results + + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ + def _aggregate_results( self, - link: Mapping[str, Any] - ) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], Any]: + source: str, + source_dict: SourceDict, + partial_results: List[pruef_ergebnis], + ) -> pruef_ergebnis: """ - Prüft einen einzelnen Link anhand der im Link-Dict enthaltenen Link-URL. + Aggregiert Einzelprüfungen zu einem Gesamt‑``pruef_ergebnis``. - Ablauf - ------ - 1. Erwartet wird ein Mapping (z.B. dict), das die Linkparameter enthält. - Mindestens der Schlüssel ``"Link"`` muss vorhanden sein. - - 2. Der eigentliche Link (z.B. URL) wird aus ``link["Link"]`` extrahiert - und an ``self.link_pruefer.pruefe(...)`` übergeben. - - 3. Das Prüfergebnis wird anschließend durch ``self.pruefmanager.verarbeite(...)`` - geleitet, der UIInteraktion, Logging und finale Entscheidung übernimmt. - - 4. Wenn das verarbeitete Prüfergebnis **nicht OK** ist, wird - ``(None, pruef_ergebnis)`` zurückgegeben. - - 5. Wenn das Prüfergebnis **OK** ist, wird das unveränderte LinkDict - in der Struktur ``{"rows": [link]}`` zurückgegeben. - - Parameter - --------- - link: - Ein Mapping mit den Linkparametern (z.B. id, Thema, Gruppe, Link, - Anbieter, Stildatei). Diese Methode verändert das Mapping nicht. - - Returns - ------- - Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis] - - ``data``: ``{"rows": [link]}`` wenn gültig, sonst ``None`` - - ``pruef_ergebnis``: das vom Pruefmanager verarbeitete Ergebnis - - Hinweise - -------- - - Diese Methode führt **keine Normalisierung** durch. - - Die Verantwortung für die Struktur des Link-Dicts liegt beim Fachplugin. - - Der Linkpruefer prüft ausschließlich den Wert ``link["Link"]``. + **Keine UI‑Interaktion.** """ + if source_dict: + return pruef_ergebnis( + ok=True, + meldung="Quelle erfolgreich geprüft", + aktion="ok", + kontext={ + "source": source, + "valid_entries": sum(len(v) for v in source_dict.values()), + }, + ) - # 1) Link extrahieren (Fachplugin garantiert, dass "Link" existiert) - raw_link = link.get("Link") - - # 2) Fachliche Prüfung durch den Linkpruefer - pruef_ergebnis = self.link_pruefer.pruefe(raw_link) - - # 3) Verarbeitung durch den Pruefmanager - processed = self.pruefmanager.verarbeite(pruef_ergebnis) - - # 4) Wenn Prüfung nicht OK → keine Daten zurückgeben - if not getattr(processed, "ok", False): - return None, processed - - # 5) Prüfung OK → unverändertes Link-Dict zurückgeben - data = {"rows": [link]} - - return data, processed - - - - - # ------------------------------------------------------------------ # - # Datenbank-Verarbeitung - # ------------------------------------------------------------------ # - #def process_database_table(self, db_path: str, table: Optional[str] = None) -> Tuple[Optional[Dict[str, List[Mapping[str, Any]]]], pruef_ergebnis]: - #noch nicht implementiert - """ - Liest eine Tabelle aus einer SQLite/GeoPackage-Datei. - - Verhalten - --------- - 1. Validiert die Datei mit dem Dateipruefer. - 2. Falls OK, versucht es, die angegebene Tabelle zu lesen; falls keine Tabelle - angegeben ist, wird nach einer typischen Metadaten-Tabelle 'layer_metadaten' - gesucht und diese gelesen. - 3. Gibt die Zeilen als Liste von Dicts zurück. - """ - dp = self._datei_pruefer_cls(pfad=db_path, temporaer_erlaubt=False) - pe = dp.pruefe() - processed = self.pruefmanager.verarbeite(pe) - if not getattr(processed, "ok", False): - return None, processed - - conn = sqlite3.connect(db_path) - cur = conn.cursor() - if table: - cur.execute(f"SELECT * FROM {table}") - cols = [d[0] for d in cur.description] - rows = [dict(zip(cols, r)) for r in cur.fetchall()] - else: - cur.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='layer_metadaten'") - if cur.fetchone(): - cur.execute("SELECT * FROM layer_metadaten") - cols = [d[0] for d in cur.description] - rows = [dict(zip(cols, r)) for r in cur.fetchall()] - else: - rows = [] - conn.close() - - pe_ok = pruef_ergebnis(ok=True, meldung="DB gelesen", aktion="ok", kontext=db_path) - processed_ok = self.pruefmanager.verarbeite(pe_ok) - return {"rows": rows}, processed_ok - - # ------------------------------------------------------------------ # - # Hauptlauf / Dispatch - # ------------------------------------------------------------------ # - def run(self) -> Dict[str, Any]: - """ - Hauptmethode des DataGrabbers. - - Ablauf - ------ - 1. Prüft, ob eine Quelle gesetzt ist. - 2. Klassifiziert die Quelle via :meth:`analyze_source`. - 3. Dispatch: - - file (.xlsx/.xls) -> :meth:`process_excel_source` - - link -> :meth:`process_single_link` - - database -> :meth:`process_database_table` - - unknown -> Fehler - 4. Aggregiert geladene Einträge in einem Ergebnis-Dict und gibt dieses zurück. - - Rückgabeformat - ------------- - Ein Dict mit den Schlüsseln: - - 'geladen' : Liste der geladenen Themen/Namen - - 'fehler' : Mapping Thema -> Fehlermeldung - - 'ausserhalb': Liste der als ausserhalb klassifizierten Themen - - 'relevant' : Liste der relevanten Themen - - 'details' : zusätzliche Detailinformationen (z. B. Anzahl Zeilen) - """ - result: Dict[str, Any] = {"geladen": [], "fehler": {}, "ausserhalb": [], "relevant": [], "details": {}} - - if not self.source: - pe = pruef_ergebnis(ok=False, meldung="Keine Quelle gesetzt", aktion="kein_dateipfad", kontext=None) - processed = self.pruefmanager.verarbeite(pe) - result["fehler"]["source"] = getattr(processed, "meldung", "Keine Quelle") - return result - - src_type = self.analyze_source(self.source) - - if src_type == "file": - suffix = Path(self.source).suffix.lower() - if suffix in (".xlsx", ".xls"): - data_dict, pe = self.process_excel_source(self.source) - else: - pe = pruef_ergebnis(ok=False, meldung="Dateityp nicht unterstützt", aktion="falsche_endung", kontext=self.source) - pe = self.pruefmanager.verarbeite(pe) - data_dict = None - - elif src_type == "link": - data_dict, pe = self.process_single_link(self.source) - - #elif src_type == "database": - #data_dict, pe = self.process_database_table(self.source, table=None) - - else: - pe = pruef_ergebnis(ok=False, meldung="Quelle unbekannt", aktion="kein_dateipfad", kontext=self.source) - pe = self.pruefmanager.verarbeite(pe) - data_dict = None - - # Falls Daten vorhanden: fülle result['geladen'] und details - if data_dict and "rows" in data_dict: - rows = data_dict["rows"] - for r in rows: - thema = r.get("Inhalt") or r.get("ident") or r.get("Link") or "unbenannt" - result["geladen"].append(thema) - result["details"]["source_rows"] = len(rows) - - # Falls das letzte pruef_ergebnis einen Fehler enthält, übernehme es - if not getattr(pe, "ok", False): - result["fehler"]["source"] = getattr(pe, "meldung", "Fehler bei Quelle") - - return result + return pruef_ergebnis( + ok=False, + meldung="Keine gültigen Einträge in der Quelle gefunden", + aktion="read_error", + kontext={"source": source}, + ) diff --git a/modules/Dateipruefer.py b/modules/Dateipruefer.py index 31e5c25..1ad1a44 100644 --- a/modules/Dateipruefer.py +++ b/modules/Dateipruefer.py @@ -1,98 +1,175 @@ """ -sn_basis/modules/Dateipruefer.py – Prüfung von Dateieingaben für das Plugin. -Verwendet sys_wrapper und gibt pruef_ergebnis an den Pruefmanager zurück. +sn_basis/modules/Dateipruefer.py + +Erweiterter Dateiprüfer für Verfahrens-DB-Workflows mit vollständiger Unterstützung +der Anforderungen 1-2.e (leerer Pfad, fehlende Datei, bestehende Datei). """ from pathlib import Path +from typing import Optional -from sn_basis.functions.sys_wrapper import ( - join_path, - file_exists, -) - -from sn_basis.modules.Pruefmanager import pruef_ergebnis +from sn_basis.functions.sys_wrapper import join_path, file_exists +from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion class Dateipruefer: """ - Prüft Dateieingaben und liefert ein pruef_ergebnis zurück. - Die eigentliche Nutzerinteraktion übernimmt der Pruefmanager. + Prüft Dateieingaben für Verfahrens-DB-Workflows und liefert :class:`pruef_ergebnis`. + + **Funktionsweise (deine Anforderungen 1-2.e):** + + +---------------------+------------------------------------------+---------------+ + | **Fall** | **Ergebnis** | **ok** | + +=====================+==========================================+===============+ + | 1. Leerer Pfad | ``temporaer_erlaubt`` | False | + +---------------------+------------------------------------------+---------------+ + | 2.a Leerer Pfad | Pruefmanager fragt → ``temporaer_erzeugen`` | True | + +---------------------+------------------------------------------+---------------+ + | 2.b Datei existiert | ``ok`` | True | + +---------------------+------------------------------------------+---------------+ + | 2.c Ungültiger Pfad | ``datei_nicht_gefunden`` | False | + +---------------------+------------------------------------------+---------------+ + | **2.d Datei fehlt** | **``datei_wird_erzeugt``** | **True** | + +---------------------+------------------------------------------+---------------+ + | **2.e Datei da** | **``datei_existiert``** | **False** | + +---------------------+------------------------------------------+---------------+ + + Der Dateiprüfer führt **keine UI-Interaktion** durch. + Entscheidungen werden ausschließlich vom :class:`Pruefmanager` getroffen. """ def __init__( self, - pfad: str, + pfad: Optional[str], basis_pfad: str = "", leereingabe_erlaubt: bool = False, - standarddatei: str | None = None, + standarddatei: Optional[str] = None, temporaer_erlaubt: bool = False, - ): + *, + verfahrens_db_modus: bool = True, # 🆕 Verfahrens-DB-spezifische Logik + ) -> None: + """ + Parameters + ---------- + pfad : Optional[str] + Vom UI gelieferter Dateipfad (kann leer oder Whitespace sein). + basis_pfad : str, optional + Basisverzeichnis für relative Pfade (default: ""). + leereingabe_erlaubt : bool, optional + Ob leere Eingabe grundsätzlich erlaubt ist (default: False). + standarddatei : Optional[str], optional + Optionaler Standardpfad (default: None). + temporaer_erlaubt : bool, optional + Ob bei leerer Eingabe temporäre Layer erlaubt sind (default: False). + verfahrens_db_modus : bool, optional + Aktiviert Verfahrens-DB-spezifische Logik (2.d, 2.e) (default: True). + """ self.pfad = pfad self.basis_pfad = basis_pfad self.leereingabe_erlaubt = leereingabe_erlaubt self.standarddatei = standarddatei self.temporaer_erlaubt = temporaer_erlaubt + self.verfahrens_db_modus = verfahrens_db_modus - # --------------------------------------------------------- - # Hilfsfunktion - # --------------------------------------------------------- - + # ------------------------------------------------------------------ + # Hilfsfunktionen + # ------------------------------------------------------------------ def _pfad(self, relativer_pfad: str) -> Path: - """ - Erzeugt einen OS-unabhängigen Pfad relativ zum Basisverzeichnis. - """ + """Erzeugt OS-unabhängigen Pfad relativ zum Basisverzeichnis.""" return join_path(self.basis_pfad, relativer_pfad) - # --------------------------------------------------------- - # Hauptfunktion - # --------------------------------------------------------- + def _ist_leer(self) -> bool: + """ + Prüft robust, ob Eingabe als „leer" zu behandeln ist. + Returns + ------- + bool + True bei None, leerem String oder reinem Whitespace. + """ + if self.pfad is None: + return True + if not isinstance(self.pfad, str): + return True + return not self.pfad.strip() + + def _ist_gueltiger_gpkg_pfad(self, pfad: Path) -> bool: + """ + Prüft, ob Pfad für GPKG geeignet ist (Endung + Schreibrechte). + + Returns + ------- + bool + True wenn `.gpkg`-Endung und Verzeichnis beschreibbar. + """ + if not str(pfad).lower().endswith('.gpkg'): + return False + + # Verzeichnis muss beschreibbar sein + return pfad.parent.exists() and pfad.parent.is_dir() + + # ------------------------------------------------------------------ + # Hauptlogik: deine Anforderungen 1-2.e + # ------------------------------------------------------------------ def pruefe(self) -> pruef_ergebnis: """ - Prüft eine Dateieingabe und liefert ein pruef_ergebnis zurück. - Der Pruefmanager entscheidet später, wie der Nutzer gefragt wird. - """ + 🆕 Prüft Dateieingabe gemäß Anforderungen 1-2.e. - # ----------------------------------------------------- - # 1. Fall: Eingabe ist leer - # ----------------------------------------------------- - if not self.pfad: + **Workflow:** + 1. **Leere Eingabe** → ``temporaer_erlaubt`` (Pruefmanager fragt) + 2. **Pfad prüfen**: + - **Ungültig** → 2.c ``datei_nicht_gefunden`` + - **Gültig, fehlt** → **2.d** ``datei_wird_erzeugt`` (ok=True!) + - **Gültig, existiert** → **2.e** ``datei_existiert`` (Pruefmanager fragt) + 3. **Datei OK** → 2.b ``ok`` + + Returns + ------- + pruef_ergebnis + Mit korrekter Aktion für jeden Fall. + """ + # 1. 🎯 ANFORDERUNG 1: Leere Eingabe + if self._ist_leer(): return self._handle_leere_eingabe() - # ----------------------------------------------------- - # 2. Fall: Eingabe ist nicht leer → Datei prüfen - # ----------------------------------------------------- - pfad = self._pfad(self.pfad) + # 2. Pfad normalisieren + pfad = self._pfad(self.pfad.strip()) - if not file_exists(pfad): + # 🆕 2.c: Ungültiger GPKG-Pfad? + if not self.verfahrens_db_modus or not self._ist_gueltiger_gpkg_pfad(pfad): return pruef_ergebnis( ok=False, - meldung=f"Die Datei '{self.pfad}' wurde nicht gefunden.", + meldung=f"Der Pfad '{self.pfad}' ist kein gültiger GPKG-Pfad.", aktion="datei_nicht_gefunden", kontext=pfad, ) - # ----------------------------------------------------- - # 3. Datei existiert → Erfolg - # ----------------------------------------------------- + # 🆕 2.d: Gültiger Pfad, Datei fehlt → DIREKT WEITER (ok=True!) + if not file_exists(pfad): + return pruef_ergebnis( + ok=True, # 🎯 WICHTIG: Pipeline fortsetzen! + meldung=f"Datei '{self.pfad}' wird erzeugt.", + aktion="datei_wird_erzeugt", + kontext=pfad, + ) + + # 🆕 2.e: Datei existiert → Pruefmanager fragt Überschreiben/etc. return pruef_ergebnis( - ok=True, - meldung="Datei gefunden.", - aktion="ok", + ok=False, # 🎯 Pruefmanager soll 4-Optionen-Dialog zeigen + meldung=f"Datei '{self.pfad}' existiert bereits.", + aktion="datei_existiert", kontext=pfad, ) - # --------------------------------------------------------- - # Behandlung leerer Eingaben - # --------------------------------------------------------- + # 2.b: Wird nicht erreicht (durch 2.e abgefangen) + # ------------------------------------------------------------------ + # Leere Eingabe (ANFORDERUNG 1, 2.a) + # ------------------------------------------------------------------ def _handle_leere_eingabe(self) -> pruef_ergebnis: """ - Liefert ein pruef_ergebnis für den Fall, dass das Dateifeld leer ist. - Der Pruefmanager fragt später den Nutzer. + Behandelt leere Eingaben (Priorität: leereingabe → Standard → temporär → Fehler). """ - - # 1. Leereingabe erlaubt → Nutzer fragen, ob das beabsichtigt war if self.leereingabe_erlaubt: return pruef_ergebnis( ok=False, @@ -101,19 +178,17 @@ class Dateipruefer: kontext=None, ) - # 2. Standarddatei verfügbar → Nutzer fragen, ob sie verwendet werden soll if self.standarddatei: return pruef_ergebnis( ok=False, meldung=( - f"Es wurde keine Datei angegeben. " + "Es wurde keine Datei angegeben. " f"Soll die Standarddatei '{self.standarddatei}' verwendet werden?" ), aktion="standarddatei_vorschlagen", kontext=self._pfad(self.standarddatei), ) - # 3. Temporäre Datei erlaubt → Nutzer fragen, ob temporär gearbeitet werden soll if self.temporaer_erlaubt: return pruef_ergebnis( ok=False, @@ -125,7 +200,6 @@ class Dateipruefer: kontext=None, ) - # 4. Leereingabe nicht erlaubt → Fehler return pruef_ergebnis( ok=False, meldung="Es wurde keine Datei angegeben.", diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index 12d8669..3ea40ec 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -1,66 +1,45 @@ +""" +sn_basis/modules/Pruefmanager.py +""" + from __future__ import annotations from typing import Optional, Any -from sn_basis.functions import ( - ask_yes_no, - info, - warning, - error, - set_layer_visible, -) - +from sn_basis.functions import ask_yes_no, info, warning, error from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion - +print("DEBUG: Pruefmanager DATEI GELADEN:", __file__) class Pruefmanager: - """ - Zentrale Verarbeitung von pruef_ergebnis-Objekten. - - Erwartete öffentliche API (verwendet von Core-Komponenten wie DataGrabber): - - report_error(thema, meldung, *, aktion: Optional[PruefAktion]=None, kontext=None) -> None - - request_decision(pruef_res) -> str - - report_summary(summary: dict) -> None - - verarbeite(ergebnis: pruef_ergebnis) -> pruef_ergebnis - """ - - def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None): + def __init__(self, ui_modus: str = "qgis", parent: Optional[Any] = None) -> None: self.ui_modus = ui_modus self.parent = parent - # --------------------------------------------------------------------- - # Basis-API: Meldungen / Zusammenfassungen - # --------------------------------------------------------------------- - def report_error(self, thema: str, meldung: str, *, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None) -> None: - """ - Einheitliche Meldung für Fehler/Warnungen aus dem Core. - Keine Rückgabe; dient als zentraler Hook für Logging/UI. - """ + # ------------------------------------------------------------------ + # Meldungen / Zusammenfassungen + # ------------------------------------------------------------------ + def report_error( + self, + thema: str, + meldung: str, + *, + aktion: Optional[PruefAktion] = None, + kontext: Optional[Any] = None, + ) -> None: critical_actions = { - "netzwerkfehler", - "pruefe_exception", - "save_exception", - "layer_create_failed", - "read_error", - "open_error", + "netzwerkfehler", "pruefe_exception", "save_exception", + "layer_create_failed", "read_error", "open_error", } warn_actions = { - "datei_nicht_gefunden", - "pfad_nicht_gefunden", - "url_nicht_erreichbar", - "falsche_endung", - "kein_header", - "kein_arbeitsblatt", + "datei_nicht_gefunden", "pfad_nicht_gefunden", "url_nicht_erreichbar", + "falsche_endung", "kein_header", "kein_arbeitsblatt", } if aktion in critical_actions: error(thema, meldung) return - if aktion in warn_actions: warning(thema, meldung) return - - # Default: informative Warnung warning(thema, meldung) def report_summary(self, summary: dict) -> None: @@ -75,202 +54,159 @@ class Pruefmanager: f"Dienste ausserhalb: {len(ausserhalb)}\n" f"Fehler: {len(fehler)}" ) - info("DataGrabber Zusammenfassung", message) - # --------------------------------------------------------------------- - # Entscheidungs-API - # --------------------------------------------------------------------- - def request_decision(self, pruef_res: Any) -> str: - """ - Synchronously request a decision from the user (or return a default in headless mode). + # ------------------------------------------------------------------ + # VERFAHRENS-DB-spezifische Entscheidungen + # ------------------------------------------------------------------ + def _handle_datei_existiert(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + if self.ui_modus != "qgis": + return ergebnis - Returns one of: - - "abort" - - "continue" - - "temporaer_erzeugen" - - "ignore" - """ - aktion = getattr(pruef_res, "aktion", None) - meldung = getattr(pruef_res, "meldung", str(pruef_res)) + pfad = ergebnis.kontext + pfad_str = str(pfad) if pfad else "unbekannt" + titel = "Verfahrens-DB existiert bereits" + meldung = ( + f"Die Datei '{pfad_str}' existiert bereits.\n\n" + "Was soll geschehen?\n\n" + "• **Überschreiben**: Bestehende Layer ersetzen\n" + "• **Anhängen**: Neue Layer hinzufügen\n" + "• **Überspringen**: Nur temporäre Layer erzeugen" + ) + + # Vereinfacht: Erst Überschreiben? → Dann Anhängen? → Überspringen + if ask_yes_no( + titel, + f"{meldung}\n\n**Überschreiben** (alle Layer ersetzen)?", + default=False, + parent=self.parent + ): + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_ueberschreiben", + kontext=ergebnis.kontext, + ) + + if ask_yes_no( + titel, + f"{meldung}\n\n**Anhängen** (neue Layer hinzufügen)?", + default=False, + parent=self.parent + ): + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_anhaengen", + kontext=ergebnis.kontext, + ) + + if ask_yes_no( + titel, + f"{meldung}\n\n**Überspringen** (nur temporäre Layer)?", + default=True, + parent=self.parent + ): + return pruef_ergebnis( + ok=True, + aktion="datei_existiert_ueberspringen", + kontext=ergebnis.kontext, + ) + + return ergebnis + + # ------------------------------------------------------------------ + # Basis-Entscheidungen (KORREKT: → pruef_ergebnis) + # ------------------------------------------------------------------ + def _handle_basic_decision(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """Basis-Entscheidung für einfache Ja/Nein-Fragen.""" + print(f"DEBUG _handle_basic_decision: aktion='{ergebnis.aktion}', ui_modus='{self.ui_modus}'") + + if self.ui_modus != "qgis": + print("DEBUG: Nicht QGIS → ergebnis unverändert") + return ergebnis + + title_map = { + "leereingabe_erlaubt": "Ohne Eingabe fortfahren", + "standarddatei_vorschlagen": "Standarddatei verwenden", + "temporaer_erlaubt": "Temporäre Layer erzeugen", + "layer_unsichtbar": "Layer einblenden", + } + + title = title_map.get(ergebnis.aktion, "Entscheidung erforderlich") + meldung = ergebnis.meldung or "" + + try: + print(f"DEBUG ask_yes_no: title='{title}', meldung='{meldung[:50]}...'") + yes = ask_yes_no(title, meldung, default=False, parent=self.parent) + print(f"DEBUG ask_yes_no: yes={yes}") + except Exception as e: + print(f"DEBUG ask_yes_no Exception: {e}") + return ergebnis + + if not yes: + print("DEBUG: Nutzer sagte Nein → ok=False") + return ergebnis + + # Nutzer sagte Ja + if ergebnis.aktion == "temporaer_erlaubt": + print("DEBUG: temporaer_erlaubt bestätigt → ok=True") + return pruef_ergebnis( + ok=True, + aktion="temporaer_erlaubt", + kontext=ergebnis.kontext + ) + + print("DEBUG: Andere Aktion bestätigt → ok=True, aktion='ok'") + return pruef_ergebnis( + ok=True, + aktion="ok", + kontext=ergebnis.kontext + ) + + # ------------------------------------------------------------------ + # Hauptlogik: verarbeite() (KORRIGIERT!) + # ------------------------------------------------------------------ + def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + print("🔥 verarbeite() START") + print("DEBUG Pruefmanager:", ergebnis.ok, ergebnis.aktion) + print("DEBUG ergebnis.aktion TYPE:", type(ergebnis.aktion), repr(ergebnis.aktion)) + + # 1. Erfolg → direkt weiter + print("🔍 Schritt 1: Prüfe ergebnis.ok =", ergebnis.ok) + if ergebnis.ok: + print("✅ Schritt 1: ok=True → return") + return ergebnis + + # 2. VERFAHRENS-DB: Bestehende Datei + print("🔍 Schritt 2: Prüfe datei_existiert =", ergebnis.aktion == "datei_existiert") + if ergebnis.aktion == "datei_existiert": + print("✅ Schritt 2: _handle_datei_existiert") + return self._handle_datei_existiert(ergebnis) + + # 3. Basis interaktive Aktionen + print("🔍 Schritt 3: Definiere interactive_actions") interactive_actions = { "leereingabe_erlaubt", - "standarddatei_vorschlagen", + "standarddatei_vorschlagen", "temporaer_erlaubt", "layer_unsichtbar", } + print("DEBUG interactive_actions:", repr(interactive_actions)) + print("DEBUG ergebnis.aktion in interactive_actions?", ergebnis.aktion in interactive_actions) + + if ergebnis.aktion in interactive_actions: + print("✅ Schritt 3: Interaktive Aktion → _handle_basic_decision") + decision = self._handle_basic_decision(ergebnis) + print(f"DEBUG: _handle_basic_decision Ergebnis: ok={decision.ok}, aktion='{decision.aktion}'") + return decision - if aktion in interactive_actions: - if self.ui_modus == "qgis": - title_map = { - "leereingabe_erlaubt": "Ohne Eingabe fortfahren", - "standarddatei_vorschlagen": "Standarddatei verwenden", - "temporaer_erlaubt": "Temporäre Datei erzeugen", - "layer_unsichtbar": "Layer einblenden", - } - title = title_map.get(aktion, "Entscheidung erforderlich") - try: - yes = ask_yes_no(title, meldung, default=False, parent=self.parent) - except Exception: - return "abort" - if yes: - if aktion == "temporaer_erlaubt": - return "temporaer_erzeugen" - return "continue" - return "abort" - - if self.ui_modus == "headless": - return "abort" - - informational_actions = { - "leer", - "datei_nicht_gefunden", - "pfad_nicht_gefunden", - "url_nicht_erreichbar", - "netzwerkfehler", - "falscher_geotyp", - "layer_leer", - "falscher_layertyp", - "falsches_crs", - "felder_fehlen", - "datenquelle_unerwartet", - "layer_nicht_editierbar", - "kein_header", - "kein_arbeitsblatt", - "read_error", - "open_error", - "pflichtfelder_fehlen", - } - if aktion in informational_actions: - return "abort" - - return "abort" - - # --------------------------------------------------------------------- - # Höhere Abstraktion: verarbeite - # --------------------------------------------------------------------- - def verarbeite(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: - """ - Verarbeitet ein pruef_ergebnis-Objekt und führt ggf. Nutzerinteraktion durch. - Liefert ein ggf. modifiziertes pruef_ergebnis zurück. - """ - if ergebnis.ok: - return ergebnis - - aktion = ergebnis.aktion - kontext = ergebnis.kontext - meldung = ergebnis.meldung - - # Zentrale Meldung - self.report_error(aktion or "pruefung", meldung or "", aktion=aktion, kontext=kontext) - - # Interaktive Entscheidungen - if aktion in ("leereingabe_erlaubt", "standarddatei_vorschlagen", "temporaer_erlaubt", "layer_unsichtbar"): - decision = self.request_decision(ergebnis) - if decision == "temporaer_erzeugen": - return pruef_ergebnis(ok=True, meldung="Temporäre Datei soll erzeugt werden.", aktion="temporaer_erzeugen", kontext=None) - if decision == "continue": - return pruef_ergebnis(ok=True, meldung="Fortgefahren.", aktion="ok", kontext=kontext) - return ergebnis # abort / unverändert - - # Spezielle Excel/Importer-Fälle: klare Meldungen, keine interaktive Entscheidung - if aktion == "kein_header": - warning("Excel-Import", meldung or "") - return ergebnis - - if aktion == "kein_arbeitsblatt": - warning("Excel-Import", meldung or "") - return ergebnis - - if aktion in ("read_error", "open_error"): - error("Excel-Import", meldung or "") - return ergebnis - - if aktion == "datei_nicht_gefunden": - warning("Datei nicht gefunden", meldung or "") - return ergebnis - - # Spezieller Fall: layer_unsichtbar (falls nicht interaktiv behandelt) - if aktion == "layer_unsichtbar": - if kontext is not None: - try: - set_layer_visible(kontext, True) - return pruef_ergebnis(ok=True, meldung="Layer wurde eingeblendet.", aktion="ok", kontext=kontext) - except Exception: - return ergebnis - return ergebnis - - # Standard: keine Änderung + # 4. Fehler behandeln + print("❌ Schritt 4: FEHLER BEHANDELN") + self.report_error( + thema=ergebnis.aktion or "pruefung", + meldung=ergebnis.meldung or "", + aktion=ergebnis.aktion, + kontext=ergebnis.kontext, + ) + print("🔥 verarbeite() ENDE mit ok=False") return ergebnis - def ask_overwrite_append_cancel(self, layer_name: str, default: str = "overwrite") -> str: - """ - Zeigt dem Nutzer eine Auswahl für einen bereits existierenden Layer an. - - Rückgabe - ------- - str - Einer der Werte: "overwrite", "append", "cancel". - - Verhalten - -------- - - Verwendet bevorzugt die UI-Wrapper-Funktion `qt_wrapper` / `qgisui_wrapper`, - falls vorhanden (z. B. ein QMessageBox-Dialog mit drei Buttons). - - Im Mock- oder Headless-Modus (kein Qt/QGIS verfügbar) wird der übergebene - `default`-Wert zurückgegeben. - - Alle Nutzerinteraktionen laufen über diese zentrale Methode, damit das - Plugin an einer Stelle gesteuert und ggf. getested werden kann. - - Parameter - --------- - layer_name: - Anzeigename des Layers, der bereits existiert (wird im Dialog angezeigt). - default: - Rückgabewert im Headless/Mock-Modus oder wenn der Dialog nicht verfügbar ist. - Gültige Werte: "overwrite", "append", "cancel". Standard: "overwrite". - """ - # Validierung des Defaults - if default not in ("overwrite", "append", "cancel"): - default = "overwrite" - - # Versuche, eine UI-Wrapper-Funktion zu verwenden, falls vorhanden - try: - # qgisui_wrapper kann eine spezialisierte Dialogfunktion bereitstellen - from sn_basis.functions import qgisui_wrapper as qgisui - ask_fn = getattr(qgisui, "ask_overwrite_append_cancel", None) - if callable(ask_fn): - # Die Wrapper-Funktion soll genau die drei Strings zurückgeben - choice = ask_fn(layer_name) - if choice in ("overwrite", "append", "cancel"): - return choice - except Exception: - # Falls Import/Wrapper fehlschlägt, weiter zum Qt-Fallback - pass - - # Fallback: direkte Qt-Dialoge über qt_wrapper (wenn verfügbar) - try: - from sn_basis.functions import qt_wrapper as qt - QMessageBox = getattr(qt, "QMessageBox", None) - if QMessageBox is not None: - # Erzeuge und konfiguriere Dialog - msg = QMessageBox() - msg.setWindowTitle("Layer bereits vorhanden") - msg.setText(f"Der Layer '{layer_name}' existiert bereits. Was möchten Sie tun?") - overwrite_btn = msg.addButton("Überschreiben", QMessageBox.AcceptRole) - append_btn = msg.addButton("Anhängen", QMessageBox.AcceptRole) - cancel_btn = msg.addButton("Abbrechen", QMessageBox.RejectRole) - msg.setDefaultButton(overwrite_btn) - # Blockierend anzeigen - msg.exec_() - clicked = msg.clickedButton() - if clicked == overwrite_btn: - return "overwrite" - if clicked == append_btn: - return "append" - return "cancel" - except Exception: - # Qt nicht verfügbar oder Fehler beim Dialogaufbau - pass - - # Headless / Mock: gib Default zurück - return default diff --git a/modules/pruef_ergebnis.py b/modules/pruef_ergebnis.py index af0054d..78eb423 100644 --- a/modules/pruef_ergebnis.py +++ b/modules/pruef_ergebnis.py @@ -1,9 +1,21 @@ +""" +sn_basis/modules/pruef_ergebnis.py + +Erweitertes Ergebnisobjekt für Dateiprüfungen mit Verfahrens-DB-spezifischen Aktionen. +""" + from __future__ import annotations from dataclasses import dataclass from typing import Any, Optional, Literal +from pathlib import Path + + +# ============================================================================= +# Erweiterte PruefAktionen für Verfahrens-DB-Workflow +# ============================================================================= -# Erweitertes Literal mit allen erlaubten Aktionen (PruefAktion) PruefAktion = Literal[ + # Basis-Aktionen (bestehend) "ok", "leer", "leereingabe_erlaubt", @@ -16,6 +28,8 @@ PruefAktion = Literal[ "pfad_nicht_gefunden", "url_nicht_erreichbar", "netzwerkfehler", + + # Layer-spezifisch "layer_nicht_gefunden", "layer_unsichtbar", "falscher_geotyp", @@ -25,15 +39,26 @@ PruefAktion = Literal[ "felder_fehlen", "datenquelle_unerwartet", "layer_nicht_editierbar", + + # Dateiendung/Format "falsche_endung", "pflichtfelder_fehlen", - # Excel / Import-spezifische Aktionen + + # Excel/Import "kein_header", "kein_arbeitsblatt", "read_error", "open_error", "datenabruf", - # Generische Prüf-/Speicher-Aktionen + + # 🆕 VERFAHRENS-DB SPEZIFISCH (deine Anforderungen 2.d, 2.e) + "datei_wird_erzeugt", # 2.d: Pfad gültig, Datei fehlt → weiter + "datei_existiert", # Datei vorhanden → Layer-Entscheidung + "datei_existiert_ueberschreiben", # 2.e: Nutzer wählt "Überschreiben" + "datei_existiert_anhaengen", # 2.e: Nutzer wählt "Anhängen" + "datei_existiert_ueberspringen", # 2.e: Nutzer wählt "Überspringen" + + # Generisch "pruefe_exception", "save_exception", "save_not_implemented", @@ -42,22 +67,113 @@ PruefAktion = Literal[ "needs_user_action", ] + @dataclass class pruef_ergebnis: """ - Einheitliches Ergebnisobjekt für Prüfer. - - ok: True wenn Prüfung bestanden - - meldung: menschenlesbare Meldung - - aktion: maschinenlesbarer Aktionscode (PruefAktion) - - kontext: optionaler Zusatzkontext (z. B. Pfad, Layer-Objekt) + Einheitliches Ergebnisobjekt für Prüfer im Verfahrens-DB-Workflow. + + Attributes + ---------- + ok : bool + True wenn Prüfung bestanden und Pipeline fortgesetzt werden kann. + False signalisiert Fehler oder Nutzerentscheidung erforderlich. + meldung : Optional[str], optional + Menschenlesbare Meldung für UI-Dialoge (BY: Pruefmanager). + aktion : Optional[PruefAktion], optional + Maschinenlesbarer Aktionscode für nachfolgende Pipeline-Schritte. + kontext : Optional[Any], optional + Zusatzkontext: meist `pathlib.Path` für Dateipfade oder Layer-Objekte. + + Verfahrens-DB-spezifische Aktionen: + + +-----------------------------+-------------------------------------------------+ + | Aktion | Bedeutung | + +=============================+=================================================+ + | ``datei_wird_erzeugt`` | 2.d: Neues GPKG wird angelegt (Pfad gültig) | + +-----------------------------+-------------------------------------------------+ + | ``datei_existiert`` | Datei vorhanden → Layer-Überschreibung prüfen | + +-----------------------------+-------------------------------------------------+ + | ``datei_existiert_*`` | 2.e: Nutzerentscheidung für bestehende Datei | + +-----------------------------+-------------------------------------------------+ """ + ok: bool meldung: Optional[str] = None aktion: Optional[PruefAktion] = None kontext: Optional[Any] = None - def __init__(self, ok: bool, meldung: Optional[str] = None, aktion: Optional[PruefAktion] = None, kontext: Optional[Any] = None): + def __init__( + self, + ok: bool, + meldung: Optional[str] = None, + aktion: Optional[PruefAktion] = None, + kontext: Optional[Any] = None, + ) -> None: + """ + Erstellt ein neues Prüfergebnis. + + Parameters + ---------- + ok : bool + True für "weiter mit Pipeline", False für "Entscheidung/Fehler". + meldung : Optional[str] + UI-Text für Nutzerdialoge. + aktion : Optional[PruefAktion] + Maschinenaktion für nachfolgende Verarbeitung. + kontext : Optional[Any] + Typischerweise `pathlib.Path` (Dateipfad) oder `QgsVectorLayer`. + """ self.ok = ok self.meldung = meldung self.aktion = aktion self.kontext = kontext + + @property + def ist_verfahrens_db_aktion(self) -> bool: + """ + Prüft, ob es sich um eine Verfahrens-DB-spezifische Aktion handelt. + + Returns + ------- + bool + True für ``datei_wird_erzeugt`` oder ``datei_existiert*``. + """ + return self.aktion in { + "datei_wird_erzeugt", + "datei_existiert", + "datei_existiert_ueberschreiben", + "datei_existiert_anhaengen", + "datei_existiert_ueberspringen", + } + + @property + def dateipfad(self) -> Optional[Path]: + """ + Extrahiert den Dateipfad aus dem Kontext (falls vorhanden). + + Returns + ------- + Optional[Path] + `Path`-Objekt oder None. + """ + if isinstance(self.kontext, Path): + return self.kontext + return None + + @property + def erlaubte_persistierung(self) -> bool: + """ + Prüft, ob die Pipeline Daten persistieren darf. + + Returns + ------- + bool + True für ``datei_wird_erzeugt``, ``datei_existiert_ueberschreiben``, + ``datei_existiert_anhaengen``. + """ + return self.aktion in { + "datei_wird_erzeugt", + "datei_existiert_ueberschreiben", + "datei_existiert_anhaengen", + } From 5dc8412a6a7c5dbe374be52fa4ea669efe78cacf Mon Sep 17 00:00:00 2001 From: daniel Date: Fri, 6 Mar 2026 10:20:40 +0100 Subject: [PATCH 11/11] =?UTF-8?q?Imports=20f=C3=BCr=20sn=5FVerfahrensgebie?= =?UTF-8?q?t=20erg=C3=A4nzt,=20Stilpr=C3=BCfer=20wird=20jetzt=20auch=20f?= =?UTF-8?q?=C3=BCr=20apply=5Fstyle=20verwendet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- functions/__init__.py | 2 +- functions/dialog_wrapper.py | 49 +++++++++++++++++++- functions/ly_style_wrapper.py | 39 ++++++++++++---- functions/qgiscore_wrapper.py | 6 +++ functions/qt_wrapper.py | 87 ++++++++++++++++++++++++++++++++++- modules/Pruefmanager.py | 58 ++++++++++++----------- modules/stilpruefer.py | 2 +- 7 files changed, 203 insertions(+), 40 deletions(-) diff --git a/functions/__init__.py b/functions/__init__.py index cfbfc4a..8b06c1d 100644 --- a/functions/__init__.py +++ b/functions/__init__.py @@ -15,7 +15,7 @@ from .ly_metadata_wrapper import ( is_layer_editable, ) from .ly_style_wrapper import apply_style -from .dialog_wrapper import ask_yes_no +from .dialog_wrapper import ask_yes_no, ask_overwrite_append_cancel_custom from .message_wrapper import ( _get_message_bar, diff --git a/functions/dialog_wrapper.py b/functions/dialog_wrapper.py index f91c4bf..71bfc48 100644 --- a/functions/dialog_wrapper.py +++ b/functions/dialog_wrapper.py @@ -2,8 +2,10 @@ sn_basis/functions/dialog_wrapper.py – Benutzer-Dialoge (Qt5/6/Mock-kompatibel) """ from typing import Any +from typing import Literal, Optional from sn_basis.functions.qt_wrapper import ( - QMessageBox, YES, NO, QT_VERSION + QMessageBox, YES, NO, CANCEL, QT_VERSION, exec_dialog, ICON_QUESTION, + ) def ask_yes_no( @@ -35,3 +37,48 @@ def ask_yes_no( except Exception as e: print(f"⚠️ ask_yes_no Fehler: {e}") return default + + +OverwriteDecision = Optional[Literal["overwrite", "append", "cancel"]] + + +def ask_overwrite_append_cancel_custom( + parent, + title: str, + message: str, +) -> Literal["overwrite", "append", "cancel"]: + """Zeigt Dialog mit benutzerdefinierten Buttons: Überschreiben/Anhängen/Abbrechen. + + Parameters + ---------- + parent : + Eltern-Widget oder None. + title : str + Dialog-Titel. + message : str + Hauptmeldung mit Erklärung. + + Returns + ------- + Literal["overwrite", "append", "cancel"] + Genaue Entscheidung des Nutzers. + """ + msg = QMessageBox(parent) + msg.setIcon(ICON_QUESTION) + msg.setWindowTitle(title) + msg.setText(message) + + # Eigene Buttons mit exakten Texten + overwrite_btn = msg.addButton("Überschreiben", QMessageBox.ButtonRole.AcceptRole) + append_btn = msg.addButton("Anhängen", QMessageBox.ButtonRole.ActionRole) + cancel_btn = msg.addButton("Abbrechen", QMessageBox.ButtonRole.RejectRole) + + exec_dialog(msg) + + clicked = msg.clickedButton() + if clicked == overwrite_btn: + return "overwrite" + elif clicked == append_btn: + return "append" + else: # cancel_btn + return "cancel" diff --git a/functions/ly_style_wrapper.py b/functions/ly_style_wrapper.py index 3145532..ad0221a 100644 --- a/functions/ly_style_wrapper.py +++ b/functions/ly_style_wrapper.py @@ -1,23 +1,44 @@ # sn_basis/functions/ly_style_wrapper.py from sn_basis.functions.ly_existence_wrapper import layer_exists -from sn_basis.functions.sys_wrapper import ( - get_plugin_root, - join_path, - file_exists, -) - +from sn_basis.functions.sys_wrapper import get_plugin_root, join_path +from sn_basis.modules.stilpruefer import Stilpruefer +from typing import Optional def apply_style(layer, style_name: str) -> bool: + """ + Wendet einen Layerstil an, sofern er gültig ist. + + - Validierung erfolgt ausschließlich über Stilpruefer + - Keine eigenen Dateisystem- oder Endungsprüfungen + - Keine Seiteneffekte bei ungültigem Stil + """ + print(">>> apply_style() START") + if not layer_exists(layer): return False - style_path = join_path(get_plugin_root(), "styles", style_name) - if not file_exists(style_path): + # Stilpfad zusammensetzen + style_path = join_path(get_plugin_root(), "sn_verfahrensgebiet","styles", style_name) + + # Stil prüfen + pruefer = Stilpruefer() + ergebnis = pruefer.pruefe(style_path) + print(">>> Stilprüfung:", ergebnis) + + print( + f"[Stilprüfung] ok={ergebnis.ok} | " + f"aktion={ergebnis.aktion} | " + f"meldung={ergebnis.meldung}" +) + + + if not ergebnis.ok: return False + # Stil anwenden try: - ok, _ = layer.loadNamedStyle(style_path) + ok, _ = layer.loadNamedStyle(str(ergebnis.kontext)) if ok: getattr(layer, "triggerRepaint", lambda: None)() return True diff --git a/functions/qgiscore_wrapper.py b/functions/qgiscore_wrapper.py index 4a3905e..2473fbf 100644 --- a/functions/qgiscore_wrapper.py +++ b/functions/qgiscore_wrapper.py @@ -36,6 +36,9 @@ try: Qgis as _Qgis, QgsMapLayerProxyModel as _QgsMaplLayerProxyModel, QgsVectorFileWriter as _QgsVectorFileWriter, + QgsFeature as _QgsFeature, + QgsField as _QgsField, + QgsGeometry as _QgsGeometry, ) QgsProject = _QgsProject @@ -45,6 +48,9 @@ try: Qgis = _Qgis QgsMapLayerProxyModel = _QgsMaplLayerProxyModel QgsVectorFileWriter = _QgsVectorFileWriter + QgsFeature = _QgsFeature + QgsField = _QgsField + QgsGeometry = _QgsGeometry QGIS_AVAILABLE = True diff --git a/functions/qt_wrapper.py b/functions/qt_wrapper.py index 09dfa40..d0c325f 100644 --- a/functions/qt_wrapper.py +++ b/functions/qt_wrapper.py @@ -11,6 +11,7 @@ NO: Optional[Any] = None CANCEL: Optional[Any] = None ICON_QUESTION: Optional[Any] = None + # Qt-Klassen (werden dynamisch gesetzt) QDockWidget: Type[Any] = object QMessageBox: Type[Any] = object @@ -36,6 +37,8 @@ QToolButton: Type[Any] = object QSizePolicy: Type[Any] = object Qt: Type[Any] = object QComboBox: Type[Any] = object +QHBoxLayout: Type[Any] = object + def exec_dialog(dialog: Any) -> Any: """Führt Dialog modal aus (Qt6: exec(), Qt5: exec_(), Mock: YES)""" @@ -77,12 +80,14 @@ try: QToolButton as _QToolButton, QSizePolicy as _QSizePolicy, QComboBox as _QComboBox, + QHBoxLayout as _QHBoxLayout, ) from qgis.PyQt.QtCore import ( QEventLoop as _QEventLoop, QUrl as _QUrl, QCoreApplication as _QCoreApplication, Qt as _Qt, + QVariant as _QVariant ) from qgis.PyQt.QtNetwork import ( QNetworkRequest as _QNetworkRequest, @@ -115,12 +120,16 @@ try: QToolButton = _QToolButton QSizePolicy = _QSizePolicy QComboBox = _QComboBox - + QVariant = _QVariant + QHBoxLayout= _QHBoxLayout # ✅ QT6 ENUMS YES = QMessageBox.StandardButton.Yes NO = QMessageBox.StandardButton.No CANCEL = QMessageBox.StandardButton.Cancel ICON_QUESTION = QMessageBox.Icon.Question + AcceptRole = QMessageBox.ButtonRole.AcceptRole + ActionRole = QMessageBox.ButtonRole.ActionRole + RejectRole = QMessageBox.ButtonRole.RejectRole # Qt6 Enum-Aliase ToolButtonTextBesideIcon = Qt.ToolButtonStyle.ToolButtonTextBesideIcon @@ -161,12 +170,14 @@ except (ImportError, AttributeError): QToolButton as _QToolButton, QSizePolicy as _QSizePolicy, QComboBox as _QComboBox, + QHBoxLayout as _QHBoxLayout, ) from PyQt5.QtCore import ( QEventLoop as _QEventLoop, QUrl as _QUrl, QCoreApplication as _QCoreApplication, Qt as _Qt, + QVariant as _QVariant ) from PyQt5.QtNetwork import ( QNetworkRequest as _QNetworkRequest, @@ -199,12 +210,18 @@ except (ImportError, AttributeError): QToolButton = _QToolButton QSizePolicy = _QSizePolicy QComboBox = _QComboBox + QVariant = _QVariant + QHBoxLayout = _QHBoxLayout # ✅ PYQT5 ENUMS YES = QMessageBox.Yes NO = QMessageBox.No CANCEL = QMessageBox.Cancel ICON_QUESTION = QMessageBox.Question + AcceptRole = QMessageBox.AcceptRole + ActionRole = QMessageBox.ActionRole + RejectRole = QMessageBox.RejectRole + # PyQt5 Enum-Aliase ToolButtonTextBesideIcon = Qt.ToolButtonTextBesideIcon @@ -244,6 +261,10 @@ except (ImportError, AttributeError): No = NO Cancel = CANCEL Question = ICON_QUESTION + AcceptRole = 0 + ActionRole = 3 + RejectRole = 1 + @classmethod def question(cls, parent, title, message, buttons, default_button): @@ -423,9 +444,71 @@ except (ImportError, AttributeError): QComboBox = _MockComboBox + + # --------------------------- + # Mock für QVariant + # --------------------------- + + class _MockQVariant: + """ + Minimaler Ersatz für QtCore.QVariant. + + Ziel: + - Werte transparent durchreichen + - Typ-Konstanten bereitstellen + - Keine Qt-Abhängigkeiten + """ + + # Typ-Konstanten (symbolisch, Werte egal) + Invalid = 0 + Int = 1 + Double = 2 + String = 3 + Bool = 4 + Date = 5 + DateTime = 6 + + def __init__(self, value: Any = None): + self._value = value + + def value(self) -> Any: + return self._value + + def __repr__(self) -> str: + return f"QVariant({self._value!r})" + + # Optional: automatische Entpackung + def __int__(self): + return int(self._value) + + def __float__(self): + return float(self._value) + + def __str__(self): + return str(self._value) + QVariant = _MockQVariant + + class _MockQHBoxLayout: + def __init__(self, *args, **kwargs): + self._widgets = [] + + def addWidget(self, widget): + self._widgets.append(widget) + + def addLayout(self, layout): + pass + + def addStretch(self, *args, **kwargs): + pass + + def setSpacing(self, *args, **kwargs): + pass + + def setContentsMargins(self, *args, **kwargs): + pass + QHBoxLayout = _MockQHBoxLayout def exec_dialog(dialog: Any) -> Any: return YES - # --------------------------- TEST --------------------------- if __name__ == "__main__": debug_qt_status() diff --git a/modules/Pruefmanager.py b/modules/Pruefmanager.py index 3ea40ec..96bb8b5 100644 --- a/modules/Pruefmanager.py +++ b/modules/Pruefmanager.py @@ -5,7 +5,7 @@ sn_basis/modules/Pruefmanager.py from __future__ import annotations from typing import Optional, Any -from sn_basis.functions import ask_yes_no, info, warning, error +from sn_basis.functions import ask_yes_no, info, warning, error, ask_overwrite_append_cancel_custom from sn_basis.modules.pruef_ergebnis import pruef_ergebnis, PruefAktion print("DEBUG: Pruefmanager DATEI GELADEN:", __file__) @@ -60,6 +60,26 @@ class Pruefmanager: # VERFAHRENS-DB-spezifische Entscheidungen # ------------------------------------------------------------------ def _handle_datei_existiert(self, ergebnis: pruef_ergebnis) -> pruef_ergebnis: + """Handhabt das Szenario, dass die Ziel-Verfahrens-DB bereits existiert. + + Zeigt einen einzigen Dialog mit drei Optionen an: + - **Überschreiben**: Bestehende Layer ersetzen (entspricht YES) + - **Anhängen**: Neue Layer zur Datei hinzufügen (entspricht NO) + - **Abbrechen**: Vorgang beenden (entspricht CANCEL) + + Parameters + ---------- + ergebnis : pruef_ergebnis + Eingabe-Ergebnis mit Dateipfad im ``kontext``-Attribut. + + Returns + ------- + pruef_ergebnis + Ergebnis mit Aktion: + - ``datei_existiert_ueberschreiben`` + - ``datei_existiert_anhaengen`` + - ``datei_existiert_ueberspringen`` (für Cancel-Fall) + """ if self.ui_modus != "qgis": return ergebnis @@ -72,48 +92,34 @@ class Pruefmanager: "Was soll geschehen?\n\n" "• **Überschreiben**: Bestehende Layer ersetzen\n" "• **Anhängen**: Neue Layer hinzufügen\n" - "• **Überspringen**: Nur temporäre Layer erzeugen" + "• **Abbrechen**: Vorgang beenden" ) - # Vereinfacht: Erst Überschreiben? → Dann Anhängen? → Überspringen - if ask_yes_no( - titel, - f"{meldung}\n\n**Überschreiben** (alle Layer ersetzen)?", - default=False, - parent=self.parent - ): + # Einzelner Dialog mit drei Optionen + entscheidung = ask_overwrite_append_cancel_custom( + parent=self.parent, + title=titel, + message=meldung + ) + + if entscheidung == "overwrite": return pruef_ergebnis( ok=True, aktion="datei_existiert_ueberschreiben", kontext=ergebnis.kontext, ) - - if ask_yes_no( - titel, - f"{meldung}\n\n**Anhängen** (neue Layer hinzufügen)?", - default=False, - parent=self.parent - ): + elif entscheidung == "append": return pruef_ergebnis( ok=True, aktion="datei_existiert_anhaengen", kontext=ergebnis.kontext, ) - - if ask_yes_no( - titel, - f"{meldung}\n\n**Überspringen** (nur temporäre Layer)?", - default=True, - parent=self.parent - ): + else: # cancel return pruef_ergebnis( ok=True, aktion="datei_existiert_ueberspringen", kontext=ergebnis.kontext, ) - - return ergebnis - # ------------------------------------------------------------------ # Basis-Entscheidungen (KORREKT: → pruef_ergebnis) # ------------------------------------------------------------------ diff --git a/modules/stilpruefer.py b/modules/stilpruefer.py index db9312a..aa12879 100644 --- a/modules/stilpruefer.py +++ b/modules/stilpruefer.py @@ -6,7 +6,7 @@ Die Anwendung erfolgt später über eine Aktion. from pathlib import Path -from sn_basis.functions import file_exists +from sn_basis.functions.sys_wrapper import file_exists from sn_basis.modules.pruef_ergebnis import pruef_ergebnis
sn_basis