Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 262 additions & 0 deletions loopstructural/debug_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
#! python3

"""Debug manager handling logging and debug directory management."""

# standard
import datetime
import json
import os
import tempfile
import uuid
from pathlib import Path
from typing import Any

# PyQGIS
from qgis.core import QgsProject

# project
import loopstructural.toolbelt.preferences as plg_prefs_hdlr


class DebugManager:
"""Manage debug mode state, logging and debug file storage."""

def __init__(self, plugin):
self.plugin = plugin
self._session_dir = None
self._session_id = uuid.uuid4().hex
self._project_name = self._get_project_name()
self._debug_state_logged = False
self.logger = self.plugin.log

def _get_settings(self):
return plg_prefs_hdlr.PlgOptionsManager.get_plg_settings()

def _get_project_name(self) -> str:
try:
proj = QgsProject.instance()
title = proj.title()
if title:
return title
stem = Path(proj.fileName() or "").stem
return stem or "untitled_project"
except Exception as err:
self.plugin.log(
message=f"[map2loop] Failed to resolve project name: {err}",
log_level=1,
)
return "unknown_project"

def is_debug(self) -> bool:
"""Return whether debug mode is enabled."""
try:
state = bool(self._get_settings().debug_mode)
if not self._debug_state_logged:
self.plugin.log(
message=f"[map2loop] Debug mode: {'ON' if state else 'OFF'}",
log_level=0,
)
self._debug_state_logged = True
return state
except Exception as err:
self.plugin.log(
message=f"[map2loop] Error checking debug mode: {err}",
log_level=2,
)
return False

def get_effective_debug_dir(self) -> Path:
"""Return the session debug directory, creating it if needed."""
if self._session_dir is not None:
return self._session_dir

try:
debug_dir_pref = plg_prefs_hdlr.PlgOptionsManager.get_debug_directory()
except Exception as err:
self.plugin.log(
message=f"[map2loop] Reading debug_directory failed: {err}",
log_level=1,
)
debug_dir_pref = ""

base_dir = (
Path(debug_dir_pref).expanduser()
if str(debug_dir_pref).strip()
else Path(tempfile.gettempdir()) / "map2loop_debug"
)

ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
session_dir = base_dir / self._project_name / f"session_{self._session_id}_{ts}"

try:
session_dir.mkdir(parents=True, exist_ok=True)
except Exception as err:
self.plugin.log(
message=(
f"[map2loop] Failed to create session dir '{session_dir}': {err}. "
"Falling back to system temp."
),
log_level=1,
)
fallback = (
Path(tempfile.gettempdir())
/ "map2loop_debug"
/ self._project_name
/ f"session_{self._session_id}_{ts}"
)
try:
fallback.mkdir(parents=True, exist_ok=True)
except Exception as err_fallback:
self.plugin.log(
message=(
f"[map2loop] Failed to create fallback debug dir '{fallback}': "
f"{err_fallback}"
),
log_level=2,
)
fallback = Path(tempfile.gettempdir())
session_dir = fallback

self._session_dir = session_dir
self.plugin.log(
message=f"[map2loop] Debug directory resolved: {session_dir}",
log_level=0,
)
return self._session_dir

def _sanitize_label(self, context_label: str) -> str:
"""Sanitize context label for safe filename usage."""
return "".join(
c if c.isalnum() or c in ("-", "_") else "_"
for c in context_label.replace(" ", "_").lower()
)

def log_params(self, context_label: str, params: Any):
"""Log parameters and persist them when debug mode is enabled."""
try:
self.plugin.log(
message=f"[map2loop] {context_label} parameters: {str(params)}",
log_level=0,
)
except Exception as err:
self.plugin.log(
message=(
f"[map2loop] {context_label} parameters (stringified due to {err}): {str(params)}"
),
log_level=0,
)

if self.is_debug():
try:
debug_dir = self.get_effective_debug_dir()
safe_label = self._sanitize_label(context_label)
file_path = debug_dir / f"{safe_label}_params.json"
payload = params if isinstance(params, dict) else {"_payload": params}
with open(file_path, "w", encoding="utf-8") as file_handle:
json.dump(payload, file_handle, ensure_ascii=False, indent=2, default=str)
self.plugin.log(
message=f"[map2loop] Params saved to: {file_path}",
log_level=0,
)
self._ensure_runner_script()
except Exception as err:
self.plugin.log(
message=f"[map2loop] Failed to save params for {context_label}: {err}",
log_level=2,
)

def save_debug_file(self, filename: str, content_bytes: bytes):
"""Persist a debug file atomically and log its location."""
try:
debug_dir = self.get_effective_debug_dir()
out_path = debug_dir / filename
tmp_path = debug_dir / (filename + ".tmp")
with open(tmp_path, "wb") as file_handle:
file_handle.write(content_bytes)
os.replace(tmp_path, out_path)
self.plugin.log(
message=f"[map2loop] Debug file saved: {out_path}",
log_level=0,
)
return out_path
except Exception as err:
self.plugin.log(
message=f"[map2loop] Failed to save debug file '{filename}': {err}",
log_level=2,
)
return None

def _ensure_runner_script(self):
"""Create a reusable runner script in the debug directory."""
try:
debug_dir = self.get_effective_debug_dir()
script_path = debug_dir / "run_map2loop.py"
if script_path.exists():
return
script_content = """#!/usr/bin/env python3
import argparse
import json
from pathlib import Path

import geopandas as gpd

from loopstructural.main import m2l_api


def load_layer(layer_info):
if isinstance(layer_info, dict):
export_path = layer_info.get("export_path")
if export_path:
return gpd.read_file(export_path)
return layer_info


def load_params(path):
params = json.loads(Path(path).read_text())
# convert exported layers to GeoDataFrames
for key, value in list(params.items()):
params[key] = load_layer(value)
return params


def run(params):
if "sampler_type" in params:
result = m2l_api.sample_contacts(**params)
print("Sampler result:", result)
elif "sorting_algorithm" in params:
result = m2l_api.sort_stratigraphic_column(**params)
print("Sorter result:", result)
elif "calculator_type" in params:
result = m2l_api.calculate_thickness(**params)
print("Thickness result:", result)
elif "geology_layer" in params and "unit_name_field" in params:
result = m2l_api.extract_basal_contacts(**params)
print("Basal contacts result:", result)
else:
print("Unknown params shape; inspect manually:", params.keys())


def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"params",
nargs="?",
default=None,
help="Path to params JSON (defaults to first *_params.json in this folder)",
)
args = parser.parse_args()
base = Path(__file__).parent
params_path = Path(args.params) if args.params else next(base.glob("*_params.json"))
params = load_params(params_path)
run(params)


if __name__ == "__main__":
main()
"""
script_path.write_text(script_content, encoding="utf-8")
except Exception as err:
self.plugin.log(
message=f"[map2loop] Failed to create runner script: {err}",
log_level=1,
)
39 changes: 39 additions & 0 deletions loopstructural/gui/dlg_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ def __init__(self, parent):
self.btn_reset.setIcon(QIcon(QgsApplication.iconPath("mActionUndo.svg")))
self.btn_reset.pressed.connect(self.reset_settings)

if hasattr(self, "btn_browse_debug_directory"):
self.btn_browse_debug_directory.pressed.connect(self._browse_debug_directory)
if hasattr(self, "btn_open_debug_directory"):
self.btn_open_debug_directory.pressed.connect(self._open_debug_directory)

# load previously saved settings
self.load_settings()

Expand All @@ -91,6 +96,9 @@ def apply(self):
settings.interpolator_cpw = self.cpw_spin_box.value()
settings.interpolator_regularisation = self.regularisation_spin_box.value()
settings.version = __version__
debug_dir_text = (self.le_debug_directory.text() if hasattr(self, "le_debug_directory") else "") or ""
self.plg_settings.set_debug_directory(debug_dir_text)
settings.debug_directory = debug_dir_text

# dump new settings into QgsSettings
self.plg_settings.save_from_object(settings)
Expand All @@ -114,6 +122,8 @@ def load_settings(self):
self.regularisation_spin_box.setValue(settings.interpolator_regularisation)
self.cpw_spin_box.setValue(settings.interpolator_cpw)
self.npw_spin_box.setValue(settings.interpolator_npw)
if hasattr(self, "le_debug_directory"):
self.le_debug_directory.setText(settings.debug_directory or "")

def reset_settings(self):
"""Reset settings in the UI and persisted settings to plugin defaults."""
Expand All @@ -125,6 +135,35 @@ def reset_settings(self):
# update the form
self.load_settings()

def _browse_debug_directory(self):
"""Open a directory selector for debug directory."""
from qgis.PyQt.QtWidgets import QFileDialog

start_dir = (self.le_debug_directory.text() if hasattr(self, "le_debug_directory") else "") or ""
chosen = QFileDialog.getExistingDirectory(self, "Select Debug Files Directory", start_dir)
if chosen and hasattr(self, "le_debug_directory"):
self.le_debug_directory.setText(chosen)

def _open_debug_directory(self):
"""Open configured debug directory in the system file manager."""
logger = getattr(self, "log", PlgLogger().log)
target = (
self.le_debug_directory.text()
if hasattr(self, "le_debug_directory")
else self.plg_settings.get_debug_directory()
) or ""
if target:
target_path = Path(target)
if target_path.exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target_path)))
else:
logger(
message=f"[map2loop] Debug directory does not exist: {target}",
log_level=1,
)
else:
logger(message="[map2loop] No debug directory configured.", log_level=1)


class PlgOptionsFactory(QgsOptionsWidgetFactory):
"""Factory for options widget."""
Expand Down
42 changes: 35 additions & 7 deletions loopstructural/gui/dlg_settings.ui
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,9 @@
<bool>false</bool>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="8" column="0" colspan="2">
<widget class="QPushButton" name="btn_reset">
<property name="minimumSize">
<item row="9" column="0" colspan="2">
<widget class="QPushButton" name="btn_reset">
<property name="minimumSize">
<size>
<width>200</width>
<height>25</height>
Expand All @@ -100,8 +100,8 @@
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QLabel" name="lbl_version_saved_value">
<item row="7" column="1">
<widget class="QLabel" name="lbl_version_saved_value">
<property name="minimumSize">
<size>
<width>0</width>
Expand Down Expand Up @@ -147,8 +147,36 @@
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="btn_help">
<item row="8" column="0">
<widget class="QLabel" name="lbl_debug_directory">
<property name="text">
<string>Debug directory</string>
</property>
</widget>
</item>
<item row="8" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QLineEdit" name="le_debug_directory"/>
</item>
<item>
<widget class="QPushButton" name="btn_browse_debug_directory">
<property name="text">
<string>Browse...</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="btn_open_debug_directory">
<property name="text">
<string>Open Folder</string>
</property>
</widget>
</item>
</layout>
</item>
<item row="0" column="0">
<widget class="QPushButton" name="btn_help">
<property name="minimumSize">
<size>
<width>200</width>
Expand Down
Loading