From f5bd42dce0e709dae96a490648484780abef1c07 Mon Sep 17 00:00:00 2001 From: Andrii Liekariev Date: Mon, 28 Sep 2026 01:07:41 +0300 Subject: [PATCH 1/2] Add opt-in problem reports: local log, review dialog and one-click sending - Local plugin log (QGIS message log and a rotating file) with no layer names, paths, attribute values or coordinates. - "Report a problem..." dialog: the user reviews and edits a report (environment, layers, project, recent operations, rotation math, crash and hang dumps from the previous session, opt-in geometries shifted to a local origin), then copies it, opens a pre-filled GitHub issue, sends an email or sends it with one click. Nothing is sent automatically. - Cloudflare Worker receiver (tools/report_worker) that files reports as issues in a private repository; the endpoint and email come from release secrets. - GitHub issue form and privacy note. --- .github/ISSUE_TEMPLATE/bug_report.yml | 38 ++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/workflows/release.yaml | 27 +- .github/workflows/test.yaml | 11 + CHANGELOG.md | 1 + CLAUDE.md | 4 + DEVELOPMENT.md | 2 + PolygonsParallelToLine/src/algorithm.py | 23 +- PolygonsParallelToLine/src/const.py | 3 + PolygonsParallelToLine/src/contact.py | 5 + PolygonsParallelToLine/src/diagnostics.py | 572 ++++++++++++++++++++ PolygonsParallelToLine/src/map_tool.py | 118 +++- PolygonsParallelToLine/src/parallelizer.py | 28 +- PolygonsParallelToLine/src/plugin.py | 72 ++- PolygonsParallelToLine/src/pptl.py | 64 ++- PolygonsParallelToLine/src/reference.py | 7 +- PolygonsParallelToLine/src/report_dialog.py | 166 ++++++ PolygonsParallelToLine/src/report_sender.py | 77 +++ PolygonsParallelToLine/src/rotator.py | 34 +- PolygonsParallelToLine/src/snapshot.py | 183 +++++++ PolygonsParallelToLine/src/target.py | 2 +- README.md | 13 + site/privacy.md | 18 + tests/conftest.py | 9 + tests/test_algorithm.py | 37 ++ tests/test_diagnostics.py | 461 ++++++++++++++++ tests/test_map_tool.py | 71 +++ tests/test_parallelizer.py | 33 ++ tests/test_plugin.py | 83 ++- tests/test_report_dialog.py | 157 ++++++ tests/test_report_sender.py | 111 ++++ tests/test_snapshot.py | 113 ++++ tools/report_worker/.gitignore | 1 + tools/report_worker/README.md | 39 ++ tools/report_worker/package.json | 10 + tools/report_worker/src/index.js | 65 +++ tools/report_worker/src/report.js | 38 ++ tools/report_worker/test/index.test.js | 94 ++++ tools/report_worker/wrangler.toml | 18 + 39 files changed, 2765 insertions(+), 48 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 PolygonsParallelToLine/src/contact.py create mode 100644 PolygonsParallelToLine/src/diagnostics.py create mode 100644 PolygonsParallelToLine/src/report_dialog.py create mode 100644 PolygonsParallelToLine/src/report_sender.py create mode 100644 PolygonsParallelToLine/src/snapshot.py create mode 100644 site/privacy.md create mode 100644 tests/test_diagnostics.py create mode 100644 tests/test_report_dialog.py create mode 100644 tests/test_report_sender.py create mode 100644 tests/test_snapshot.py create mode 100644 tools/report_worker/.gitignore create mode 100644 tools/report_worker/README.md create mode 100644 tools/report_worker/package.json create mode 100644 tools/report_worker/src/index.js create mode 100644 tools/report_worker/src/report.js create mode 100644 tools/report_worker/test/index.test.js create mode 100644 tools/report_worker/wrangler.toml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..334f2ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: Bug report +description: The plugin raised an error or produced a wrong result +labels: [bug] +body: +- type: markdown + attributes: + value: | + In QGIS, **Vector › Parallelizer › Report a problem…** (or the **Report a problem…** button shown after an error) fills in the diagnostics field below for you. + Issues are public: remove anything you don't want to share. + + **Wrong result without an error?** The report describes the last few actions of the interactive map tool in detail, but a Processing run only in summary. So please reproduce the problem on one of the affected features with the map tool (click the reference, then the feature), then open **Report a problem…** right away. If you can share the shapes, tick **Include the geometries involved**: they are moved to a local origin, so their real location is not included. +- type: textarea + id: what-happened + attributes: + label: What happened? + description: What did you do, what did you expect, and what happened instead? + validations: + required: true +- type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open a line layer and a polygon layer + 2. ... + validations: + required: true +- type: textarea + id: diagnostics + attributes: + label: Diagnostics report + description: From Vector › Parallelizer › Report a problem… in QGIS. + render: text +- type: textarea + id: data + attributes: + label: Sample data or screenshots + description: Optional. A small layer that reproduces the problem helps a lot. Only attach data you are allowed to share publicly. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a2e4129 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: +- name: Questions and ideas + url: https://github.com/elfpkck/parallelizer/discussions + about: Ask how to use the plugin or discuss ideas. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6f65238..7c7e959 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,6 +30,13 @@ jobs: env: QGIS_PLUGIN_TOKEN: ${{ secrets.QGIS_PLUGIN_TOKEN }} run: ': "${QGIS_PLUGIN_TOKEN:?QGIS_PLUGIN_TOKEN secret is not set}"' + - name: Check the report email and endpoint are set + env: + REPORT_EMAIL: ${{ secrets.REPORT_EMAIL }} + REPORT_ENDPOINT: ${{ secrets.REPORT_ENDPOINT }} + run: | + : "${REPORT_EMAIL:?REPORT_EMAIL secret is not set}" + : "${REPORT_ENDPOINT:?REPORT_ENDPOINT secret is not set}" - uses: actions/checkout@v7.0.1 with: {fetch-depth: 0} - uses: actions/setup-python@v7 @@ -51,14 +58,32 @@ jobs: gh release create "${GITHUB_REF_NAME}" --title "${GITHUB_REF_NAME}" --generate-notes fi echo "created=true" >> "${GITHUB_OUTPUT}" + # Kept out of the repository and written into the placeholder only for the package. + - name: Fill in the report contacts + env: + REPORT_EMAIL: ${{ secrets.REPORT_EMAIL }} + REPORT_ENDPOINT: ${{ secrets.REPORT_ENDPOINT }} + run: | + python - <<'PY' + import os + import pathlib + + path = pathlib.Path("PolygonsParallelToLine/src/contact.py") + path.write_text( + "".join(f"{name} = {os.environ[name]!r}\n" for name in ("REPORT_EMAIL", "REPORT_ENDPOINT")), + encoding="utf-8", + ) + PY - name: Release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} QGIS_PLUGIN_TOKEN: ${{ secrets.QGIS_PLUGIN_TOKEN }} + # --allow-uncommitted-changes: package the filled-in contact.py (qgis-plugin-ci builds the zip from git). run: | qgis-plugin-ci release "${GITHUB_REF_NAME}" \ --github-token "${GITHUB_TOKEN}" \ - --qgis-token "${QGIS_PLUGIN_TOKEN}" + --qgis-token "${QGIS_PLUGIN_TOKEN}" \ + --allow-uncommitted-changes - name: Delete orphan release on failure if: failure() && steps.create_release.outputs.created == 'true' env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index eb28f72..37fa06d 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,6 +24,17 @@ jobs: - uses: pre-commit/action@v3.0.1 with: extra_args: --color=always --from-ref ${{ github.event.pull_request.base.sha }} --to-ref ${{ github.event.pull_request.head.sha }} + worker-tests: + name: Report Worker Tests + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.draft == false }} + steps: + - uses: actions/checkout@v7.0.1 + - uses: actions/setup-node@v7 + with: + node-version: 22 + - run: node --test + working-directory: tools/report_worker unit-tests: name: Unit Tests runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 4062783..d502acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +- Add **Vector › Parallelizer › Report a problem…**: a diagnostics report (versions, settings, last error, recent plugin log, and a description of the layers, project and features the failed or last operation worked on, with the rotation math for the last five operations; geometries only on request, moved to a local origin, with large references clipped around each target; Python stacks from a crash or a freeze in the previous session) you can review and edit, then copy, open as a pre-filled GitHub issue, open as a pre-filled email to the developer in your own mail app, or send with one click (**Send report**, no account needed) to the developer's private report service. See the privacy note on the docs site. Nothing is sent automatically. After an unexpected error, a message offers to open it. The plugin now also logs to the Log Messages panel (*Parallelizer* tab) and a small log file in the QGIS profile folder ## [2.1.3] - 2026-09-27 - Rename the plugin on the QGIS plugin portal to "Parallelizer: Align Polygons and Lines" and rewrite its description, about text and tags so it is easier to find diff --git a/CLAUDE.md b/CLAUDE.md index 2157391..276971f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,10 @@ Invariants: Alongside the Processing algorithm, `Plugin.initGui` registers a dedicated "Parallelizer" `QToolBar` (objectName `PolygonsParallelToLineToolBar`) with two `QAction`s: the interactive parallelize toggle that activates `ParallelToLineMapTool` (`src/map_tool.py`) on the canvas, and a Settings action that opens `MapToolSettingsDialog` (`src/settings_dialog.py`) to edit `MapToolSettings` (`src/settings.py`). `MapToolSettings` owns live tool state (`by_longest`, `pick_reference_segment`, `pick_target_segment`) and persists each via `QSettings` under the `PolygonsParallelToLine/map_tool/` prefix, emitting `changed` on every write so the toolbar's checkable actions and the dialog's checkboxes stay in sync; the map tool reads these flags at each operation. The tool has two states: first a click or drag-rectangle on a line or polygon sets the reference (`canvasReleaseEvent`; highlighted with a `QgsRubberBand`); after that, a single click identifies and rotates one line/polygon target, while a click-and-drag draws a selection rectangle (`QgsRubberBand` polygon) and on release rotates every line/polygon feature whose geometry intersects the rectangle across all editable visible layers, wrapped per-layer in `beginEditCommand`/`endEditCommand`. Single clicks use `QgsMapToolIdentify.TopDownAll` so the tool walks through identify results top-down and picks the first one whose geometry type matches the current state's needs (line or polygon when no reference is set, with line-first preference: a line under the click wins over a polygon; line or polygon when one is). The drag-rectangle reference path applies the same line-first preference across visible layers. All cross-CRS work goes through `QgsCoordinateTransform` against `QgsProject.instance()`: the reference is stored in the map (canvas) CRS (transformed once at set time from the source layer's CRS) and `_reference_for_layer` re-projects it into each target layer's CRS at rotation time, while filter rectangles are transformed via `transformBoundingBox`. All math is delegated to `compute_parallel_geometry` in `src/parallelizer.py`, which reuses `ReferenceFeature`, `Segment`, `calc_delta_azimuth`, and `TargetRotator` from the batch pipeline; pivot is the target's centroid. Right-click or `Esc` clears the reference (or cancels an in-progress drag). The toolbar additionally exposes two checkable actions (also mirrored as checkboxes in the settings dialog) to pick a single segment of the reference / target. With pick mode on, a single click identifies the segment closest to the click point; a drag-rectangle picks the segment of the topmost matching feature (reference role) or one segment per intersecting feature (target role) via `_pick_segment_in_rect`, which picks the segment with the largest overlap with the rectangle and falls back to the segment closest to the rectangle's center when no segment actually intersects (e.g., a rectangle sitting inside a polygon's interior). When the target-segment flag is on, the chosen `Segment` is passed through as `target_segment=` to `compute_parallel_geometry`, bypassing the usual `_pick_target_segment` selection. +## Diagnostics + +`src/diagnostics.py` owns the plugin log (`logging.getLogger("parallelizer")`, set up in `Plugin.initProcessing`, torn down in `unload`): a handler to `QgsMessageLog` (tag `Parallelizer`) and a rotating file `/parallelizer/parallelizer.log`. `@capture_errors` wraps the map tool's Qt event handlers and `Algorithm.processAlgorithm`: it records unexpected exceptions (not `UserInputError`) and re-raises, and `diagnostics.notifier` makes `Plugin` offer "Report a problem…" in the message bar (queued, rate-limited). `ReportDialog` (`src/report_dialog.py`) shows `build_report()` for the user to edit, then copies it and opens a pre-filled GitHub issue (`issue_url`; `.github/ISSUE_TEMPLATE/bug_report.yml`, field `diagnostics`) or a `mailto:` link to `contact.REPORT_EMAIL` (`mailto_url`; empty in the repo and filled in from the `REPORT_EMAIL` secret by `release.yaml` only for the package, so never commit an address there; empty hides the email option; a much shorter length limit for mail clients); both go through `fit_report`, which drops the oldest log lines first. **Send report** (`src/report_sender.py`, `ReportSender`) POSTs a JSON payload (schema 1, report capped at `MAX_REPORT_BYTES`) through `QgsNetworkAccessManager` to `contact.REPORT_ENDPOINT` (filled from the `REPORT_ENDPOINT` secret like the email; empty hides the button). The receiver is `tools/report_worker/` (Cloudflare Worker, `node --test`), which files a private GitHub issue and must never store or log IPs or report content; the privacy promises it has to keep are in `site/privacy.md`. Nothing is ever sent automatically; keep it that way (GDPR/ePrivacy). Log calls must never include layer names, file paths, attribute values or coordinates; counts, geometry types, CRS authids, flags and versions are fine. `scrub()` is a best-effort second line of defense, not a license to log sensitive data. Raise `diagnostics.UserInputError` (a `QgsProcessingException`) for problems with the user's input so they are not recorded as bugs; a plain `QgsProcessingException` is recorded. Exception messages must not embed coordinates either. Each map tool release and each Processing run is a `diagnostics.operation(...)`; code inside adds sections via `current_operation()` using the describers in `src/snapshot.py` (fail-safe, no names/paths/coordinates). `compute_parallel_geometry`/`TargetRotator` take an optional `trace` dict for the rotation math; Processing passes none per feature and only re-runs the failing feature with a trace (`ParallelToReference.note_failed_feature`), keeping the hot loop free. The last `MAX_KEPT_OPERATIONS` non-idle operations are kept, plus the failed one. `setup_logging` also arms `faulthandler` into `crash.log` unless someone else owns it; QGIS usually does, writing to `/qgis-python-crash-info-` for its crash dialog, and the next session reads the newest unseen such file instead and map tool operations pass `watchdog=True` to dump stacks into `hang.log` after `HANG_TIMEOUT_S`; the next session moves both to `*-previous.log`, reports them and offers "Report a problem…" when they contain plugin frames. Geometries reach the report only through `geometry_block()`, shifted to a local origin, behind an opt-in checkbox in `ReportDialog`. + ## Testing `tests/test_main_functionality.py` is the integration suite — builds in-memory `QgsVectorLayer`s from WKT, runs the pipeline via `processing.run(algOrName=Algorithm(), ...)`, compares output WKT. Extend the existing `@pytest.mark.parametrize` table for new cases rather than adding files. Other `tests/test_*.py` files are module unit tests and don't need a full processing run. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 8d67b97..a347325 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -86,6 +86,8 @@ These instructions are specific to PyCharm. Required GitHub Secrets (one-time setup, repo Settings → Secrets and variables → Actions): - `QGIS_PLUGIN_TOKEN`: an upload token created on the plugin's page on plugins.qgis.org (Tokens) +- `REPORT_EMAIL`: the address the report dialog's **Send by email…** button writes to. The workflow writes it into `PolygonsParallelToLine/src/contact.py` only for the package, so it never appears in the repository. It is still readable inside the published zip, so prefer a forwarding alias you can replace (e.g. iCloud+ Hide My Email). Local and CI builds leave it empty, which hides the email option. +- `REPORT_ENDPOINT`: the URL of the report receiver behind **Send report** (a Cloudflare Worker, set up as described in [`tools/report_worker/README.md`](tools/report_worker/README.md)). Written into `contact.py` the same way; empty hides the button. Uploading with a token syncs the portal's `about` text from `metadata.txt` on every release. It does not sync `name` unless a portal admin enables "Allow update name" for the plugin. diff --git a/PolygonsParallelToLine/src/algorithm.py b/PolygonsParallelToLine/src/algorithm.py index ddc15f3..e949857 100644 --- a/PolygonsParallelToLine/src/algorithm.py +++ b/PolygonsParallelToLine/src/algorithm.py @@ -19,7 +19,9 @@ SOURCE_TYPE_LINE, SOURCE_TYPE_POLYGON, ) +from .diagnostics import capture_errors, current_operation, operation from .pptl import Params, ParallelToReference +from .snapshot import describe_processing, describe_project, describe_source if TYPE_CHECKING: from qgis.core import ( @@ -110,10 +112,24 @@ def _create_output_fields(self, source_layer: QgsProcessingFeatureSource) -> Qgs fields.append(QgsField(COLUMN_NAME, BOOL_FIELD_TYPE)) return fields + @capture_errors def processAlgorithm( # noqa: N802 self, parameters: dict[str, Any], context: QgsProcessingContext, feedback: QgsProcessingFeedback ) -> dict[str, str]: + with operation("processing run") as op: + op.add("Processing", describe_processing(parameters, context)) + op.add("Project", describe_project(project=context.project())) + return self._process(parameters, context, feedback) + + def _process( + self, parameters: dict[str, Any], context: QgsProcessingContext, feedback: QgsProcessingFeedback + ) -> dict[str, str]: + op = current_operation() target_layer = self.parameterAsSource(parameters, self.TARGET_LAYER, context) + op.add( + "Target layer", + describe_source(target_layer, self.parameterAsVectorLayer(parameters, self.TARGET_LAYER, context)), + ) output_fields = self._create_output_fields(target_layer) sink, dest_id = self.parameterAsSink( parameters=parameters, @@ -123,8 +139,13 @@ def processAlgorithm( # noqa: N802 geometryType=target_layer.wkbType(), crs=target_layer.sourceCrs(), ) + reference_layer = self.parameterAsSource(parameters, self.REFERENCE_LAYER, context) + op.add( + "Reference layer", + describe_source(reference_layer, self.parameterAsVectorLayer(parameters, self.REFERENCE_LAYER, context)), + ) params = Params( - reference_layer=self.parameterAsSource(parameters, self.REFERENCE_LAYER, context), + reference_layer=reference_layer, target_layer=target_layer, by_longest=self.parameterAsBool(parameters, self.LONGEST, context), no_multi=self.parameterAsBool(parameters, self.NO_MULTI, context), diff --git a/PolygonsParallelToLine/src/const.py b/PolygonsParallelToLine/src/const.py index cb891d1..52d00e2 100644 --- a/PolygonsParallelToLine/src/const.py +++ b/PolygonsParallelToLine/src/const.py @@ -1,7 +1,10 @@ +from pathlib import Path + from qgis.core import Qgis, QgsProcessing, QgsProcessingParameterNumber, QgsWkbTypes COLUMN_NAME = "_rotated" OUTPUT_LAYER_NAME = "Output layer with rotated features" +PLUGIN_DIR = Path(__file__).resolve().parent.parent # Qgis.* scoped enums were added in QGIS 3.30/3.36; older releases fall back to the legacy enums, spelled # with their enum class so the plugin portal's Qt6 check (a static scan) accepts them. diff --git a/PolygonsParallelToLine/src/contact.py b/PolygonsParallelToLine/src/contact.py new file mode 100644 index 0000000..c46b332 --- /dev/null +++ b/PolygonsParallelToLine/src/contact.py @@ -0,0 +1,5 @@ +# Filled in by the release workflow from the REPORT_EMAIL and REPORT_ENDPOINT secrets, so they stay out of the +# repository. Empty in the repository: the report dialog then hides the email and one-click send options. +REPORT_EMAIL = "" +# URL of the report receiver (tools/report_worker), which files each report as a private GitHub issue. +REPORT_ENDPOINT = "" diff --git a/PolygonsParallelToLine/src/diagnostics.py b/PolygonsParallelToLine/src/diagnostics.py new file mode 100644 index 0000000..46a96c4 --- /dev/null +++ b/PolygonsParallelToLine/src/diagnostics.py @@ -0,0 +1,572 @@ +""" +Local log and a problem report the user reviews and shares themselves; nothing here sends data anywhere. + +Privacy rule for every log call in the plugin: no layer names, file paths, attribute values or coordinates. +""" + +from __future__ import annotations + +import collections +import configparser +import contextlib +import dataclasses +import faulthandler +import functools +import logging +from logging.handlers import RotatingFileHandler +import os +from pathlib import Path +import platform +import re +import tempfile +import threading +import time +import traceback +from typing import IO, TYPE_CHECKING, Any, Callable, TypeVar, cast +from urllib.parse import quote, urlencode + +from qgis.core import Qgis, QgsApplication, QgsGeometry, QgsMessageLog, QgsProcessingException, QgsProjUtils +from qgis.PyQt.QtCore import ( # type: ignore[import-not-found] + PYQT_VERSION_STR, + QObject, + QSettings, + pyqtSignal, + qVersion, +) +import qgis.utils # type: ignore[import-not-found] + +try: + from osgeo import gdal # type: ignore[import-not-found] +except ImportError: # pragma: no cover - GDAL ships with every QGIS build + gdal = None + +from . import contact +from .const import PLUGIN_DIR + +if TYPE_CHECKING: + from collections.abc import Iterator + + from .settings import MapToolSettings + +LOG_TAG = "Parallelizer" +LOG_FILE_NAME = "parallelizer.log" +# Python stack dumps written by faulthandler, kept in the log folder; moved to "-previous.log" next session. +_DUMPS = { + "crash": ("crash.log", "Crash in the previous session"), + "hang": ("hang.log", "Slow or frozen operation in the previous session"), +} +HANG_TIMEOUT_S = 30.0 +_KEY_LAST_QGIS_CRASH = "PolygonsParallelToLine/diagnostics/last_qgis_crash_mtime" +ISSUE_TEMPLATE = "bug_report.yml" +LOG_SECTION = "Recent log lines (oldest first)" +GEOMETRY_SECTION = "Geometries (moved so the first reference vertex is at 0 0; real location removed)" +TRUNCATED_NOTE = "(truncated; the full report is on your clipboard)" + +_LOG_MAX_BYTES = 256 * 1024 +_REPORT_LOG_LINES = 100 +# GitHub rejects issue URLs somewhere above 8 KB. +_MAX_URL_LENGTH = 7000 +# Mail clients accept much shorter links: on Windows a mailto: link handed to the default mail app can be cut +# at about 2,000 characters. +_MAX_MAILTO_LENGTH = 1900 +_MAIL_PROMPT = "Please describe what you did, what you expected and what happened instead:\n\n\n" +_RECORDED_ATTR = "_parallelizer_recorded" +_MAX_OPERATION_GEOMETRIES = 12 +MAX_TRACED_FEATURES = 5 +MAX_KEPT_OPERATIONS = 5 +MAX_WKT_VERTICES = 1000 +# Two decimal numbers in a row, e.g. "523301.2 6720011.8" in a GEOS message or a WKT fragment. +_COORDINATE_PAIR = re.compile(r"-?\d+\.\d+(?:[ ,]+-?\d+\.\d+)+") + +logger = logging.getLogger("parallelizer") + +F = TypeVar("F", bound=Callable[..., Any]) + + +class UserInputError(QgsProcessingException): + """A problem with the user's input (e.g. an empty layer), not a plugin bug: shown, but not recorded.""" + + +class _ErrorNotifier(QObject): + error_recorded = pyqtSignal() + + +# Created on import, in the main thread, so an emit from the Processing worker thread reaches +# queued connections in the main thread. +notifier = _ErrorNotifier() + + +@dataclasses.dataclass +class _State: + handlers: list[logging.Handler] = dataclasses.field(default_factory=list) + file_handler: RotatingFileHandler | None = None + last_error: str | None = None + last_error_type: str | None = None + # Newest first. A failed operation is also kept on its own, so later actions never push it out. + operations: collections.deque[Operation] = dataclasses.field( + default_factory=lambda: collections.deque(maxlen=MAX_KEPT_OPERATIONS) + ) + failed_operation: Operation | None = None + dump_files: dict[str, IO[str]] = dataclasses.field(default_factory=dict) + owns_faulthandler: bool = False + # Dumps found at startup, i.e. written by the previous session: kind -> text. + previous_dumps: dict[str, str] = dataclasses.field(default_factory=dict) + + +@dataclasses.dataclass +class Operation: + """What one map tool action or Processing run worked on, kept for the report.""" + + name: str + # A dict section is rendered when the report is built, so values filled in before a failure still show. + sections: list[tuple[str, list[str] | dict[str, Any]]] = dataclasses.field(default_factory=list) + # None stands for a geometry too large for the report; it is not kept in memory. + geometries: list[tuple[str, QgsGeometry | None]] = dataclasses.field(default_factory=list) + traced_features: int = 0 + duration_s: float | None = None + failed: bool = False + # An idle operation (e.g. a click on nothing) is not kept unless it fails. + idle: bool = False + + def describe_as(self, name: str) -> None: + self.name = name + self.idle = False + + def add(self, title: str, lines: list[str] | dict[str, Any]) -> None: + self.sections.append((title, lines)) + + def add_trace(self, title: str) -> dict[str, Any]: + trace: dict[str, Any] = {} + self.add(title, trace) + return trace + + def add_geometry(self, role: str, geom: QgsGeometry) -> None: + if len(self.geometries) >= _MAX_OPERATION_GEOMETRIES or geom.isNull(): + return + small = geom.constGet().nCoordinates() <= MAX_WKT_VERTICES + self.geometries.append((role, QgsGeometry(geom) if small else None)) + + def report_lines(self, label: str) -> list[str]: + status = "failed" if self.failed else "finished" + duration = f" in {self.duration_s:.3f} s" if self.duration_s is not None else "" + lines = [f" {label}: {self.name} ({status}{duration})"] + for title, section in self.sections: + lines.append(f" {title}") + if isinstance(section, dict): + section = [f"{k}: {v:.6g}" if isinstance(v, float) else f"{k}: {v}" for k, v in section.items()] # noqa: PLW2901 + lines.extend(f" {line}" for line in section or ["(nothing recorded)"]) + return lines + + +_state = _State() +# Per thread, because a Processing run can overlap with map tool use in the main thread. +_current = threading.local() + + +class _QgsMessageLogHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + if record.levelno >= logging.ERROR: + level = Qgis.MessageLevel.Critical + elif record.levelno >= logging.WARNING: + level = Qgis.MessageLevel.Warning + else: + level = Qgis.MessageLevel.Info + try: + QgsMessageLog.logMessage(self.format(record), LOG_TAG, level) + except Exception: # noqa: BLE001 + self.handleError(record) + + +def default_log_dir() -> Path: + return Path(QgsApplication.qgisSettingsDirPath()) / "parallelizer" + + +def _add_handler(handler: logging.Handler, fmt: str) -> None: + handler.setFormatter(logging.Formatter(fmt)) + logger.addHandler(handler) + _state.handlers.append(handler) + + +def setup_logging(log_dir: Path | None = None) -> None: + if _state.handlers: + return + logger.setLevel(logging.INFO) + logger.propagate = False + + log_file = (log_dir or default_log_dir()) / LOG_FILE_NAME + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler(log_file, maxBytes=_LOG_MAX_BYTES, backupCount=1, encoding="utf-8") + except OSError: + pass + else: + _add_handler(file_handler, "%(asctime)s %(levelname)s %(message)s") + _state.file_handler = file_handler + # Logged before the QGIS handler is attached, so it only marks sessions in the file. + logger.info("Session start: Parallelizer %s, QGIS %s", plugin_metadata("version"), Qgis.version()) + _setup_dumps(log_file.parent) + + _add_handler(_QgsMessageLogHandler(), "%(message)s") + + +def _setup_dumps(log_dir: Path) -> None: + """ + Collect the previous session's crash/hang dumps, then arm faulthandler for this session. A hard crash + (e.g. a segfault in Qt) kills Python before any except clause runs; faulthandler still writes the + Python stacks. QGIS usually owns faulthandler already (its crash dialog shows those stacks), so it is + left alone and QGIS's own dump file is read in the next session instead. + """ + with contextlib.suppress(OSError): + qgis_crash = _unseen_qgis_crash_dump() + if qgis_crash: + _state.previous_dumps["crash"] = qgis_crash + try: + for kind, (name, _) in _DUMPS.items(): + path = log_dir / name + if path.exists() and path.stat().st_size: + _state.previous_dumps[kind] = path.read_text(encoding="utf-8", errors="replace") + path.replace(log_dir / f"{kind}-previous.log") + _state.dump_files[kind] = path.open("w", encoding="utf-8") + # Someone else (QGIS, another plugin, pytest) already owns it: don't redirect their dumps. + if not faulthandler.is_enabled(): + faulthandler.enable(_state.dump_files["crash"], all_threads=True) + _state.owns_faulthandler = True + except OSError: + _close_dumps() + + +def _unseen_qgis_crash_dump() -> str | None: + """ + The newest non-empty Python crash dump QGIS left in the temp folder + (``QgsCrashHandler::sPythonCrashLogFile``) since the last check; each crash is reported once. + """ + settings = QSettings() + last_seen = float(settings.value(_KEY_LAST_QGIS_CRASH, 0.0, type=float)) + own = f"qgis-python-crash-info-{os.getpid()}" + dumps = [ + (stat.st_mtime, path) + for path in Path(tempfile.gettempdir()).glob("qgis-python-crash-info-*") + if path.name != own and (stat := path.stat()).st_size + ] + if not dumps: + return None + mtime, newest = max(dumps) + settings.setValue(_KEY_LAST_QGIS_CRASH, mtime) + return newest.read_text(encoding="utf-8", errors="replace") if mtime > last_seen else None + + +def _close_dumps() -> None: + if _state.owns_faulthandler: + faulthandler.disable() + _state.owns_faulthandler = False + faulthandler.cancel_dump_traceback_later() + for file in _state.dump_files.values(): + file.close() + _state.dump_files.clear() + + +def teardown_logging() -> None: + for handler in _state.handlers: + logger.removeHandler(handler) + handler.close() + _state.handlers.clear() + _state.file_handler = None + _close_dumps() + _state.previous_dumps.clear() + _state.last_error = None + _state.last_error_type = None + _state.operations.clear() + _state.failed_operation = None + + +def log_file_path() -> Path | None: + return Path(_state.file_handler.baseFilename) if _state.file_handler else None + + +def record_error(exc: BaseException) -> None: + # An error raised through nested decorated calls (e.g. keyPressEvent -> deactivate) is recorded once. + if getattr(exc, _RECORDED_ATTR, False): + return + with contextlib.suppress(Exception): + setattr(exc, _RECORDED_ATTR, True) + # Diagnostics must never mask the error being reported. + with contextlib.suppress(Exception): + # Scrubbed before logging: a traceback carries file paths, and its message may carry coordinates. + _state.last_error = scrub("".join(traceback.format_exception(type(exc), exc, exc.__traceback__)).rstrip()) + _state.last_error_type = type(exc).__name__ + logger.error("Unexpected error\n%s", _state.last_error) + notifier.error_recorded.emit() + + +def last_error() -> str | None: + return _state.last_error + + +@contextlib.contextmanager +def operation(name: str, *, idle: bool = False, watchdog: bool = False) -> Iterator[Operation]: + """ + ``watchdog`` dumps the Python stacks to hang.log if the operation runs longer than HANG_TIMEOUT_S. Only + for map tool actions: Processing runs can legitimately take long and have their own cancel button. + """ + op = Operation(name, idle=idle) + _current.operation = op + hang_file = _state.dump_files.get("hang") if watchdog else None + if hang_file is not None: + faulthandler.dump_traceback_later(HANG_TIMEOUT_S, repeat=False, file=hang_file) + start = time.perf_counter() + try: + yield op + except Exception: + op.failed = True + _state.failed_operation = op + raise + finally: + if hang_file is not None: + faulthandler.cancel_dump_traceback_later() + op.duration_s = time.perf_counter() - start + _current.operation = None + if op.failed or not op.idle: + _state.operations.appendleft(op) + + +def current_operation() -> Operation: + """The operation running in this thread, or a throwaway one so callers need no checks.""" + return getattr(_current, "operation", None) or Operation("untracked") + + +def capture_errors(func: F) -> F: + """Record unexpected exceptions for the report, then re-raise so QGIS still shows its own error.""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: # noqa: ANN401 + try: + return func(*args, **kwargs) + except UserInputError: + raise + except Exception as exc: + record_error(exc) + raise + + return cast("F", wrapper) + + +@functools.cache +def _metadata() -> dict[str, str]: + parser = configparser.ConfigParser(interpolation=None) + parser.read(PLUGIN_DIR / "metadata.txt", encoding="utf-8") + return dict(parser["general"]) if parser.has_section("general") else {} + + +def plugin_metadata(key: str) -> str: + return _metadata().get(key, "") + + +def scrub(text: str) -> str: + """Best-effort removal of personal paths and coordinates; the user still reviews the result.""" + replacements = { + str(PLUGIN_DIR): "", + QgsApplication.qgisSettingsDirPath(): "", + str(Path.home()): "~", + } + # Longest first: the plugin dir usually sits inside the profile dir, which sits inside home. + for raw_path, placeholder in sorted(replacements.items(), key=lambda item: -len(item[0])): + path = raw_path.rstrip("/\\") + if len(path) < 3: # noqa: PLR2004 - "/" or "C:" would replace every separator + continue + for variant in {path, path.replace("\\", "/"), path.replace("/", "\\")}: + text = re.sub(re.escape(variant), placeholder, text, flags=re.IGNORECASE) + return _COORDINATE_PAIR.sub("", text) + + +def environment() -> dict[str, str]: + return { + "Plugin": plugin_metadata("version") or "unknown", + "QGIS": Qgis.version(), + "Qt": qVersion(), + "PyQt": PYQT_VERSION_STR, + "Python": platform.python_version(), + "GEOS": Qgis.geosVersion(), + "PROJ": f"{QgsProjUtils.projVersionMajor()}.{QgsProjUtils.projVersionMinor()}", + "GDAL": gdal.__version__ if gdal is not None else "unknown", + "OS": " ".join(part for part in (platform.system(), platform.release(), platform.machine()) if part), + "Locale": QgsApplication.locale(), + "Screen": _screen_description(), + } + + +def _screen_description() -> str: + screen = QgsApplication.primaryScreen() if QgsApplication.instance() is not None else None + if screen is None: + return "unknown" + return f"scale {screen.devicePixelRatio():g}, {screen.logicalDotsPerInch():g} dpi" + + +def active_plugins() -> list[str]: + return [f"{name} {qgis.utils.pluginMetadata(name, 'version')}" for name in sorted(qgis.utils.active_plugins)] + + +def _recent_log_lines() -> list[str]: + log_file = log_file_path() + if log_file is None: + return [] + try: + with log_file.open(encoding="utf-8", errors="replace") as f: + return [line.rstrip("\n") for line in collections.deque(f, maxlen=_REPORT_LOG_LINES)] + except OSError: + return [] + + +def build_report(settings: MapToolSettings | None = None) -> str: + lines = ["Parallelizer diagnostics report", "", "Environment"] + lines.extend(f" {name}: {value}" for name, value in environment().items()) + + if settings is not None: + lines.extend( + [ + "", + "Map tool settings", + f" by_longest: {settings.by_longest}", + f" pick_reference_segment: {settings.pick_reference_segment}", + f" pick_target_segment: {settings.pick_target_segment}", + ] + ) + + lines.extend(["", "Active plugins", *_indented("\n".join(active_plugins()))]) + lines.extend(["", "Last error in this session", *_indented(_state.last_error)]) + failed = _state.failed_operation + if failed is not None: + lines.extend(["", "Operation that failed", *scrub("\n".join(failed.report_lines("failed"))).splitlines()]) + recent = [line for label, op in _listed_operations() if op is not failed for line in op.report_lines(label)] + if recent: + lines.extend(["", "Recent operations (newest first)", *scrub("\n".join(recent)).splitlines()]) + for kind, text in _state.previous_dumps.items(): + title = _DUMPS[kind][1] + if _mentions_plugin(text): + lines.extend(["", f"{title} (Python stacks)", *_indented(text)]) + else: + lines.extend(["", title, " it happened outside Parallelizer code"]) + lines.extend(["", LOG_SECTION, *_indented("\n".join(_recent_log_lines()))]) + return "\n".join(lines) + + +def _mentions_plugin(dump: str) -> bool: + return str(PLUGIN_DIR) in dump + + +def previous_session_problem() -> str | None: + """A message-bar text when the previous session crashed or froze inside Parallelizer code.""" + if _mentions_plugin(_state.previous_dumps.get("crash", "")): + return "QGIS closed unexpectedly last time while Parallelizer was working." + if _mentions_plugin(_state.previous_dumps.get("hang", "")): + return f"Parallelizer was busy for over {HANG_TIMEOUT_S:g} s last time (QGIS may have frozen)." + return None + + +def _listed_operations() -> list[tuple[str, Operation]]: + """The failed operation (label "failed") first, then the other kept ones, newest first, labeled #1, #2...""" + failed = _state.failed_operation + listed = [("failed", failed)] if failed is not None else [] + recent = [op for op in _state.operations if op is not failed] + listed.extend((f"#{number}", op) for number, op in enumerate(recent, start=1)) + return listed + + +def has_geometries() -> bool: + return any(op.geometries for _, op in _listed_operations()) + + +def geometry_block() -> str | None: + """ + The geometries of the listed operations as WKT, all moved by one offset so the first geometry (the + reference) sits at 0 0: shapes and relative positions stay, also across operations; the location does not. + """ + listed = [(label, op) for label, op in _listed_operations() if op.geometries] + if not listed: + return None + kept = [(role, geom) for _, op in listed for role, geom in op.geometries if geom is not None] + first = next((geom for role, geom in kept if role.startswith("reference")), kept[0][1] if kept else None) + origin = next(first.vertices(), None) if first is not None else None + lines = [GEOMETRY_SECTION] + for label, op in listed: + lines.append(f" {label}: {op.name}") + for role, geom in op.geometries: + if geom is None or origin is None: + lines.append(f" {role}: omitted, more than {MAX_WKT_VERTICES} vertices; attach sample data instead") + continue + shifted = QgsGeometry(geom) + shifted.translate(-origin.x(), -origin.y()) + lines.append(f" {role}: {shifted.asWkt(6)}") + return "\n".join(lines) + + +def _indented(text: str | None) -> list[str]: + if not text: + return [" none"] + return [f" {line}" for line in scrub(text).splitlines()] + + +def issue_title() -> str: + return f"Bug: {_state.last_error_type}" if _state.last_error_type else "" + + +def _longest_fitting(n: int, fits: Callable[[int], bool]) -> int: + """Largest k in [0, n] with fits(k), assuming fits is monotonic and fits(0) holds.""" + low, high = 0, n + while low < high: + mid = (low + high + 1) // 2 + if fits(mid): + low = mid + else: + high = mid - 1 + return low + + +def fit_report(report: str, fits: Callable[[str], bool], max_chars: int) -> str: + """ + ``report``, or with the oldest log lines dropped (then the end cut) until ``fits`` accepts it. Every probe + measures the whole candidate, so the final cut starts from at most ``max_chars`` characters (``fits`` never + accepts more than that anyway). + """ + if fits(report): + return report + + lines = report.splitlines() + start = lines.index(LOG_SECTION) + 1 if LOG_SECTION in lines else len(lines) + head, logs = lines[:start], lines[start:] + + def with_logs(keep: int) -> str: + return "\n".join([*head, f" {TRUNCATED_NOTE}", *logs[len(logs) - keep :]]) + + keep = _longest_fitting(len(logs), lambda k: fits(with_logs(k))) + if fits(text := with_logs(keep)): + return text + + joined = "\n".join(head)[:max_chars] + chars = _longest_fitting(len(joined), lambda n: fits(f"{joined[:n]}\n{TRUNCATED_NOTE}")) + return f"{joined[:chars]}\n{TRUNCATED_NOTE}" + + +def _fitted_url(report: str, build: Callable[[str], str], limit: int) -> str: + return build(fit_report(report, lambda body: len(build(body)) <= limit, limit)) + + +def issue_url(report: str, title: str = "") -> str: + """GitHub new-issue URL with the report pre-filled; oldest log lines are dropped to fit the URL limit.""" + base = plugin_metadata("tracker").rstrip("/") + "/new" + + def build(body: str) -> str: + return f"{base}?{urlencode({'template': ISSUE_TEMPLATE, 'title': title, 'diagnostics': body})}" + + return _fitted_url(report, build, _MAX_URL_LENGTH) + + +def mailto_url(report: str, title: str = "") -> str: + """A mailto: link to ``contact.REPORT_EMAIL`` with the report pre-filled, cut to what mail clients accept.""" + subject = f"Parallelizer problem report: {title}" if title else "Parallelizer problem report" + + def build(body: str) -> str: + # RFC 6068: percent-encode everything (a "+" is not a space here) and use CRLF line breaks. + encoded = quote(f"{_MAIL_PROMPT}{body}".replace("\n", "\r\n"), safe="") + return f"mailto:{contact.REPORT_EMAIL}?subject={quote(subject, safe='')}&body={encoded}" + + return _fitted_url(report, build, _MAX_MAILTO_LENGTH) diff --git a/PolygonsParallelToLine/src/map_tool.py b/PolygonsParallelToLine/src/map_tool.py index 8a38091..779fc67 100644 --- a/PolygonsParallelToLine/src/map_tool.py +++ b/PolygonsParallelToLine/src/map_tool.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING, Any, Literal from qgis.core import ( Qgis, @@ -19,14 +19,16 @@ from qgis.PyQt.QtGui import QColor # type: ignore[import-not-found] from .const import LINE_GEOMETRY, POLYGON_GEOMETRY, SUPPORTED_GEOMETRIES +from .diagnostics import MAX_TRACED_FEATURES, capture_errors, current_operation, logger, operation from .parallelizer import compute_parallel_geometry from .reference import ReferenceFeature, Segment, iter_segments +from .snapshot import add_reference_geometry, describe_crs, describe_geometry, describe_layer, describe_project if TYPE_CHECKING: - from qgis.core import QgsCoordinateReferenceSystem from qgis.gui import QgisInterface, QgsMapMouseEvent from qgis.PyQt.QtGui import QKeyEvent # type: ignore[import-not-found] + from .diagnostics import Operation from .settings import MapToolSettings @@ -73,17 +75,21 @@ def __init__(self, iface: QgisInterface, settings: MapToolSettings) -> None: self._drag_start_pos: QPoint | None = None self._drag_start_point: QgsPointXY | None = None self._is_dragging: bool = False + self._reference_sections: list[tuple[str, list[str]]] = [] + @capture_errors def activate(self) -> None: super().activate() self.setCursor(Qt.CursorShape.CrossCursor) self._show_message("Click or drag-rectangle on a line or polygon to set the reference.", Qgis.MessageLevel.Info) + @capture_errors def deactivate(self) -> None: self._clear_reference() self._cancel_drag() super().deactivate() + @capture_errors def keyPressEvent(self, event: QKeyEvent) -> None: if event.key() == Qt.Key.Key_Escape: if self._is_dragging: @@ -97,12 +103,14 @@ def keyPressEvent(self, event: QKeyEvent) -> None: return super().keyPressEvent(event) + @capture_errors def canvasPressEvent(self, event: QgsMapMouseEvent) -> None: if event.button() != Qt.MouseButton.LeftButton: return self._drag_start_pos = event.originalPixelPoint() self._drag_start_point = event.originalMapPoint() + @capture_errors def canvasMoveEvent(self, event: QgsMapMouseEvent) -> None: if self._drag_start_pos is None: return @@ -114,6 +122,7 @@ def canvasMoveEvent(self, event: QgsMapMouseEvent) -> None: self._start_selection_band() self._update_selection_band(event.originalMapPoint()) + @capture_errors def canvasReleaseEvent(self, event: QgsMapMouseEvent) -> None: if event.button() == Qt.MouseButton.RightButton: self._cancel_drag() @@ -128,16 +137,17 @@ def canvasReleaseEvent(self, event: QgsMapMouseEvent) -> None: drag_start = self._drag_start_point self._cancel_drag() - if was_dragging and drag_start is not None: - release_point = event.originalMapPoint() - rect = QgsRectangle(drag_start, release_point) - if self.reference_geom is None: - self._set_reference_from_rect(rect) - else: - self._rotate_features_in_rect(rect) - return + with operation("map tool: click on nothing to use", idle=True, watchdog=True): + if was_dragging and drag_start is not None: + release_point = event.originalMapPoint() + rect = QgsRectangle(drag_start, release_point) + if self.reference_geom is None: + self._set_reference_from_rect(rect) + else: + self._rotate_features_in_rect(rect) + return - self._handle_single_click(event) + self._handle_single_click(event) def _handle_single_click(self, event: QgsMapMouseEvent) -> None: # QMouseEvent.x()/y() were removed in Qt 6 and pos() is deprecated there; originalPixelPoint() works on @@ -180,11 +190,11 @@ def _handle_single_click(self, event: QgsMapMouseEvent) -> None: def _set_reference_from_feature(self, layer: QgsVectorLayer, feature: QgsFeature, map_point: QgsPointXY) -> None: ref_geom = feature.geometry() + click_layer = self._point_in_layer_crs(map_point, layer) if self.settings.pick_reference_segment: - click_layer = self._point_in_layer_crs(map_point, layer) segment = _closest_segment_of(ref_geom, click_layer) ref_geom = QgsGeometry.fromPolylineXY([QgsPointXY(segment.start), QgsPointXY(segment.end)]) - self._set_reference(ref_geom, layer.crs()) + self._set_reference(ref_geom, layer, via="click", marker=QgsGeometry.fromPointXY(click_layer)) self._show_message( "Reference set. Click or drag-rectangle to rotate line/polygon features.", Qgis.MessageLevel.Success, @@ -197,7 +207,10 @@ def _rotate_target( geom_type: QgsWkbTypes.GeometryType, map_point: QgsPointXY, ) -> None: + op = self._begin("map tool: rotate (click)", with_reference=True) + op.add("Target layer", describe_layer(layer)) if not layer.isEditable(): + logger.info("Click rotation skipped: target layer not editable") self._show_message( f"Layer '{layer.name()}' is not in edit mode; toggle editing to rotate features.", Qgis.MessageLevel.Warning, @@ -205,18 +218,23 @@ def _rotate_target( return kind: Kind = "line" if geom_type == LINE_GEOMETRY else "polygon" + click_layer = self._point_in_layer_crs(map_point, layer) target_segment: Segment | None = None if self.settings.pick_target_segment: - click_layer = self._point_in_layer_crs(map_point, layer) target_segment = _closest_segment_of(feature.geometry(), click_layer) + reference = self._reference_for_layer(layer) + trace = self._trace_feature(op, reference, feature) + op.add_geometry("click", QgsGeometry.fromPointXY(click_layer)) rotated = compute_parallel_geometry( - self._reference_for_layer(layer), + reference, feature.geometry(), kind, by_longest=self.settings.by_longest, target_segment=target_segment, + trace=trace, ) if rotated is None: + logger.info("Click rotation: %s already parallel, pick_target_segment=%s", kind, target_segment is not None) self._show_message("Feature already parallel; no rotation applied.", Qgis.MessageLevel.Info) return @@ -225,8 +243,10 @@ def _rotate_target( if ok: layer.endEditCommand() layer.triggerRepaint() + logger.info("Click rotation: %s rotated, pick_target_segment=%s", kind, target_segment is not None) else: layer.destroyEditCommand() + logger.warning("Click rotation: changeGeometry failed for a %s (provider %s)", kind, layer.providerType()) self._show_message("Failed to update feature geometry.", Qgis.MessageLevel.Warning) def _set_reference_from_rect(self, map_rect: QgsRectangle) -> None: @@ -263,7 +283,7 @@ def _set_reference_from_rect(self, map_rect: QgsRectangle) -> None: if self.settings.pick_reference_segment: segment = _pick_segment_in_rect(ref_geom, rect_geom, layer_rect.center()) ref_geom = QgsGeometry.fromPolylineXY([QgsPointXY(segment.start), QgsPointXY(segment.end)]) - self._set_reference(ref_geom, layer.crs()) + self._set_reference(ref_geom, layer, via="rectangle", marker=rect_geom) suffix = f" ({len(found)} features in selection; using topmost)" if len(found) > 1 else "" self._show_message( f"Reference set{suffix}. Click or drag-rectangle to rotate line/polygon features.", @@ -277,6 +297,7 @@ def _rotate_features_in_rect(self, map_rect: QgsRectangle) -> None: canvas = self.iface.mapCanvas() rotated_count = 0 non_editable: list[str] = [] + op = self._begin("map tool: rotate (rectangle)", with_reference=True) for canvas_layer in canvas.layers(): if not isinstance(canvas_layer, QgsVectorLayer): @@ -292,6 +313,7 @@ def _rotate_features_in_rect(self, map_rect: QgsRectangle) -> None: if not features: continue + op.add(f"Target layer ({len(features)} feature(s) in the rectangle)", describe_layer(canvas_layer)) if not canvas_layer.isEditable(): non_editable.append(canvas_layer.name()) continue @@ -300,6 +322,11 @@ def _rotate_features_in_rect(self, map_rect: QgsRectangle) -> None: layer_rotated = self._rotate_layer_features(canvas_layer, features, kind, layer_rect=layer_rect) rotated_count += layer_rotated + logger.info( + "Rectangle rotation: %d feature(s) rotated, %d non-editable layer(s) skipped", + rotated_count, + len(non_editable), + ) self._report_bulk_result(rotated_count, non_editable) def _rotate_layer_features( @@ -311,12 +338,12 @@ def _rotate_layer_features( layer_rect: QgsRectangle | None = None, ) -> int: reference = self._reference_for_layer(layer) + op = current_operation() pick_target = self.settings.pick_target_segment and layer_rect is not None - rect_geom: QgsGeometry | None = None - rect_center: QgsPointXY | None = None - if pick_target and layer_rect is not None: - rect_geom = QgsGeometry.fromRect(layer_rect) - rect_center = layer_rect.center() + rect_geom = QgsGeometry.fromRect(layer_rect) if layer_rect is not None else None + rect_center = layer_rect.center() if layer_rect is not None else None + if rect_geom is not None and not any(role == "rectangle" for role, _ in op.geometries): + op.add_geometry("rectangle", rect_geom) layer.beginEditCommand("Parallel to Line (bulk)") layer_rotated = 0 @@ -332,6 +359,7 @@ def _rotate_layer_features( kind, by_longest=self.settings.by_longest, target_segment=target_segment, + trace=self._trace_feature(op, reference, feature), ) if rotated is None: continue @@ -382,7 +410,44 @@ def _transform_rect_to_layer(self, map_rect: QgsRectangle, layer: QgsVectorLayer transform = QgsCoordinateTransform(map_crs, layer_crs, QgsProject.instance()) return transform.transformBoundingBox(map_rect) - def _set_reference(self, geom: QgsGeometry, source_crs: QgsCoordinateReferenceSystem) -> None: + def _begin(self, name: str, *, with_reference: bool = False) -> Operation: + """Name the running operation and snapshot the project, plus the reference set by an earlier click.""" + op = current_operation() + op.describe_as(name) + op.add("Project and canvas", describe_project(self.iface.mapCanvas())) + if with_reference: + for title, lines in self._reference_sections: + op.add(f"{title} (set earlier)", lines) + return op + + @staticmethod + def _trace_feature(op: Operation, reference: QgsGeometry, feature: QgsFeature) -> dict[str, Any] | None: + """Snapshot one target (up to MAX_TRACED_FEATURES per operation) and return its rotation trace.""" + if op.traced_features >= MAX_TRACED_FEATURES: + return None + op.traced_features += 1 + label = f"target {op.traced_features}" + geom = feature.geometry() + distance = reference.distance(geom) + op.add( + f"Target feature {op.traced_features}", + [f"id: {feature.id()}", f"distance to reference: {distance:.6g}", *describe_geometry(geom)], + ) + add_reference_geometry(op, reference, geom, label, distance) + op.add_geometry(label, geom) + return op.add_trace(f"Rotation math for target feature {op.traced_features}") + + def _set_reference(self, geom: QgsGeometry, layer: QgsVectorLayer, *, via: str, marker: QgsGeometry) -> None: + source_crs = layer.crs() + self._reference_sections = [ + ("Reference layer", describe_layer(layer)), + ("Reference geometry", describe_geometry(geom)), + ] + op = self._begin(f"map tool: set reference ({via})") + for title, lines in self._reference_sections: + op.add(title, lines) + add_reference_geometry(op, geom, marker, via, geom.distance(marker)) + op.add_geometry(via, marker) self._clear_reference() reference = QgsGeometry(geom) map_crs = self.iface.mapCanvas().mapSettings().destinationCrs() @@ -398,6 +463,15 @@ def _set_reference(self, geom: QgsGeometry, source_crs: QgsCoordinateReferenceSy rubber_band.setFillColor(self.REFERENCE_FILL) rubber_band.setToGeometry(self.reference_geom) self.reference_rubber_band = rubber_band + logger.info( + "Reference set by %s: %s with %d vertices, pick_reference_segment=%s, layer CRS %s, canvas CRS %s", + via, + QgsWkbTypes.displayString(reference.wkbType()), + 0 if reference.isNull() else reference.constGet().nCoordinates(), + self.settings.pick_reference_segment, + describe_crs(source_crs), + describe_crs(map_crs), + ) def _reference_for_layer(self, layer: QgsVectorLayer) -> QgsGeometry: if self.reference_geom is None: diff --git a/PolygonsParallelToLine/src/parallelizer.py b/PolygonsParallelToLine/src/parallelizer.py index d614657..507708d 100644 --- a/PolygonsParallelToLine/src/parallelizer.py +++ b/PolygonsParallelToLine/src/parallelizer.py @@ -1,7 +1,7 @@ from __future__ import annotations import math -from typing import Literal +from typing import Any, Literal from qgis.core import Qgis, QgsFeature, QgsGeometry @@ -21,7 +21,9 @@ def compute_parallel_geometry( # noqa: PLR0913 by_longest: bool, target_segment: Segment | None = None, angle_threshold: float = math.inf, + trace: dict[str, Any] | None = None, ) -> QgsGeometry | None: + """``trace``, when given, is filled with the intermediate values for the problem report.""" reference = ReferenceFeature.from_geometry(reference_geom) if target_kind == "polygon" and target_segment is None: @@ -33,6 +35,7 @@ def compute_parallel_geometry( # noqa: PLR0913 closest_reference=reference, angle_threshold=angle_threshold, by_longest=by_longest, + trace=trace, ).rotate() return QgsGeometry(target.geom) if target.is_rotated else None @@ -44,19 +47,42 @@ def compute_parallel_geometry( # noqa: PLR0913 else _pick_target_segment(target_geom, ref_segment, by_longest=by_longest) ) delta = calc_delta_azimuth(ref_segment.azimuth, chosen_target_segment.azimuth) + if trace is not None: + pick = "longest" if by_longest else "smallest angle" + if target_segment is not None: + pick = "given" + trace.update( + strategy="line", + target_segment_pick=pick, + reference_azimuth=ref_segment.azimuth, + reference_segment_length=ref_segment.length, + target_azimuth=chosen_target_segment.azimuth, + target_segment_length=chosen_target_segment.length, + delta=delta, + angle_threshold=angle_threshold, + ) if abs(delta) > angle_threshold: + _note(trace, "skipped: delta above threshold") return None if math.isclose(delta, 0.0, abs_tol=ABSOLUTE_TOLERANCE): + _note(trace, "skipped: already parallel") return None rotated = QgsGeometry(target_geom) if rotated.rotate(delta, centroid_xy) != Qgis.GeometryOperationResult.Success: + _note(trace, "skipped: QgsGeometry.rotate failed") return None + _note(trace, "rotated") return rotated +def _note(trace: dict[str, Any] | None, outcome: str) -> None: + if trace is not None: + trace["outcome"] = outcome + + def _pick_target_segment(target_geom: QgsGeometry, ref_segment: Segment, *, by_longest: bool) -> Segment: segments = list(iter_segments(target_geom)) if by_longest: diff --git a/PolygonsParallelToLine/src/plugin.py b/PolygonsParallelToLine/src/plugin.py index c344c8c..3e9b11f 100644 --- a/PolygonsParallelToLine/src/plugin.py +++ b/PolygonsParallelToLine/src/plugin.py @@ -1,13 +1,17 @@ from __future__ import annotations -from pathlib import Path +import time from typing import TYPE_CHECKING -from qgis.core import QgsApplication +from qgis.core import Qgis, QgsApplication +from qgis.PyQt.QtCore import Qt # type: ignore[import-not-found] from qgis.PyQt.QtGui import QIcon # type: ignore[import-not-found] -from qgis.PyQt.QtWidgets import QAction # type: ignore[import-not-found] +from qgis.PyQt.QtWidgets import QAction, QPushButton # type: ignore[import-not-found] +from . import diagnostics +from .const import PLUGIN_DIR from .provider import Provider +from .report_dialog import ReportDialog from .settings import MapToolSettings from .settings_dialog import MapToolSettingsDialog @@ -18,6 +22,7 @@ from .map_tool import ParallelToLineMapTool _MENU_NAME = "Parallelizer" +_REPORT_OFFER_INTERVAL_S = 60.0 class Plugin: @@ -30,9 +35,12 @@ def __init__(self, iface: QgisInterface | None = None) -> None: self.settings_action: QAction | None = None self.pick_reference_action: QAction | None = None self.pick_target_action: QAction | None = None + self.report_action: QAction | None = None self.map_tool: ParallelToLineMapTool | None = None + self._last_report_offer: float | None = None def initProcessing(self) -> None: + diagnostics.setup_logging() self.provider = Provider() QgsApplication.processingRegistry().addProvider(self.provider) @@ -49,7 +57,7 @@ def initGui(self) -> None: self.toolbar = self.iface.addToolBar(_MENU_NAME) self.toolbar.setObjectName("PolygonsParallelToLineToolBar") - icon_path = Path(__file__).resolve().parent.parent / "icons" / "icon.png" + icon_path = PLUGIN_DIR / "icons" / "icon.png" self.parallelize_action = QAction( QIcon(str(icon_path)), "Parallel to Line (interactive)", self.iface.mainWindow() ) @@ -84,6 +92,14 @@ def initGui(self) -> None: self.toolbar.addAction(self.settings_action) self.iface.addPluginToVectorMenu(_MENU_NAME, self.settings_action) + self.report_action = QAction("Report a problem…", self.iface.mainWindow()) + self.report_action.triggered.connect(self._open_report_dialog) + self.iface.addPluginToVectorMenu(_MENU_NAME, self.report_action) + diagnostics.notifier.error_recorded.connect(self._offer_report, Qt.ConnectionType.QueuedConnection) + problem = diagnostics.previous_session_problem() + if problem is not None: + self._offer_report(problem) + self.map_tool = ParallelToLineMapTool(self.iface, self.settings) self.map_tool.deactivated.connect(self._on_map_tool_deactivated) @@ -127,18 +143,48 @@ def _open_settings_dialog(self) -> None: dialog = MapToolSettingsDialog(self.settings, self.iface.mainWindow()) dialog.exec() + def _open_report_dialog(self) -> None: + if self.iface is None: + return + report = diagnostics.build_report(self.settings) + dialog = ReportDialog( + report, + diagnostics.issue_title(), + self.iface.mainWindow(), + geometries=diagnostics.geometry_block if diagnostics.has_geometries() else None, + ) + dialog.exec() + + def _offer_report(self, text: str = "Hit an unexpected error.") -> None: + if self.iface is None: + return + now = time.monotonic() + if self._last_report_offer is not None and now - self._last_report_offer < _REPORT_OFFER_INTERVAL_S: + return + self._last_report_offer = now + bar = self.iface.messageBar() + item = bar.createMessage(_MENU_NAME, text) + button = QPushButton("Report a problem…", item) + button.clicked.connect(self._open_report_dialog) + item.layout().addWidget(button) + bar.pushWidget(item, Qgis.MessageLevel.Warning, 15) + def unload(self) -> None: if self.provider is not None: QgsApplication.processingRegistry().removeProvider(self.provider) + self.provider = None if self.iface is not None: - if self.parallelize_action is not None: - self.iface.removePluginVectorMenu(_MENU_NAME, self.parallelize_action) - if self.pick_reference_action is not None: - self.iface.removePluginVectorMenu(_MENU_NAME, self.pick_reference_action) - if self.pick_target_action is not None: - self.iface.removePluginVectorMenu(_MENU_NAME, self.pick_target_action) - if self.settings_action is not None: - self.iface.removePluginVectorMenu(_MENU_NAME, self.settings_action) + for action in ( + self.parallelize_action, + self.pick_reference_action, + self.pick_target_action, + self.settings_action, + self.report_action, + ): + if action is not None: + self.iface.removePluginVectorMenu(_MENU_NAME, action) + if self.report_action is not None: + diagnostics.notifier.error_recorded.disconnect(self._offer_report) if self.map_tool is not None: canvas = self.iface.mapCanvas() if canvas.mapTool() is self.map_tool: @@ -149,6 +195,8 @@ def unload(self) -> None: self.settings_action = None self.pick_reference_action = None self.pick_target_action = None + self.report_action = None self.toolbar = None self.map_tool = None self.settings = None + diagnostics.teardown_logging() diff --git a/PolygonsParallelToLine/src/pptl.py b/PolygonsParallelToLine/src/pptl.py index 28b5e04..d73c184 100644 --- a/PolygonsParallelToLine/src/pptl.py +++ b/PolygonsParallelToLine/src/pptl.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import dataclasses from functools import cached_property from typing import TYPE_CHECKING, Literal @@ -8,14 +9,15 @@ QgsFeature, QgsFeatureSink, QgsFields, - QgsProcessingException, QgsProcessingFeatureSource, QgsWkbTypes, ) from .const import COLUMN_NAME, LINE_GEOMETRY +from .diagnostics import UserInputError, current_operation, logger from .parallelizer import compute_parallel_geometry from .reference import ReferenceLayer +from .snapshot import add_reference_geometry, describe_crs, describe_geometry from .target import Target if TYPE_CHECKING: @@ -52,23 +54,79 @@ def target_kind(self) -> Literal["line", "polygon"]: def run(self) -> None: # pydevd_pycharm.settrace("127.0.0.1", port=53100, stdoutToServer=True, stderrToServer=True) # noqa: ERA001 self.validate_target_layer() + self.log_run_summary() self.rotate_features() def validate_target_layer(self) -> None: if not self.total_number: msg = "Target layer is empty" - raise QgsProcessingException(msg) + raise UserInputError(msg) + + def log_run_summary(self) -> None: + reference, target = self.params.reference_layer, self.params.target_layer + message = ( + f"Processing run: reference {QgsWkbTypes.displayString(reference.wkbType())} " + f"({describe_crs(reference.sourceCrs())}), target {QgsWkbTypes.displayString(target.wkbType())} " + f"({describe_crs(target.sourceCrs())}), {self.total_number} feature(s), " + f"by_longest={self.params.by_longest}, no_multi={self.params.no_multi}, " + f"distance={self.params.distance}, angle={self.params.angle}" + ) + self.feedback.pushDebugInfo(message) + logger.info(message) def rotate_features(self) -> None: total = 100.0 / self.total_number + rotated_index = self.params.fields.indexFromName(COLUMN_NAME) + processed_count = rotated_count = 0 for i, feature in enumerate(self.params.target_layer.getFeatures(), start=1): if self.feedback.isCanceled(): break - processed = self.process_feature(feature) + try: + processed = self.process_feature(feature) + except Exception: + with contextlib.suppress(Exception): + self.note_failed_feature(feature) + raise self.params.sink.addFeature(processed, QgsFeatureSink.Flag.FastInsert) self.feedback.setProgress(int(i * total)) + processed_count = i + rotated_count += bool(processed.attribute(rotated_index)) + + logger.info( + "Processing run finished: %d of %d feature(s) processed, %d rotated%s", + processed_count, + self.total_number, + rotated_count, + " (canceled)" if self.feedback.isCanceled() else "", + ) + current_operation().add( + "Result", + [ + f"processed: {processed_count} of {self.total_number}, rotated: {rotated_count}", + f"canceled: {self.feedback.isCanceled()}", + ], + ) + + def note_failed_feature(self, feature: QgsFeature) -> None: + """Snapshot the feature that failed; re-running it with a trace records the math up to the failure.""" + op = current_operation() + target_geom = feature.geometry() + op.add("Failed feature", [f"id: {feature.id()}", *describe_geometry(target_geom)]) + op.add_geometry("target", target_geom) + reference_geom = self.reference_layer.get_closest_feature(Target(feature).center_xy).geom + op.add("Closest reference feature", describe_geometry(reference_geom)) + add_reference_geometry(op, reference_geom, target_geom, "target", reference_geom.distance(target_geom)) + # The caller suppresses errors: a re-run that fails again still leaves the trace filled up to that point. + compute_parallel_geometry( + reference_geom, + target_geom, + self.target_kind, + by_longest=self.params.by_longest, + angle_threshold=self.params.angle, + trace=op.add_trace("Rotation math for the failed feature"), + ) def process_feature(self, feature: QgsFeature) -> QgsFeature: target = Target(feature) diff --git a/PolygonsParallelToLine/src/reference.py b/PolygonsParallelToLine/src/reference.py index ce2310f..bdaf3ef 100644 --- a/PolygonsParallelToLine/src/reference.py +++ b/PolygonsParallelToLine/src/reference.py @@ -6,6 +6,7 @@ from qgis.core import QgsPoint, QgsProcessingException, QgsSpatialIndex, QgsWkbTypes from .const import LINE_GEOMETRY, POLYGON_GEOMETRY +from .diagnostics import UserInputError if TYPE_CHECKING: from collections.abc import Iterator @@ -31,7 +32,7 @@ def from_geometry(cls, geom: QgsGeometry) -> ReferenceFeature: def get_closest_segment(self, point_xy: QgsPointXY) -> Segment: sqr_dist, _, next_vertex_idx, _ = self.geom.closestSegmentWithContext(point_xy) if sqr_dist < 0 or next_vertex_idx <= 0: - msg = f"Reference geometry has no valid segment near {point_xy}" + msg = "Reference geometry has no valid segment near the target" raise QgsProcessingException(msg) start, end = self.geom.vertexAt(next_vertex_idx - 1), self.geom.vertexAt(next_vertex_idx) return Segment(start=start, end=end) @@ -47,8 +48,8 @@ def get_closest_feature(self, point: QgsPointXY) -> ReferenceFeature: closest_id = self.spatial_index.nearestNeighbor(point, 1) if not closest_id: - msg = f"No reference features found near point {point}" - raise QgsProcessingException(msg) + msg = "No reference features found" + raise UserInputError(msg) return ReferenceFeature(self.id_feature_map[closest_id[0]]) diff --git a/PolygonsParallelToLine/src/report_dialog.py b/PolygonsParallelToLine/src/report_dialog.py new file mode 100644 index 0000000..a03a439 --- /dev/null +++ b/PolygonsParallelToLine/src/report_dialog.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +from qgis.PyQt.QtCore import QUrl # type: ignore[import-not-found] +from qgis.PyQt.QtGui import QDesktopServices, QFontDatabase # type: ignore[import-not-found] +from qgis.PyQt.QtWidgets import ( # type: ignore[import-not-found] + QApplication, + QCheckBox, + QDialog, + QDialogButtonBox, + QLabel, + QPlainTextEdit, + QVBoxLayout, +) + +from . import contact +from .diagnostics import LOG_SECTION, issue_url, mailto_url, plugin_metadata +from .report_sender import ReportSender + +if TYPE_CHECKING: + from qgis.PyQt.QtWidgets import QWidget # type: ignore[import-not-found] + +_INTRO = ( + "This report helps the developer find and fix the problem. It contains software versions, active plugins, " + "the plugin's settings, the last error, a description of the layers, project and features the last few " + "operations worked on (types, coordinate systems, counts, validity; no names or coordinates), recent " + "Parallelizer log lines and, if QGIS crashed or froze last time, the Python stacks it left. Paths to your " + "home and QGIS profile folders have been replaced automatically. " + "Please check the text and edit or delete anything you don't want to share." +) + + +def intro_text() -> str: + """What the report holds, then one sentence per available way to share it.""" + email, endpoint = contact.REPORT_EMAIL, contact.REPORT_ENDPOINT + sentences = ["Nothing is sent automatically; every button below first copies the full report to the clipboard."] + if endpoint: + sentences.append( + '"Send report" sends the text above to the developer\'s report service (a Cloudflare Worker that files ' + "it privately on GitHub); your IP address is used only for rate limiting and is not stored. " + f"Privacy note: {privacy_url()}." + ) + sentences.append( + '"Open GitHub issue" opens a pre-filled issue in your browser; it is only created if you submit it, and ' + "GitHub issues are public." + ) + if email: + sentences.append(f'"Send by email" opens a pre-filled message to {email} in your mail app.') + sentences.append("If a pre-filled text ends with a truncation note, paste the full report from the clipboard.") + where = f"an issue at {plugin_metadata('tracker')}" + (f" or an email to {email}" if email else "") + sentences.append(f"You can also paste the copied report into {where}.") + return f"{_INTRO}\n\n{' '.join(sentences)}" + + +def privacy_url() -> str: + return f"{plugin_metadata('homepage').rstrip('/')}/privacy.html" + + +class ReportDialog(QDialog): + def __init__( + self, + report: str, + title: str = "", + parent: QWidget | None = None, + *, + geometries: Callable[[], str | None] | None = None, + ) -> None: + """``geometries`` builds the opt-in geometry block; it is only called if the user ticks the box.""" + super().__init__(parent) + self._issue_title = title + self._build_geometries = geometries + self._geometry_block: str | None = None + self.setWindowTitle("Parallelizer: Report a problem") + + intro = QLabel(intro_text()) + intro.setWordWrap(True) + + self.text_edit = QPlainTextEdit(report) + self.text_edit.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + self.text_edit.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + + self.geometry_checkbox = QCheckBox( + "Include the geometries involved, moved so the first reference vertex is at 0 0 " + "(shapes and relative positions are kept, the real location is not)" + ) + self.geometry_checkbox.setEnabled(geometries is not None) + self.geometry_checkbox.toggled.connect(self._toggle_geometries) + + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + self.send_button = button_box.addButton("Send report", QDialogButtonBox.ButtonRole.ActionRole) + self.send_button.setVisible(bool(contact.REPORT_ENDPOINT)) + self.send_button.setDefault(bool(contact.REPORT_ENDPOINT)) + self.send_button.clicked.connect(self.send_report) + self.status_label = QLabel() + self.status_label.setWordWrap(True) + self.status_label.hide() + self._sender = ReportSender(self) + self._sender.finished.connect(self._on_sent) + copy_button = button_box.addButton("Copy to clipboard", QDialogButtonBox.ButtonRole.ActionRole) + copy_button.clicked.connect(self.copy_to_clipboard) + issue_button = button_box.addButton("Open GitHub issue…", QDialogButtonBox.ButtonRole.ActionRole) + issue_button.clicked.connect(self.open_issue) + if contact.REPORT_EMAIL: + email_button = button_box.addButton("Send by email…", QDialogButtonBox.ButtonRole.ActionRole) + email_button.clicked.connect(self.send_email) + button_box.rejected.connect(self.reject) + + layout = QVBoxLayout() + layout.addWidget(intro) + layout.addWidget(self.geometry_checkbox) + layout.addWidget(self.text_edit) + layout.addWidget(self.status_label) + layout.addWidget(button_box) + self.setLayout(layout) + self.resize(760, 560) + + def _toggle_geometries(self, checked: bool) -> None: # noqa: FBT001 + if self._geometry_block is None and self._build_geometries is not None: + self._geometry_block = self._build_geometries() + if self._geometry_block is None: + return + text = self.report_text() + block = f"\n{self._geometry_block}\n" + if checked and block not in text: + # Before the log lines, so they are what gets dropped first when a link is too long. + index = text.find(f"\n{LOG_SECTION}\n") + index = len(text) if index == -1 else index + text = f"{text[:index]}{block}{text[index:]}" + elif not checked: + text = text.replace(block, "") + self.text_edit.setPlainText(text) + + def report_text(self) -> str: + return str(self.text_edit.toPlainText()) + + def copy_to_clipboard(self) -> None: + QApplication.clipboard().setText(self.report_text()) + + def open_issue(self) -> None: + self._open_with_full_copy(issue_url(self.report_text(), self._issue_title)) + + def send_report(self) -> None: + self.copy_to_clipboard() + self.send_button.setEnabled(False) + self._show_status("Sending…") + self._sender.send(self.report_text(), self._issue_title) + + def _on_sent(self, ok: bool, message: str) -> None: # noqa: FBT001 + if ok: + self._show_status(message) + return + self._show_status(f"{message} The full report is on your clipboard; you can also use the other buttons.") + self.send_button.setEnabled(True) + + def _show_status(self, text: str) -> None: + self.status_label.setText(text) + self.status_label.show() + + def send_email(self) -> None: + self._open_with_full_copy(mailto_url(self.report_text(), self._issue_title)) + + def _open_with_full_copy(self, url: str) -> None: + # Copied first, in case the link had to be truncated to fit its length limit. + self.copy_to_clipboard() + QDesktopServices.openUrl(QUrl.fromEncoded(url.encode("ascii"))) diff --git a/PolygonsParallelToLine/src/report_sender.py b/PolygonsParallelToLine/src/report_sender.py new file mode 100644 index 0000000..654fa59 --- /dev/null +++ b/PolygonsParallelToLine/src/report_sender.py @@ -0,0 +1,77 @@ +"""One-click sending of a reviewed report to the receiver in ``contact.REPORT_ENDPOINT`` (see tools/report_worker).""" + +from __future__ import annotations + +import functools +import json +from typing import TYPE_CHECKING + +from qgis.core import Qgis, QgsNetworkAccessManager +from qgis.PyQt.QtCore import QByteArray, QObject, QUrl, pyqtSignal # type: ignore[import-not-found] +from qgis.PyQt.QtNetwork import QNetworkRequest # type: ignore[import-not-found] + +from . import contact +from .diagnostics import fit_report, logger, plugin_metadata + +if TYPE_CHECKING: + from qgis.PyQt.QtNetwork import QNetworkReply # type: ignore[import-not-found] + +SCHEMA_VERSION = 1 +# The receiver rejects bodies over 64 KB; the rest of the payload is small. +MAX_REPORT_BYTES = 60_000 +TIMEOUT_MS = 15_000 +_CREATED = 201 +_TOO_MANY_REQUESTS = 429 + + +def build_payload(report: str, title: str = "") -> bytes: + fitted = fit_report(report, lambda text: len(text.encode("utf-8")) <= MAX_REPORT_BYTES, MAX_REPORT_BYTES) + payload = { + "schema": SCHEMA_VERSION, + "title": title, + "plugin_version": plugin_metadata("version") or "unknown", + "qgis_version": Qgis.version(), + "report": fitted, + } + return json.dumps(payload).encode("utf-8") + + +class ReportSender(QObject): + """Posts one report; ``finished(ok, message)`` carries a message ready to show to the user.""" + + finished = pyqtSignal(bool, str) + + def send(self, report: str, title: str = "") -> None: + request = QNetworkRequest(QUrl(contact.REPORT_ENDPOINT)) + request.setHeader(QNetworkRequest.KnownHeaders.ContentTypeHeader, "application/json") + request.setTransferTimeout(TIMEOUT_MS) + # QGIS's manager applies the user's proxy and SSL settings. + reply = QgsNetworkAccessManager.instance().post(request, QByteArray(build_payload(report, title))) + reply.finished.connect(functools.partial(self._on_finished, reply)) + + def _on_finished(self, reply: QNetworkReply) -> None: + status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + body = bytes(reply.readAll()) + error = reply.errorString() + reply.deleteLater() + + if status == _CREATED: + reference = _reference(body) + logger.info("Report sent (reference %s)", reference) + self.finished.emit(True, f"Sent, thank you. Reference: {reference}") # noqa: FBT003 + return + if status == _TOO_MANY_REQUESTS: + reason = "too many reports were sent from your network recently; please try again later" + elif status: + reason = f"the report service answered with status {status}" + else: + reason = f"the report service could not be reached ({error})" + logger.warning("Report sending failed: %s", reason) + self.finished.emit(False, f"Not sent: {reason}.") # noqa: FBT003 + + +def _reference(body: bytes) -> str: + try: + return str(json.loads(body)["id"]) + except (ValueError, KeyError, TypeError): + return "unknown" diff --git a/PolygonsParallelToLine/src/rotator.py b/PolygonsParallelToLine/src/rotator.py index 72282ea..3b219f5 100644 --- a/PolygonsParallelToLine/src/rotator.py +++ b/PolygonsParallelToLine/src/rotator.py @@ -1,7 +1,7 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from qgis.core import QgsPointXY @@ -18,16 +18,37 @@ class TargetRotator: ABSOLUTE_TOLERANCE = 1e-8 def __init__( - self, target: Target, closest_reference: ReferenceFeature, angle_threshold: float, *, by_longest: bool + self, + target: Target, + closest_reference: ReferenceFeature, + angle_threshold: float, + *, + by_longest: bool, + trace: dict[str, Any] | None = None, ): self.target = target self.angle_threshold = angle_threshold self.by_longest = by_longest + self.trace = trace target_closest_vertex = target.get_closest_vertex(closest_reference) self.prev_target_segment, self.next_target_segment = target.get_adjacent_segments(target_closest_vertex) ref_segment = closest_reference.get_closest_segment(QgsPointXY(target_closest_vertex)) self.prev_delta_azimuth = calc_delta_azimuth(ref_segment.azimuth, self.prev_target_segment.azimuth) self.next_delta_azimuth = calc_delta_azimuth(ref_segment.azimuth, self.next_target_segment.azimuth) + if trace is not None: + trace.update( + strategy="polygon", + reference_azimuth=ref_segment.azimuth, + reference_segment_length=ref_segment.length, + prev_segment_azimuth=self.prev_target_segment.azimuth, + prev_segment_length=self.prev_target_segment.length, + prev_delta=self.prev_delta_azimuth, + next_segment_azimuth=self.next_target_segment.azimuth, + next_segment_length=self.next_target_segment.length, + next_delta=self.next_delta_azimuth, + angle_threshold=angle_threshold, + by_longest=by_longest, + ) def rotate(self) -> None: prev_within = abs(self.prev_delta_azimuth) <= self.angle_threshold @@ -44,10 +65,17 @@ def rotate(self) -> None: self.rotate_by_angle(self.prev_delta_azimuth) elif next_within: self.rotate_by_angle(self.next_delta_azimuth) + elif self.trace is not None: + self.trace["outcome"] = "skipped: both deltas above threshold" def rotate_by_angle(self, angle: float) -> None: - if not math.isclose(angle, 0.0, abs_tol=self.ABSOLUTE_TOLERANCE): + if math.isclose(angle, 0.0, abs_tol=self.ABSOLUTE_TOLERANCE): + outcome = "skipped: already parallel" + else: self.target.rotate(angle) + outcome = "rotated" if self.target.is_rotated else "skipped: QgsGeometry.rotate failed" + if self.trace is not None: + self.trace.update(applied_delta=angle, outcome=outcome) def rotate_by_longest_segment(self) -> None: if self.prev_target_segment.length > self.next_target_segment.length: diff --git a/PolygonsParallelToLine/src/snapshot.py b/PolygonsParallelToLine/src/snapshot.py new file mode 100644 index 0000000..a3123f3 --- /dev/null +++ b/PolygonsParallelToLine/src/snapshot.py @@ -0,0 +1,183 @@ +""" +Describers for the problem report. They return plain lines without layer names, file paths, attribute +values or coordinates, so they are safe to log and to show in the report. +""" + +from __future__ import annotations + +import functools +import re +from typing import TYPE_CHECKING, Any, Callable, TypeVar + +from qgis.core import ( + Qgis, + QgsCoordinateReferenceSystem, + QgsGeometry, + QgsProcessingFeatureSourceDefinition, + QgsProject, + QgsSettings, + QgsUnitTypes, + QgsVectorLayer, + QgsWkbTypes, +) + +from .const import COLUMN_NAME +from .diagnostics import MAX_WKT_VERTICES + +if TYPE_CHECKING: + from qgis.core import QgsProcessingContext, QgsProcessingFeatureSource + from qgis.gui import QgsMapCanvas + + from .diagnostics import Operation + +_NUMBER = re.compile(r"-?\d+(?:\.\d+)?") +_MAX_VALIDITY_ERRORS = 3 +_MAX_VALIDATED_VERTICES = 2_000 + +F = TypeVar("F", bound=Callable[..., list[str]]) + + +def _never_raises(func: F) -> F: + """A describer must not break the map tool or a Processing run it only reports on.""" + + @functools.wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> list[str]: # noqa: ANN401 + try: + return func(*args, **kwargs) + except Exception as exc: # noqa: BLE001 + return [f"unavailable ({type(exc).__name__})"] + + return wrapper # type: ignore[return-value] + + +def _enum_name(value: Any) -> str: # noqa: ANN401 + return str(getattr(value, "name", value)) + + +def describe_crs(crs: QgsCoordinateReferenceSystem) -> str: + if not crs.isValid(): + return "none" + kind = "geographic" if crs.isGeographic() else "projected" + units = QgsUnitTypes.toString(crs.mapUnits()) + # A custom CRS has no authid; its PROJ definition carries no personal data. + return f"{crs.authid() or crs.toProj()} ({kind}, {units})" + + +@_never_raises +def _type_and_crs(wkb_type: QgsWkbTypes.Type, crs: QgsCoordinateReferenceSystem) -> list[str]: + return [f"geometry type: {QgsWkbTypes.displayString(wkb_type)}", f"CRS: {describe_crs(crs)}"] + + +@_never_raises +def describe_layer(layer: QgsVectorLayer) -> list[str]: + fields = layer.fields() + return [ + *_type_and_crs(layer.wkbType(), layer.crs()), + f"provider: {layer.providerType()}, storage: {layer.storageType() or 'unknown'}", + f"features: {layer.featureCount()}, selected: {layer.selectedFeatureCount()}", + f"editable: {layer.isEditable()}, fields: {fields.count()}, has {COLUMN_NAME}: " + f"{fields.indexFromName(COLUMN_NAME) != -1}", + ] + + +@_never_raises +def describe_source(source: QgsProcessingFeatureSource, layer: QgsVectorLayer | None) -> list[str]: + if layer is not None: + return describe_layer(layer) + return [*_type_and_crs(source.wkbType(), source.sourceCrs()), f"features: {source.featureCount()}"] + + +@_never_raises +def describe_geometry(geom: QgsGeometry) -> list[str]: + if geom.isNull() or geom.isEmpty(): + return ["geometry: null or empty"] + wkb = geom.wkbType() + abstract = geom.constGet() + lines = [ + f"geometry: {QgsWkbTypes.displayString(wkb)}, curved: {QgsWkbTypes.isCurvedType(wkb)}", + f"parts: {abstract.partCount()}, rings: {sum(abstract.ringCount(i) for i in range(abstract.partCount()))}, " + f"vertices: {abstract.nCoordinates()}", + f"length: {geom.length():.6g}, area: {geom.area():.6g}", + ] + if abstract.nCoordinates() > _MAX_VALIDATED_VERTICES: + lines.append(f"validity: not checked, more than {_MAX_VALIDATED_VERTICES} vertices") + return lines + lines.append(f"duplicate vertices: {QgsGeometry(geom).removeDuplicateNodes()}") + # GEOS validation is O(n log n), unlike the default QGIS validator. Its messages carry vertex positions; + # every number is masked. + errors = geom.validateGeometry(Qgis.GeometryValidationEngine.Geos) + lines.append(f"GEOS valid: {not errors}") + reasons = sorted({_NUMBER.sub("#", error.what()) for error in errors}) + if reasons: + lines.append(f"validity problems: {'; '.join(reasons[:_MAX_VALIDITY_ERRORS])}") + return lines + + +@_never_raises +def describe_project(canvas: QgsMapCanvas | None = None, project: QgsProject | None = None) -> list[str]: + project = project or QgsProject.instance() + snapping = project.snappingConfig() + lines = [ + f"project CRS: {describe_crs(project.crs())}", + f"ellipsoid: {project.ellipsoid() or 'none'}, distance units: {QgsUnitTypes.toString(project.distanceUnits())}", + f"layers: {project.count()}", + f"topological editing: {project.topologicalEditing()}, " + f"avoid overlap: {_enum_name(project.avoidIntersectionsMode())}", + f"snapping: {snapping.enabled()}, tolerance: {snapping.tolerance():g} {_enum_name(snapping.units())}", + ] + if canvas is not None: + search_radius = QgsSettings().value("Map/searchRadiusMM", 2.0, type=float) + lines.extend( + [ + f"canvas CRS: {describe_crs(canvas.mapSettings().destinationCrs())}", + f"canvas rotation: {canvas.rotation():g}, scale: 1:{canvas.scale():.0f}", + f"identify search radius: {search_radius:g} mm", + ] + ) + return lines + + +@_never_raises +def describe_processing(parameters: dict[str, Any], context: QgsProcessingContext) -> list[str]: + selected_only = sorted( + name + for name, value in parameters.items() + if isinstance(value, QgsProcessingFeatureSourceDefinition) and value.selectedFeaturesOnly + ) + options = {name: value for name, value in parameters.items() if isinstance(value, (bool, int, float))} + return [ + f"invalid features filtering: {_enum_name(context.invalidGeometryCheck())}", + f"selected features only: {', '.join(selected_only) or 'no'}", + f"options: {', '.join(f'{name}={value}' for name, value in sorted(options.items())) or 'defaults'}", + ] + + +def reference_near(reference: QgsGeometry, target: QgsGeometry, distance: float) -> QgsGeometry: + """ + The part of the reference around the target: its bounding box grown by 1.5 times its size or the distance + to the reference, whichever is larger, so the closest reference segments are well inside (not on the edge). + At least 1% of the reference's extent, so a click point still gets a useful stretch of reference. + """ + try: + box, extent = target.boundingBox(), reference.boundingBox() + margin = max( + 1.5 * max(box.width(), box.height(), distance), + 0.01 * max(extent.width(), extent.height()), + ) + clipped = reference.intersection(QgsGeometry.fromRect(box.buffered(margin))) + except Exception: # noqa: BLE001 + return reference + return reference if clipped.isNull() or clipped.isEmpty() else clipped + + +def add_reference_geometry( + op: Operation, reference: QgsGeometry, target: QgsGeometry, target_label: str, distance: float +) -> None: + """A small reference is added once as is; a large one is clipped around each target instead.""" + if reference.isNull(): + return + if reference.constGet().nCoordinates() <= MAX_WKT_VERTICES: + if not any(role == "reference" for role, _ in op.geometries): + op.add_geometry("reference", reference) + else: + op.add_geometry(f"reference near {target_label}", reference_near(reference, target, distance)) diff --git a/PolygonsParallelToLine/src/target.py b/PolygonsParallelToLine/src/target.py index 6365730..13ab671 100644 --- a/PolygonsParallelToLine/src/target.py +++ b/PolygonsParallelToLine/src/target.py @@ -47,7 +47,7 @@ def get_adjacent_segments(self, target_vertex: QgsPoint) -> tuple[Segment, Segme Segment(start=target_vertex, end=part_geom.vertexAt(next_vertex_idx)), ) - msg = f"Vertex {target_vertex} not found in target {self.feature.id()}" + msg = f"Closest vertex not found in the outline of target {self.feature.id()}" raise QgsProcessingException(msg) def rotate(self, angle: float) -> None: diff --git a/README.md b/README.md index 48c7918..13a970a 100644 --- a/README.md +++ b/README.md @@ -209,6 +209,19 @@ This project is licensed under the [GNU General Public License v2.0 or later](LI - 🐛 [Report Issues](https://github.com/elfpkck/parallelizer/issues) - 💬 [Discussion Forum](https://github.com/elfpkck/parallelizer/discussions) +### Reporting a problem + +When the plugin hits an unexpected error, a message with a **Report a problem…** button appears; you can also open it any time from **Vector › Parallelizer › Report a problem…**. It shows a report with: + +- the plugin, QGIS, Qt, Python, GEOS, PROJ and GDAL versions, your operating system, locale and screen scaling, and the other active plugins +- the map tool settings +- the last error and recent Parallelizer log lines +- what the failed (or last) operation worked on, captured when it ran: project and canvas settings (coordinate systems, canvas rotation, snapping, topological editing), the reference and target layers (geometry type, coordinate system, provider, feature counts), the features (geometry type, parts, vertices, validity) and the rotation math (azimuths, segment lengths, chosen angle) + +Layer names, file paths and coordinates are not included; paths to your home and QGIS profile folders are replaced automatically, and you can edit the text before sharing it. To help reproduce a problem, you can tick **Include the geometries involved**: this adds them as WKT moved so the first reference vertex is at 0 0, which keeps their shapes and relative positions but not their real location. **Nothing is sent automatically**: the report only leaves your computer if you use **Send report** (sends the text to the developer's private report service; no account needed), **Copy to clipboard**, **Open GitHub issue** (a pre-filled issue in your browser, created only if you submit it; GitHub issues are public) or **Send by email** (a pre-filled message to the developer in your own mail app, sent only if you send it). See the [privacy note](https://elfpkck.github.io/parallelizer/privacy.html). + +The plugin also keeps a small log without layer names, file paths or coordinates: in the **Log Messages** panel under the *Parallelizer* tab, and in `parallelizer/parallelizer.log` inside your QGIS profile folder (**Settings › User Profiles › Open Active Profile Folder**). + --- **Made with ❤️ for the QGIS community** diff --git a/site/privacy.md b/site/privacy.md new file mode 100644 index 0000000..b337e20 --- /dev/null +++ b/site/privacy.md @@ -0,0 +1,18 @@ +# Privacy note: problem reports + +Parallelizer never sends anything on its own. It keeps a small local log (no layer names, file paths or coordinates) and, when you choose **Report a problem…**, shows you a report you can read and edit before deciding what to do with it. + +## What a report contains + +Software versions (plugin, QGIS, Qt, Python, GEOS, PROJ, GDAL), your operating system, locale and screen scaling, other active plugins, the plugin's settings, the last error, a description of the layers, project and features the last few operations worked on (types, coordinate systems, counts, validity), recent plugin log lines and, if QGIS crashed or froze last time, the Python stacks it left. Paths to your home and QGIS profile folders are replaced. Geometries are only added if you tick **Include the geometries involved**, and then they are moved to a local origin so their real location is not included. You can edit or delete anything before sending. + +## Ways to share it + +- **Send report**: the text in the dialog is sent to the developer's report service, a [Cloudflare](https://www.cloudflare.com/privacypolicy/) Worker that files it as an issue in a private GitHub repository only the developer can see. Your IP address is used only to limit how many reports can be sent per minute; it is not stored or logged by the service. Cloudflare and GitHub process the data on the developer's behalf. +- **Open GitHub issue**: a pre-filled issue opens in your browser. It is only created if you submit it, under your GitHub account, and GitHub issues are public. +- **Send by email**: a pre-filled message opens in your own mail app and is only sent if you send it; the developer then receives your email address with it. +- **Copy to clipboard**: nothing is sent. + +## Retention and your rights + +Reports sent with **Send report** are deleted once the problem is handled, and at the latest after 12 months. The dialog shows a reference number after sending; to have a report deleted earlier, or to ask what was received, open an issue on [GitHub](https://github.com/elfpkck/parallelizer/issues) quoting the reference (don't include the report itself), or write to the email address shown in the plugin's report dialog. diff --git a/tests/conftest.py b/tests/conftest.py index 05c91f9..64d9c80 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,10 +5,19 @@ import pytest from qgis.PyQt.QtCore import QSettings +from PolygonsParallelToLine.src import diagnostics + if TYPE_CHECKING: from collections.abc import Iterator +@pytest.fixture(autouse=True) +def reset_diagnostics() -> Iterator[None]: + diagnostics.teardown_logging() + yield + diagnostics.teardown_logging() + + @pytest.fixture def isolate_settings() -> Iterator[None]: settings = QSettings() diff --git a/tests/test_algorithm.py b/tests/test_algorithm.py index 27d442b..4005a49 100644 --- a/tests/test_algorithm.py +++ b/tests/test_algorithm.py @@ -1,7 +1,10 @@ from __future__ import annotations import pytest +from qgis import processing +from qgis.core import QgsFeature, QgsGeometry, QgsProcessingContext, QgsProcessingException, QgsVectorLayer +from PolygonsParallelToLine.src import diagnostics, pptl from PolygonsParallelToLine.src.algorithm import Algorithm @@ -27,3 +30,37 @@ def test_algorithm_create_instance(algorithm_instance): """Test if the createInstance method creates a new instance of the algorithm.""" new_instance = algorithm_instance.createInstance() assert isinstance(new_instance, Algorithm) + + +def test_failed_processing_run_snapshots_the_failing_feature(qgis_processing, monkeypatch): + reference = QgsVectorLayer("LineString?crs=EPSG:3857", "ref", "memory") + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromWkt("LineString (0 50, 100 50)")) + reference.dataProvider().addFeatures([feature]) + target = QgsVectorLayer("Polygon?crs=EPSG:3857", "target", "memory") + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromWkt("Polygon ((40 60, 58 64, 54 82, 36 78, 40 60))")) + target.dataProvider().addFeatures([feature]) + + def broken(*_args: object, **kwargs: object) -> None: + trace = kwargs.get("trace") + if isinstance(trace, dict): + trace["reached"] = "before the failure" + msg = "boom" + raise ValueError(msg) + + monkeypatch.setattr(pptl, "compute_parallel_geometry", broken) + params = {"REFERENCE_LAYER": reference, "TARGET_LAYER": target, "OUTPUT": "TEMPORARY_OUTPUT"} + + with pytest.raises(QgsProcessingException): + processing.run(algOrName=Algorithm(), parameters=params, context=QgsProcessingContext()) + + report = diagnostics.build_report() + assert "Operation that failed\n failed: processing run (failed" in report + assert " Failed feature\n id: 1" in report + assert " reached: before the failure" in report + assert " invalid features filtering: " in report + block = diagnostics.geometry_block() or "" + # The first reference vertex is the origin, whatever order the geometries were recorded in. + assert "reference: LineString (0 0, 100 0)" in block + assert "target: Polygon ((40 10, " in block diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..9a07231 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path +from typing import NoReturn +from urllib.parse import parse_qs, unquote, urlsplit + +import pytest +from qgis.core import Qgis, QgsApplication, QgsGeometry, QgsPointXY, QgsProcessingException + +from PolygonsParallelToLine.src import contact, diagnostics +from PolygonsParallelToLine.src.const import PLUGIN_DIR +from PolygonsParallelToLine.src.settings import MapToolSettings + + +@pytest.fixture +def emissions(): + received: list[None] = [] + + def on_error() -> None: + received.append(None) + + diagnostics.notifier.error_recorded.connect(on_error) + yield received + diagnostics.notifier.error_recorded.disconnect(on_error) + + +def _query(url: str) -> dict[str, str]: + return {key: values[0] for key, values in parse_qs(urlsplit(url).query, keep_blank_values=True).items()} + + +def test_scrub_replaces_home_profile_and_plugin_paths(): + home = str(Path.home()) + profile = QgsApplication.qgisSettingsDirPath().rstrip("/") + text = f'File "{PLUGIN_DIR}/src/map_tool.py"\nFile "{profile}/x.py"\nFile "{home}/data/y.py"' + + scrubbed = diagnostics.scrub(text) + + assert 'File "/src/map_tool.py"' in scrubbed + assert 'File "~/data/y.py"' in scrubbed + assert str(PLUGIN_DIR) not in scrubbed + if profile != home: + assert 'File "/x.py"' in scrubbed + + +def test_scrub_handles_windows_separators(): + home_backslash = str(Path.home()).replace("/", "\\") + + assert diagnostics.scrub(f"{home_backslash}\\data\\y.py") == "~\\data\\y.py" + + +def test_scrub_replaces_coordinates_but_keeps_versions(): + text = "Self-intersection at or near point 523301.2 6720011.8; QGIS 3.40.1, GEOS 3.13.1-CAPI-1.19.2" + + scrubbed = diagnostics.scrub(text) + + assert "523301.2" not in scrubbed + assert "" in scrubbed + assert "QGIS 3.40.1, GEOS 3.13.1-CAPI-1.19.2" in scrubbed + + +def test_capture_errors_records_and_reraises(emissions): + @diagnostics.capture_errors + def broken() -> NoReturn: + msg = "boom" + raise ValueError(msg) + + with pytest.raises(ValueError, match="boom"): + broken() + + assert "ValueError: boom" in (diagnostics.last_error() or "") + assert diagnostics.issue_title() == "Bug: ValueError" + assert len(emissions) == 1 + + +def test_recorded_error_is_scrubbed_and_titled_by_type(tmp_path): + diagnostics.setup_logging(tmp_path) + + @diagnostics.capture_errors + def broken() -> NoReturn: + msg = f"first line\nnear {PLUGIN_DIR}: 523301.2 6720011.8" + raise ValueError(msg) + + with pytest.raises(ValueError, match="first line"): + broken() + + for handler in diagnostics.logger.handlers: + handler.flush() + assert diagnostics.issue_title() == "Bug: ValueError" + for text in (diagnostics.last_error() or "", (tmp_path / diagnostics.LOG_FILE_NAME).read_text(encoding="utf-8")): + assert str(PLUGIN_DIR) not in text + assert "523301.2" not in text + + +def test_capture_errors_records_nested_error_once(emissions): + @diagnostics.capture_errors + def inner() -> NoReturn: + msg = "boom" + raise RuntimeError(msg) + + @diagnostics.capture_errors + def outer() -> None: + inner() + + with pytest.raises(RuntimeError): + outer() + + assert len(emissions) == 1 + + +def test_capture_errors_passes_user_input_errors_through(): + @diagnostics.capture_errors + def user_error() -> NoReturn: + msg = "Target layer is empty" + raise diagnostics.UserInputError(msg) + + with pytest.raises(QgsProcessingException): + user_error() + + assert diagnostics.last_error() is None + assert diagnostics.issue_title() == "" + + +def test_capture_errors_records_internal_processing_exceptions(): + @diagnostics.capture_errors + def internal_error() -> NoReturn: + msg = "Closest vertex not found in the outline of target 7" + raise QgsProcessingException(msg) + + with pytest.raises(QgsProcessingException): + internal_error() + + assert "Closest vertex not found" in (diagnostics.last_error() or "") + + +def test_setup_logging_is_idempotent_and_writes_file(tmp_path): + diagnostics.setup_logging(tmp_path) + diagnostics.setup_logging(tmp_path) + diagnostics.logger.info("hello from test") + for handler in diagnostics.logger.handlers: + handler.flush() + + file_handlers = [h for h in diagnostics.logger.handlers if isinstance(h, RotatingFileHandler)] + assert len(file_handlers) == 1 + log_file = tmp_path / diagnostics.LOG_FILE_NAME + assert diagnostics.log_file_path() == log_file + content = log_file.read_text(encoding="utf-8") + assert "Session start" in content + assert "hello from test" in content + + diagnostics.teardown_logging() + assert diagnostics.logger.handlers == [] + assert diagnostics.log_file_path() is None + + +def test_qgis_log_gets_levels_but_not_session_start(tmp_path, monkeypatch): + received: list[tuple[str, str, object]] = [] + + class FakeMessageLog: + @staticmethod + def logMessage(message, tag, level) -> None: # noqa: N802 + received.append((message, tag, level)) + + monkeypatch.setattr(diagnostics, "QgsMessageLog", FakeMessageLog) + diagnostics.setup_logging(tmp_path) + diagnostics.logger.info("info line") + diagnostics.logger.warning("warning line") + + assert received == [ + ("info line", diagnostics.LOG_TAG, Qgis.MessageLevel.Info), + ("warning line", diagnostics.LOG_TAG, Qgis.MessageLevel.Warning), + ] + + +@pytest.mark.usefixtures("isolate_settings") +def test_build_report_contents(tmp_path): + diagnostics.setup_logging(tmp_path) + diagnostics.logger.info("marker log line") + + @diagnostics.capture_errors + def broken() -> NoReturn: + msg = "boom" + raise ValueError(msg) + + with pytest.raises(ValueError, match="boom"): + broken() + + report = diagnostics.build_report(MapToolSettings()) + + assert " QGIS: " in report + assert " by_longest: False" in report + assert "ValueError: boom" in report + assert "" in report + assert str(PLUGIN_DIR) not in report + assert report.index(diagnostics.LOG_SECTION) < report.index("marker log line") + + +def test_build_report_without_settings_or_errors(): + report = diagnostics.build_report() + + assert "Map tool settings" not in report + assert "Last error in this session\n none" in report + + +def test_issue_url_prefills_issue_form(): + url = diagnostics.issue_url("line one\nline two", "Bug: ValueError") + + parts = urlsplit(url) + assert f"{parts.scheme}://{parts.netloc}{parts.path}" == "https://github.com/elfpkck/parallelizer/issues/new" + assert _query(url) == { + "template": "bug_report.yml", + "title": "Bug: ValueError", + "diagnostics": "line one\nline two", + } + + +def test_issue_url_drops_oldest_log_lines_to_fit(): + log_lines = [f" log line {i:04d} " + "x" * 60 for i in range(500)] + report = "\n".join(["Environment", " QGIS: 4.2", "", diagnostics.LOG_SECTION, *log_lines]) + + url = diagnostics.issue_url(report) + + body = _query(url)["diagnostics"] + assert len(url) <= 7000 + assert body.startswith("Environment\n QGIS: 4.2") + assert diagnostics.TRUNCATED_NOTE in body + assert "log line 0000" not in body + assert "log line 0499" in body + + +def test_issue_url_truncates_report_without_log_section(): + url = diagnostics.issue_url("y" * 20000) + + assert len(url) <= 7000 + assert _query(url)["diagnostics"].endswith(diagnostics.TRUNCATED_NOTE) + + +def test_logger_does_not_propagate_to_root(tmp_path, caplog): + diagnostics.setup_logging(tmp_path) + with caplog.at_level(logging.INFO): + diagnostics.logger.info("stays in the plugin log") + + assert "stays in the plugin log" not in caplog.text + + +def test_operation_keeps_failed_and_last_snapshot(): + def failing_operation() -> None: + with diagnostics.operation("first") as op: + op.add("Section", ["line"]) + op.add_trace("Math")["delta"] = 12.5 + msg = "boom" + raise ValueError(msg) + + with pytest.raises(ValueError, match="boom"): + failing_operation() + with diagnostics.operation("second"): + pass + + report = diagnostics.build_report() + + assert "Operation that failed\n failed: first (failed in" in report + assert " delta: 12.5" in report + assert "Recent operations (newest first)\n #1: second (finished in" in report + assert diagnostics.current_operation().name == "untracked" + + +def test_report_lists_environment_extras(): + report = diagnostics.build_report() + + assert " Locale: " in report + assert " Screen: " in report + assert "Active plugins" in report + + +def test_geometry_block_moves_geometries_to_local_origin(): + with diagnostics.operation("rotate") as op: + op.add_geometry("reference", QgsGeometry.fromWkt("LineString (1000 2000, 1010 2000)")) + op.add_geometry("target 1", QgsGeometry.fromWkt("Polygon ((1000 2005, 1004 2005, 1004 2009, 1000 2005))")) + + block = diagnostics.geometry_block() or "" + + assert block.startswith(diagnostics.GEOMETRY_SECTION) + assert "reference: LineString (0 0, 10 0)" in block + assert "target 1: Polygon ((0 5, 4 5, 4 9, 0 5))" in block + assert "1000" not in block + + +def test_geometry_block_omits_huge_geometries_and_is_none_without_geometries(): + assert diagnostics.geometry_block() is None + huge = QgsGeometry.fromPolylineXY([QgsPointXY(i, 0) for i in range(1500)]) + with diagnostics.operation("rotate") as op: + op.add_geometry("reference", huge) + + assert "omitted, more than 1000 vertices" in (diagnostics.geometry_block() or "") + + +def test_keeps_last_operations_drops_idle_and_never_loses_the_failure(): + def failing() -> None: + with diagnostics.operation("broken"): + msg = "boom" + raise ValueError(msg) + + with pytest.raises(ValueError, match="boom"): + failing() + for number in range(7): + with diagnostics.operation(f"op {number}"): + pass + with diagnostics.operation("click on nothing", idle=True): + pass + + report = diagnostics.build_report() + + assert " failed: broken (failed" in report + assert " #1: op 6" in report + assert " #5: op 2" in report + assert "op 1" not in report + assert "click on nothing" not in report + + +def test_geometry_block_uses_one_origin_across_operations(): + with diagnostics.operation("set reference") as op: + op.add_geometry("reference", QgsGeometry.fromWkt("LineString (1000 2000, 1010 2000)")) + with diagnostics.operation("rotate") as op: + op.add_geometry("target 1", QgsGeometry.fromWkt("Point (1005 2003)")) + + block = diagnostics.geometry_block() or "" + + assert " #1: rotate\n target 1: Point (5 3)" in block + assert " #2: set reference\n reference: LineString (0 0, 10 0)" in block + + +def _write_dump(log_dir: Path, name: str, frame_file: str) -> None: + log_dir.mkdir(parents=True, exist_ok=True) + (log_dir / name).write_text( + f'Fatal Python error: Segmentation fault\n\nCurrent thread:\n File "{frame_file}", line 12 in rotate\n', + encoding="utf-8", + ) + + +def test_previous_crash_in_plugin_code_is_reported(tmp_path): + _write_dump(tmp_path, "crash.log", f"{PLUGIN_DIR}/src/map_tool.py") + + diagnostics.setup_logging(tmp_path) + report = diagnostics.build_report() + + assert (tmp_path / "crash-previous.log").exists() + assert (tmp_path / "crash.log").read_text() == "" + assert "Crash in the previous session (Python stacks)" in report + assert 'File "/src/map_tool.py", line 12 in rotate' in report + assert "closed unexpectedly" in (diagnostics.previous_session_problem() or "") + + +def test_previous_crash_elsewhere_is_only_noted(tmp_path): + _write_dump(tmp_path, "hang.log", "/usr/share/qgis/python/other.py") + + diagnostics.setup_logging(tmp_path) + report = diagnostics.build_report() + + assert "Slow or frozen operation in the previous session\n it happened outside Parallelizer code" in report + assert "other.py" not in report + assert diagnostics.previous_session_problem() is None + + +class FakeFaulthandler: + def __init__(self, *, enabled: bool) -> None: + self.enabled = enabled + self.calls: list[tuple] = [] + + def is_enabled(self) -> bool: + return self.enabled + + def enable(self, file, *, all_threads: bool) -> None: + self.calls.append(("enable", Path(file.name).name, all_threads)) + + def disable(self) -> None: + self.calls.append(("disable",)) + + def dump_traceback_later(self, timeout, *, repeat: bool, file) -> None: + self.calls.append(("arm", timeout, repeat, Path(file.name).name)) + + def cancel_dump_traceback_later(self) -> None: + self.calls.append(("cancel",)) + + +def test_faulthandler_is_enabled_into_crash_log_and_disabled_on_teardown(tmp_path, monkeypatch): + fake = FakeFaulthandler(enabled=False) + monkeypatch.setattr(diagnostics, "faulthandler", fake) + + diagnostics.setup_logging(tmp_path) + diagnostics.teardown_logging() + + assert fake.calls[0] == ("enable", "crash.log", True) + assert ("disable",) in fake.calls + + +def test_faulthandler_owned_by_someone_else_is_left_alone(tmp_path, monkeypatch): + fake = FakeFaulthandler(enabled=True) + monkeypatch.setattr(diagnostics, "faulthandler", fake) + + diagnostics.setup_logging(tmp_path) + diagnostics.teardown_logging() + + assert not any(call[0] in {"enable", "disable"} for call in fake.calls) + + +def test_watchdog_arms_hang_dump_only_when_asked(tmp_path, monkeypatch): + fake = FakeFaulthandler(enabled=True) + monkeypatch.setattr(diagnostics, "faulthandler", fake) + diagnostics.setup_logging(tmp_path) + + with diagnostics.operation("processing run"): + pass + assert fake.calls == [] + + with diagnostics.operation("map tool", watchdog=True): + pass + assert fake.calls == [("arm", diagnostics.HANG_TIMEOUT_S, False, "hang.log"), ("cancel",)] + + +@pytest.mark.usefixtures("isolate_settings") +def test_previous_qgis_crash_dump_is_reported_once(tmp_path, monkeypatch): + temp = tmp_path / "tmp" + temp.mkdir() + monkeypatch.setattr(diagnostics.tempfile, "gettempdir", lambda: str(temp)) + monkeypatch.setattr(diagnostics, "faulthandler", FakeFaulthandler(enabled=True)) + _write_dump(temp, "qgis-python-crash-info-4242", f"{PLUGIN_DIR}/src/map_tool.py") + (temp / "qgis-python-crash-info-4243").touch() # another instance still running: empty + + diagnostics.setup_logging(tmp_path / "logs") + first = diagnostics.build_report() + diagnostics.teardown_logging() + diagnostics.setup_logging(tmp_path / "logs") + + assert "Crash in the previous session (Python stacks)" in first + assert 'File "/src/map_tool.py", line 12 in rotate' in first + assert "Crash in the previous session" not in diagnostics.build_report() + + +def test_mailto_url_prefills_subject_and_body(monkeypatch): + monkeypatch.setattr(contact, "REPORT_EMAIL", "reports@example.org") + url = diagnostics.mailto_url("line one\nline two + more", "Bug: ValueError") + + fields = _query(url) + assert url.startswith("mailto:reports@example.org?") + assert fields["subject"] == "Parallelizer problem report: Bug: ValueError" + assert fields["body"].endswith("line one\r\nline two + more") + assert "+" not in url # RFC 6068: spaces are %20, a literal plus is %2B + + +def test_mailto_url_truncates_to_mail_client_limit(): + log_lines = [f" log line {i:04d} " + "x" * 60 for i in range(200)] + report = "\n".join(["Environment", " QGIS: 4.2", "", diagnostics.LOG_SECTION, *log_lines]) + + url = diagnostics.mailto_url(report) + + body = unquote(url.partition("&body=")[2]) + assert len(url) <= 1900 + assert diagnostics.TRUNCATED_NOTE in body + assert "log line 0199" in body + assert "log line 0000" not in body diff --git a/tests/test_map_tool.py b/tests/test_map_tool.py index 3b0bb12..06ffa10 100644 --- a/tests/test_map_tool.py +++ b/tests/test_map_tool.py @@ -1,10 +1,13 @@ from __future__ import annotations +from typing import NoReturn + import pytest from qgis.core import QgsFeature, QgsGeometry, QgsProject, QgsRectangle, QgsVectorLayer from qgis.gui import QgsMapMouseEvent from qgis.PyQt.QtCore import QEvent, QPoint, Qt # type: ignore[import-not-found] +from PolygonsParallelToLine.src import diagnostics from PolygonsParallelToLine.src.map_tool import ParallelToLineMapTool, _pick_segment_in_rect from PolygonsParallelToLine.src.settings import MapToolSettings @@ -85,3 +88,71 @@ def test_single_click_sets_reference(qgis_app, qgis_iface, qgis_canvas, qgis_new finally: # The canvas is session-scoped: deactivate the tool so its rubber band doesn't outlive the test. qgis_canvas.unsetMapTool(tool) + + +def test_event_handler_error_is_recorded_and_reraised(qgis_app, qgis_iface, qgis_canvas, monkeypatch): + tool = ParallelToLineMapTool(qgis_iface, MapToolSettings()) + + def broken_click(_event) -> NoReturn: + msg = "boom" + raise RuntimeError(msg) + + monkeypatch.setattr(tool, "_handle_single_click", broken_click) + left = Qt.MouseButton.LeftButton + event = QgsMapMouseEvent(qgis_canvas, QEvent.Type.MouseButtonRelease, QPoint(10, 10), left, left) + + with pytest.raises(RuntimeError, match="boom"): + tool.canvasReleaseEvent(event) + + assert "RuntimeError: boom" in (diagnostics.last_error() or "") + + +@pytest.mark.usefixtures("isolate_settings") +def test_click_rotation_snapshot_describes_layers_features_and_math( + qgis_app, qgis_iface, qgis_canvas, qgis_new_project +): + reference = QgsVectorLayer("LineString?crs=EPSG:3857", "ref", "memory") + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromWkt("LineString (0 50, 100 50)")) + reference.dataProvider().addFeatures([feature]) + target = QgsVectorLayer("Polygon?crs=EPSG:3857", "private houses", "memory") + feature = QgsFeature() + feature.setGeometry(QgsGeometry.fromWkt("Polygon ((70 70, 88 74, 84 92, 66 88, 70 70))")) + target.dataProvider().addFeatures([feature]) + target.startEditing() + QgsProject.instance().addMapLayers([reference, target]) + qgis_canvas.setDestinationCrs(reference.crs()) + # Target on top: the offscreen canvas is small, so the identify radius spans much of the map. + qgis_canvas.setLayers([target, reference]) + qgis_canvas.setExtent(QgsRectangle(0, 0, 100, 100)) + qgis_canvas.refresh() + + tool = ParallelToLineMapTool(qgis_iface, MapToolSettings()) + qgis_canvas.setMapTool(tool) + left = Qt.MouseButton.LeftButton + + def click(x: float, y: float) -> None: + pixel = qgis_canvas.getCoordinateTransform().transform(x, y) + pos = QPoint(round(pixel.x()), round(pixel.y())) + tool.canvasPressEvent(QgsMapMouseEvent(qgis_canvas, QEvent.Type.MouseButtonPress, pos, left, left)) + tool.canvasReleaseEvent(QgsMapMouseEvent(qgis_canvas, QEvent.Type.MouseButtonRelease, pos, left, left)) + + try: + click(5, 50) + click(77, 81) + finally: + qgis_canvas.unsetMapTool(tool) + target.rollBack() + + report = diagnostics.build_report() + assert "Recent operations (newest first)\n #1: map tool: rotate (click) (finished" in report + assert " #2: map tool: set reference (click)" in report + assert " canvas rotation: 0" in report + assert " Target layer\n geometry type: Polygon" in report + assert " Reference geometry (set earlier)\n geometry: LineString" in report + assert " outcome: rotated" in report + assert "private houses" not in report + block = diagnostics.geometry_block() or "" + assert "reference: LineString (0 0, 100 0)" in block + # One click per operation; exact values depend on the test canvas's pixel rounding. + assert block.count(" click: Point (") == 2 diff --git a/tests/test_parallelizer.py b/tests/test_parallelizer.py index 5ab4254..2b6ee68 100644 --- a/tests/test_parallelizer.py +++ b/tests/test_parallelizer.py @@ -121,3 +121,36 @@ def test_polygon_target_with_explicit_segment_rotates_around_centroid(qgis_app): assert result is not None assert result.centroid().asPoint().distance(target.centroid().asPoint()) < 1e-6 + + +def test_trace_records_line_math(): + trace: dict = {} + + compute_parallel_geometry( + QgsGeometry.fromWkt(REFERENCE_HORIZONTAL), + QgsGeometry.fromWkt("LineString (0 10, 10 20)"), + "line", + by_longest=False, + trace=trace, + ) + + assert trace["strategy"] == "line" + assert trace["target_segment_pick"] == "smallest angle" + assert trace["outcome"] == "rotated" + assert math.isclose(abs(trace["delta"]), 45.0) + + +def test_trace_records_polygon_math(): + trace: dict = {} + + compute_parallel_geometry( + QgsGeometry.fromWkt(REFERENCE_HORIZONTAL), + QgsGeometry.fromWkt("Polygon ((40 10, 50 20, 40 30, 30 20, 40 10))"), + "polygon", + by_longest=False, + trace=trace, + ) + + assert trace["strategy"] == "polygon" + assert {"prev_delta", "next_delta", "applied_delta"} <= trace.keys() + assert trace["outcome"] == "rotated" diff --git a/tests/test_plugin.py b/tests/test_plugin.py index edce894..5f824dd 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -1,8 +1,14 @@ from __future__ import annotations +import threading +from unittest.mock import Mock + import pytest from qgis.core import QgsApplication +from qgis.gui import QgsMessageBar +from qgis.PyQt.QtCore import QCoreApplication # type: ignore[import-not-found] +from PolygonsParallelToLine.src import diagnostics from PolygonsParallelToLine.src.plugin import Plugin from PolygonsParallelToLine.src.provider import Provider @@ -14,7 +20,10 @@ def plugin_instance(): Cleaning up processing providers registered within the QgsApplication's processing registry. """ yield Plugin() + _remove_providers() + +def _remove_providers() -> None: for provider in QgsApplication.processingRegistry().providers(): QgsApplication.processingRegistry().removeProvider(provider) @@ -41,5 +50,77 @@ def test_init_gui(plugin_instance): def test_unload(plugin_instance): """Test if unload removes the provider from the processing registry.""" plugin_instance.initProcessing() + provider = plugin_instance.provider plugin_instance.unload() - assert plugin_instance.provider not in QgsApplication.processingRegistry().providers() + assert plugin_instance.provider is None + assert provider not in QgsApplication.processingRegistry().providers() + + +def _patch_iface(qgis_iface, monkeypatch) -> None: + # pytest-qgis's mock iface lacks the plugin menu APIs, and its message bar can't show widgets. + monkeypatch.setattr(qgis_iface, "addPluginToVectorMenu", Mock(), raising=False) + monkeypatch.setattr(qgis_iface, "removePluginVectorMenu", Mock(), raising=False) + bar = QgsMessageBar() + monkeypatch.setattr(qgis_iface, "messageBar", lambda: bar) + + +@pytest.fixture +def gui_plugin(qgis_iface, isolate_settings, monkeypatch): + _patch_iface(qgis_iface, monkeypatch) + plugin = Plugin(qgis_iface) + plugin.initGui() + yield plugin + plugin.unload() + _remove_providers() + + +def test_init_gui_adds_report_action_and_logging(gui_plugin): + gui_plugin.iface.addPluginToVectorMenu.assert_any_call("Parallelizer", gui_plugin.report_action) + assert diagnostics.logger.handlers + + +def test_unload_removes_report_action_and_logging(gui_plugin): + report_action = gui_plugin.report_action + + gui_plugin.unload() + + gui_plugin.iface.removePluginVectorMenu.assert_any_call("Parallelizer", report_action) + assert gui_plugin.report_action is None + assert diagnostics.logger.handlers == [] + + +def test_recorded_error_offers_report_once_per_interval(gui_plugin): + bar = gui_plugin.iface.messageBar() + + diagnostics.notifier.error_recorded.emit() + diagnostics.notifier.error_recorded.emit() + QCoreApplication.processEvents() + + assert len(bar.items()) == 1 + + +def test_error_from_worker_thread_is_offered_on_main_thread(gui_plugin): + bar = gui_plugin.iface.messageBar() + + worker = threading.Thread(target=diagnostics.notifier.error_recorded.emit) + worker.start() + worker.join() + assert bar.items() == [] + + QCoreApplication.processEvents() + assert len(bar.items()) == 1 + + +@pytest.mark.usefixtures("isolate_settings") +def test_previous_session_crash_is_offered_at_startup(qgis_iface, monkeypatch): + _patch_iface(qgis_iface, monkeypatch) + monkeypatch.setattr(diagnostics, "previous_session_problem", lambda: "QGIS closed unexpectedly last time") + plugin = Plugin(qgis_iface) + plugin.initGui() + try: + items = qgis_iface.messageBar().items() + assert len(items) == 1 + assert "closed unexpectedly" in items[0].text() + finally: + plugin.unload() + _remove_providers() diff --git a/tests/test_report_dialog.py b/tests/test_report_dialog.py new file mode 100644 index 0000000..9c3bbd1 --- /dev/null +++ b/tests/test_report_dialog.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from urllib.parse import parse_qs, urlsplit + +import pytest +from qgis.PyQt.QtWidgets import QApplication, QLabel, QPushButton # type: ignore[import-not-found] + +from PolygonsParallelToLine.src import contact, report_dialog +from PolygonsParallelToLine.src.diagnostics import GEOMETRY_SECTION, LOG_SECTION +from PolygonsParallelToLine.src.report_dialog import ReportDialog + + +@pytest.fixture +def opened_urls(monkeypatch) -> list[str]: + """URLs the dialog asks the desktop to open, fully encoded; nothing is really opened.""" + opened: list[str] = [] + + class FakeDesktopServices: + @staticmethod + def openUrl(url) -> bool: # noqa: N802 + opened.append(bytes(url.toEncoded()).decode()) + return True + + monkeypatch.setattr(report_dialog, "QDesktopServices", FakeDesktopServices) + return opened + + +def test_copy_uses_edited_text(qgis_app): + dialog = ReportDialog("original report", "Bug: ValueError") + dialog.text_edit.setPlainText("edited report") + + dialog.copy_to_clipboard() + + assert QApplication.clipboard().text() == "edited report" + + +def test_open_issue_builds_url_from_edited_text(qgis_app, opened_urls): + dialog = ReportDialog("original report", "Bug: ValueError") + dialog.text_edit.setPlainText("edited report") + + dialog.open_issue() + + assert len(opened_urls) == 1 + query = parse_qs(urlsplit(opened_urls[0]).query) + assert query["diagnostics"] == ["edited report"] + assert query["title"] == ["Bug: ValueError"] + assert QApplication.clipboard().text() == "edited report" + + +def test_geometry_checkbox_inserts_block_before_log_lines_and_removes_it(qgis_app): + report = f"Environment\n QGIS: 4\n\n{LOG_SECTION}\n log line" + dialog = ReportDialog(report, geometries=lambda: f"{GEOMETRY_SECTION}\n reference: LineString (0 0, 10 0)") + + dialog.geometry_checkbox.setChecked(True) + with_geometries = dialog.report_text() + dialog.geometry_checkbox.setChecked(False) + + assert with_geometries.index(GEOMETRY_SECTION) < with_geometries.index(LOG_SECTION) + assert dialog.report_text() == report + + +def test_geometry_checkbox_disabled_without_geometries(qgis_app): + dialog = ReportDialog("report") + + assert not dialog.geometry_checkbox.isEnabled() + assert not dialog.geometry_checkbox.isChecked() + + +EMAIL = "reports@example.org" + + +def test_send_email_opens_mailto_from_edited_text_and_copies_full_report(qgis_app, monkeypatch, opened_urls): + monkeypatch.setattr(contact, "REPORT_EMAIL", EMAIL) + dialog = ReportDialog("original report", "Bug: ValueError") + dialog.text_edit.setPlainText("edited report") + + dialog.send_email() + + assert len(opened_urls) == 1 + assert opened_urls[0].startswith(f"mailto:{EMAIL}?subject=") + assert "edited%20report" in opened_urls[0] + assert QApplication.clipboard().text() == "edited report" + + +def _buttons_and_intro(dialog: ReportDialog) -> tuple[list[str], str]: + buttons = [button.text() for button in dialog.findChildren(QPushButton) if not button.isHidden()] + return buttons, dialog.findChildren(QLabel)[0].text() + + +def test_dialog_offers_email_and_says_where_to_send_when_address_is_set(qgis_app, monkeypatch): + monkeypatch.setattr(contact, "REPORT_EMAIL", EMAIL) + + buttons, intro = _buttons_and_intro(ReportDialog("report")) + + assert "Send by email…" in buttons + assert EMAIL in intro + assert "https://github.com/elfpkck/parallelizer/issues" in intro + + +def test_dialog_hides_email_when_address_is_not_set(qgis_app, monkeypatch): + monkeypatch.setattr(contact, "REPORT_EMAIL", "") + + buttons, intro = _buttons_and_intro(ReportDialog("report")) + + assert "Send by email…" not in buttons + assert "email" not in intro + assert "https://github.com/elfpkck/parallelizer/issues" in intro + + +class FakeSender: + def __init__(self) -> None: + self.sent: list[tuple[str, str]] = [] + + def send(self, report: str, title: str = "") -> None: + self.sent.append((report, title)) + + +def test_send_report_button_only_with_an_endpoint(qgis_app, monkeypatch): + monkeypatch.setattr(contact, "REPORT_ENDPOINT", "") + buttons, intro = _buttons_and_intro(ReportDialog("report")) + assert "Send report" not in buttons + assert "Cloudflare" not in intro + + monkeypatch.setattr(contact, "REPORT_ENDPOINT", "https://reports.example.org/") + buttons, intro = _buttons_and_intro(ReportDialog("report")) + assert "Send report" in buttons + assert "not stored" in intro + assert "https://elfpkck.github.io/parallelizer/privacy.html" in intro + + +def test_send_report_sends_edited_text_and_stays_disabled_after_success(qgis_app, monkeypatch): + monkeypatch.setattr(contact, "REPORT_ENDPOINT", "https://reports.example.org/") + dialog = ReportDialog("original report", "Bug: ValueError") + fake = FakeSender() + dialog._sender = fake # noqa: SLF001 + dialog.text_edit.setPlainText("edited report") + + dialog.send_report() + + assert fake.sent == [("edited report", "Bug: ValueError")] + assert not dialog.send_button.isEnabled() + assert QApplication.clipboard().text() == "edited report" + dialog._on_sent(True, "Sent, thank you. Reference: 7") # noqa: SLF001, FBT003 + assert not dialog.send_button.isEnabled() + assert dialog.status_label.text() == "Sent, thank you. Reference: 7" + + +def test_failed_send_explains_fallbacks_and_allows_retry(qgis_app, monkeypatch): + monkeypatch.setattr(contact, "REPORT_ENDPOINT", "https://reports.example.org/") + dialog = ReportDialog("report") + dialog._sender = FakeSender() # noqa: SLF001 + + dialog.send_report() + dialog._on_sent(False, "Not sent: the report service could not be reached (timeout).") # noqa: SLF001, FBT003 + + assert dialog.send_button.isEnabled() + assert "on your clipboard" in dialog.status_label.text() diff --git a/tests/test_report_sender.py b/tests/test_report_sender.py new file mode 100644 index 0000000..7f3fc36 --- /dev/null +++ b/tests/test_report_sender.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, HTTPServer +import json +import socket +import threading +from typing import TYPE_CHECKING, ClassVar + +import pytest +from qgis.PyQt.QtTest import QSignalSpy # type: ignore[import-not-found] + +from PolygonsParallelToLine.src import contact, diagnostics, report_sender +from PolygonsParallelToLine.src.report_sender import ReportSender, build_payload + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class _Receiver(BaseHTTPRequestHandler): + status = 201 + body = b'{"id": 42}' + received: ClassVar[list[tuple[str, dict]]] = [] + + def do_POST(self) -> None: + length = int(self.headers["Content-Length"]) + type(self).received.append((self.headers["Content-Type"], json.loads(self.rfile.read(length)))) + self.send_response(type(self).status) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(type(self).body) + + def log_message(self, *_args: object) -> None: + pass + + +@pytest.fixture +def receiver(monkeypatch) -> Iterator[type[_Receiver]]: + _Receiver.status, _Receiver.body, _Receiver.received = 201, b'{"id": 42}', [] + server = HTTPServer(("127.0.0.1", 0), _Receiver) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + monkeypatch.setattr(contact, "REPORT_ENDPOINT", f"http://127.0.0.1:{server.server_port}/") + yield _Receiver + server.shutdown() + server.server_close() + + +def _send(report: str, title: str = "") -> tuple[bool, str]: + sender = ReportSender() + spy = QSignalSpy(sender.finished) + sender.send(report, title) + assert spy.wait(10_000), "the sender never finished" + ok, message = spy[0] + return ok, message + + +def test_payload_has_schema_versions_and_report(): + payload = json.loads(build_payload("the report", "Bug: ValueError")) + + assert payload["schema"] == 1 + assert payload["title"] == "Bug: ValueError" + assert payload["report"] == "the report" + assert payload["qgis_version"] + assert payload["plugin_version"] + + +def test_payload_caps_the_report_dropping_oldest_log_lines(): + log_lines = [f" log line {i:05d} " + "x" * 100 for i in range(2000)] + report = "\n".join(["Environment", "", diagnostics.LOG_SECTION, *log_lines]) + + fitted = json.loads(build_payload(report))["report"] + + assert len(fitted.encode("utf-8")) <= report_sender.MAX_REPORT_BYTES + assert diagnostics.TRUNCATED_NOTE in fitted + assert "log line 01999" in fitted + assert "log line 00000" not in fitted + + +def test_send_posts_json_and_reports_the_reference(qgis_app, receiver): + ok, message = _send("the report", "Bug: ValueError") + + assert ok + assert message == "Sent, thank you. Reference: 42" + content_type, payload = receiver.received[0] + assert content_type == "application/json" + assert payload["report"] == "the report" + + +@pytest.mark.parametrize( + ("status", "expected"), + [(429, "too many reports"), (500, "status 500")], +) +def test_send_failure_statuses(qgis_app, receiver, status, expected): + receiver.status, receiver.body = status, b'{"error": "nope"}' + + ok, message = _send("the report") + + assert not ok + assert expected in message + + +def test_send_unreachable_service(qgis_app, monkeypatch): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + monkeypatch.setattr(contact, "REPORT_ENDPOINT", f"http://127.0.0.1:{port}/") + + ok, message = _send("the report") + + assert not ok + assert "could not be reached" in message diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py new file mode 100644 index 0000000..6500e5f --- /dev/null +++ b/tests/test_snapshot.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from qgis.core import ( + QgsCoordinateReferenceSystem, + QgsGeometry, + QgsPointXY, + QgsProcessingContext, + QgsProcessingFeatureSourceDefinition, + QgsVectorLayer, +) + +from PolygonsParallelToLine.src import diagnostics, snapshot + + +def test_describe_geometry_counts_parts_rings_and_vertices(): + geom = QgsGeometry.fromWkt( + "MultiPolygon (((0 0, 10 0, 10 10, 0 10, 0 0), (2 2, 3 2, 3 3, 2 3, 2 2)), ((20 0, 30 0, 30 10, 20 0)))" + ) + + lines = snapshot.describe_geometry(geom) + + assert "geometry: MultiPolygon, curved: False" in lines + assert "parts: 2, rings: 3, vertices: 14" in lines + assert "GEOS valid: True" in lines + + +def test_describe_geometry_masks_numbers_in_validity_problems(): + bowtie = QgsGeometry.fromWkt("Polygon ((0 0, 10 10, 10 0, 0 10, 0 0))") + + lines = snapshot.describe_geometry(bowtie) + + assert "GEOS valid: False" in lines + problems = next(line for line in lines if line.startswith("validity problems")) + assert not any(char.isdigit() for char in problems) + + +def test_describe_geometry_null_and_failure(): + assert snapshot.describe_geometry(QgsGeometry()) == ["geometry: null or empty"] + assert snapshot.describe_geometry(None) == ["unavailable (AttributeError)"] + + +def test_describe_layer_has_no_name(): + layer = QgsVectorLayer("Polygon?crs=EPSG:4326&field=_rotated:boolean", "secret layer name", "memory") + + lines = snapshot.describe_layer(layer) + + assert "CRS: EPSG:4326 (geographic, degrees)" in lines + assert "provider: memory, storage: Memory storage" in lines + assert "has _rotated: True" in lines[-1] + assert not any("secret" in line for line in lines) + + +def test_describe_crs_custom_uses_proj_definition(): + crs = QgsCoordinateReferenceSystem.fromProj("+proj=tmerc +lon_0=17 +ellps=GRS80 +units=m") + + assert snapshot.describe_crs(crs).startswith("+proj=tmerc") + assert snapshot.describe_crs(QgsCoordinateReferenceSystem()) == "none" + + +def test_describe_project_with_canvas(qgis_canvas, qgis_new_project): + qgis_canvas.setRotation(15) + try: + lines = snapshot.describe_project(qgis_canvas) + finally: + qgis_canvas.setRotation(0) + + assert any(line.startswith("topological editing: False") for line in lines) + assert any(line.startswith("canvas rotation: 15") for line in lines) + + +def test_describe_processing_lists_selected_only_and_options(): + parameters = { + "TARGET_LAYER": QgsProcessingFeatureSourceDefinition("layer-id", selectedFeaturesOnly=True), + "LONGEST": True, + "DISTANCE": 5.0, + "OUTPUT": "TEMPORARY_OUTPUT", + } + + lines = snapshot.describe_processing(parameters, QgsProcessingContext()) + + assert "selected features only: TARGET_LAYER" in lines + assert "options: DISTANCE=5.0, LONGEST=True" in lines + + +def test_reference_near_clips_a_large_reference_around_the_target(): + reference = QgsGeometry.fromPolylineXY([QgsPointXY(x, 0) for x in range(5000)]) + target = QgsGeometry.fromWkt("Polygon ((2500 10, 2510 10, 2510 20, 2500 20, 2500 10))") + + near = snapshot.reference_near(reference, target, reference.distance(target)) + + assert near.constGet().nCoordinates() < 200 + assert near.distance(target) == reference.distance(target) + + +def test_add_reference_geometry_small_once_large_clipped(): + target = QgsGeometry.fromWkt("Point (2505 15)") + small = QgsGeometry.fromWkt("LineString (0 0, 5000 0)") + large = QgsGeometry.fromPolylineXY([QgsPointXY(x, 0) for x in range(5000)]) + with diagnostics.operation("rotate") as op: + snapshot.add_reference_geometry(op, small, target, "target 1", 15.0) + snapshot.add_reference_geometry(op, small, target, "target 2", 15.0) + snapshot.add_reference_geometry(op, large, target, "target 3", 15.0) + + assert [role for role, _ in op.geometries] == ["reference", "reference near target 3"] + + +def test_reference_near_a_point_keeps_a_useful_stretch(): + reference = QgsGeometry.fromPolylineXY([QgsPointXY(x, 0) for x in range(5000)]) + + near = snapshot.reference_near(reference, QgsGeometry.fromWkt("Point (2500 0.5)"), 0.5) + + assert near.length() >= 90 + assert near.constGet().nCoordinates() < 200 diff --git a/tools/report_worker/.gitignore b/tools/report_worker/.gitignore new file mode 100644 index 0000000..b75a0fa --- /dev/null +++ b/tools/report_worker/.gitignore @@ -0,0 +1 @@ +.wrangler/ diff --git a/tools/report_worker/README.md b/tools/report_worker/README.md new file mode 100644 index 0000000..cfaa7ae --- /dev/null +++ b/tools/report_worker/README.md @@ -0,0 +1,39 @@ +# Report receiver (Cloudflare Worker) + +Receives the reports users send with **Send report** in the plugin's report dialog and files each one as an issue in a **private** GitHub repository. The plugin only knows this Worker's URL; the GitHub token lives here as a Worker secret. Runs on the Cloudflare Workers free plan (100,000 requests/day). + +What it does with a request: + +- accepts only `POST` with `application/json`, at most 64 KB, schema 1, a non-empty `report`; +- limits each client to 5 reports per minute, keyed by a hash of the IP address (the IP itself is never stored or logged; Worker logs are off in `wrangler.toml`); +- creates an issue titled `[report] ` with the plugin/QGIS versions and the report in a code fence (so `@mentions` and `#links` in it stay inert), and answers `201 {"id": <issue number>}`, which the user sees as their reference. + +## One-time setup + +1. **Private repository** for the reports, e.g. `elfpkck/parallelizer-reports`. +2. **Token**: a fine-grained personal access token (GitHub › Settings › Developer settings) with access to that repository only and **Issues: Read and write**. Nothing else. +3. **Cloudflare account** (free). Then, from this folder: + ```shell + npx wrangler login + npx wrangler secret put GITHUB_TOKEN # paste the token + npx wrangler deploy --var GITHUB_REPO:elfpkck/parallelizer-reports + ``` + `deploy` prints the Worker URL, e.g. `https://parallelizer-reports.<account>.workers.dev`. + If it fails on the `[[ratelimits]]` block (the rate-limiting binding isn't available on every plan), remove that block and add a rate-limiting rule for the Worker's route in the Cloudflare dashboard (Security › WAF; free plans get one rule) instead. The Worker runs without the binding. +4. **Plugin**: add the URL as the `REPORT_ENDPOINT` repository secret of the plugin repo (Settings › Secrets and variables › Actions). The release workflow writes it into the package; see `DEVELOPMENT.md`. + +## Local testing + +```shell +node --test # unit tests, no dependencies +npm run dev # wrangler dev with DRY_RUN=1: answers 201 {"id": 0} without calling GitHub +``` + +To try it from QGIS, point a local build at it by setting `REPORT_ENDPOINT` in `PolygonsParallelToLine/src/contact.py` (don't commit that). + +## Operating it + +- **Retention**: close and delete report issues once the problem is handled, and at the latest after 12 months (this is what the privacy note promises). +- **Deletion requests** come with the report reference (the issue number): delete that issue. +- **Rotating the token**: create a new one, `npx wrangler secret put GITHUB_TOKEN`, revoke the old one. The plugin needs no change. +- **Spam**: lower the limit in `wrangler.toml`, or add stricter WAF rules. If the URL itself gets abused, deploy under a new name and publish a plugin release with the new `REPORT_ENDPOINT`. diff --git a/tools/report_worker/package.json b/tools/report_worker/package.json new file mode 100644 index 0000000..cd3e294 --- /dev/null +++ b/tools/report_worker/package.json @@ -0,0 +1,10 @@ +{ + "name": "parallelizer-report-worker", + "private": true, + "type": "module", + "scripts": { + "test": "node --test", + "dev": "npx wrangler dev --var DRY_RUN:1", + "deploy": "npx wrangler deploy" + } +} diff --git a/tools/report_worker/src/index.js b/tools/report_worker/src/index.js new file mode 100644 index 0000000..f9ebb7d --- /dev/null +++ b/tools/report_worker/src/index.js @@ -0,0 +1,65 @@ +// Receives one-click problem reports from the Parallelizer QGIS plugin and files each as an issue in a private +// GitHub repository. The GitHub token lives only here (a Worker secret), never in the plugin. +// Nothing about the sender is stored: the IP address is only hashed into a short-lived rate-limit key. + +import { MAX_BODY_BYTES, buildIssue, validate } from "./report.js"; + +function json(status, body) { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +async function rateLimitKey(request) { + const ip = request.headers.get("cf-connecting-ip") || "unknown"; + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip)); + return [...new Uint8Array(digest)].slice(0, 12).map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} + +export default { + async fetch(request, env) { + if (request.method !== "POST") return json(405, { error: "use POST" }); + if (!(request.headers.get("content-type") || "").startsWith("application/json")) { + return json(415, { error: "send application/json" }); + } + // Before reading the body: rejected clients cost no more than their headers. + // Optional: the binding may not exist on every plan (see README). + if (env.REPORT_LIMITER) { + const { success } = await env.REPORT_LIMITER.limit({ key: await rateLimitKey(request) }); + if (!success) return json(429, { error: "too many reports, try again later" }); + } + const tooLarge = () => json(413, { error: "report too large" }); + if (Number(request.headers.get("content-length") || 0) > MAX_BODY_BYTES) return tooLarge(); + const bytes = await request.arrayBuffer(); + if (bytes.byteLength > MAX_BODY_BYTES) return tooLarge(); + const raw = new TextDecoder().decode(bytes); + + let payload; + try { + payload = JSON.parse(raw); + } catch { + return json(400, { error: "body is not valid JSON" }); + } + const problem = validate(payload); + if (problem) return json(400, { error: problem }); + + if (env.DRY_RUN === "1") return json(201, { id: 0 }); + + const response = await fetch(`https://api.github.com/repos/${env.GITHUB_REPO}/issues`, { + method: "POST", + headers: { + authorization: `Bearer ${env.GITHUB_TOKEN}`, + accept: "application/vnd.github+json", + "content-type": "application/json", + "user-agent": "parallelizer-report-worker", + "x-github-api-version": "2022-11-28", + }, + body: JSON.stringify(buildIssue(payload)), + }); + if (!response.ok) { + // Status only: never log the report or anything about the sender. + console.error(`GitHub API answered ${response.status}`); + return json(502, { error: "could not file the report" }); + } + const issue = await response.json(); + return json(201, { id: issue.number }); + }, +}; diff --git a/tools/report_worker/src/report.js b/tools/report_worker/src/report.js new file mode 100644 index 0000000..979330b --- /dev/null +++ b/tools/report_worker/src/report.js @@ -0,0 +1,38 @@ +// Validation and issue building, kept apart from index.js: a Worker's entry module may only export handlers. + +export const SCHEMA = 1; +export const MAX_BODY_BYTES = 64 * 1024; +const MAX_TITLE = 120; +const MAX_VERSION = 60; + +export function validate(payload) { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return "body must be a JSON object"; + if (payload.schema !== SCHEMA) return `schema must be ${SCHEMA}`; + if (typeof payload.report !== "string" || !payload.report.trim()) return "report must be a non-empty string"; + for (const key of ["title", "plugin_version", "qgis_version"]) { + if (payload[key] !== undefined && typeof payload[key] !== "string") return `${key} must be a string`; + } + return null; +} + +function printable(text, max) { + return (text || "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, max); +} + +export function buildIssue(payload) { + // A fence longer than any backtick run in the report, so the report can't close it early. Inside a fence, + // GitHub doesn't turn "@name" or "#123" into mentions or links. + const longestRun = Math.max(0, ...(payload.report.match(/`+/g) || []).map((run) => run.length)); + const fence = "`".repeat(Math.max(3, longestRun + 1)); + return { + title: `[report] ${printable(payload.title, MAX_TITLE) || "Problem report"}`, + body: [ + `Plugin: ${printable(payload.plugin_version, MAX_VERSION) || "unknown"}`, + `QGIS: ${printable(payload.qgis_version, MAX_VERSION) || "unknown"}`, + "", + `${fence}text`, + payload.report, + fence, + ].join("\n"), + }; +} diff --git a/tools/report_worker/test/index.test.js b/tools/report_worker/test/index.test.js new file mode 100644 index 0000000..6e2da4d --- /dev/null +++ b/tools/report_worker/test/index.test.js @@ -0,0 +1,94 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "node:test"; + +import worker from "../src/index.js"; +import { MAX_BODY_BYTES, buildIssue, validate } from "../src/report.js"; + +const valid = { schema: 1, title: "Bug: ValueError", plugin_version: "2.2.0", qgis_version: "4.2.2", report: "text" }; + +function post(body, headers = {}) { + return new Request("https://reports.example.org/", { + method: "POST", + headers: { "content-type": "application/json", "cf-connecting-ip": "203.0.113.7", ...headers }, + body: typeof body === "string" ? body : JSON.stringify(body), + }); +} + +describe("validate", () => { + it("accepts a valid report", () => assert.equal(validate(valid), null)); + it("rejects a wrong schema", () => assert.match(validate({ ...valid, schema: 2 }), /schema/)); + it("rejects an empty report", () => assert.match(validate({ ...valid, report: " " }), /report/)); + it("rejects non-string fields", () => assert.match(validate({ ...valid, title: 5 }), /title/)); + it("rejects arrays", () => assert.match(validate([valid]), /object/)); +}); + +describe("buildIssue", () => { + it("prefixes and cleans the title", () => { + assert.equal(buildIssue({ ...valid, title: "a\nb" }).title, "[report] a b"); + assert.equal(buildIssue({ ...valid, title: "" }).title, "[report] Problem report"); + assert.equal(buildIssue({ ...valid, title: "x".repeat(500) }).title.length, "[report] ".length + 120); + }); + + it("fences the report longer than any backtick run inside it", () => { + const body = buildIssue({ ...valid, report: "before ```` after" }).body; + assert.ok(body.includes("`````text\nbefore ```` after\n`````")); + assert.ok(body.startsWith("Plugin: 2.2.0\nQGIS: 4.2.2\n")); + }); +}); + +describe("fetch", () => { + const realFetch = globalThis.fetch; + afterEach(() => { + globalThis.fetch = realFetch; + }); + + it("files a GitHub issue and returns its number", async () => { + let sent; + globalThis.fetch = async (url, init) => { + sent = { url, init }; + return new Response(JSON.stringify({ number: 17 }), { status: 201 }); + }; + const env = { GITHUB_REPO: "owner/reports", GITHUB_TOKEN: "t" }; + + const response = await worker.fetch(post(valid), env); + + assert.equal(response.status, 201); + assert.deepEqual(await response.json(), { id: 17 }); + assert.equal(sent.url, "https://api.github.com/repos/owner/reports/issues"); + assert.equal(sent.init.headers.authorization, "Bearer t"); + assert.equal(JSON.parse(sent.init.body).title, "[report] Bug: ValueError"); + }); + + it("returns 502 when GitHub fails", async () => { + globalThis.fetch = async () => new Response("{}", { status: 500 }); + const response = await worker.fetch(post(valid), { GITHUB_REPO: "o/r", GITHUB_TOKEN: "t" }); + assert.equal(response.status, 502); + }); + + it("answers without calling GitHub in dry-run mode", async () => { + globalThis.fetch = async () => assert.fail("GitHub must not be called"); + const response = await worker.fetch(post(valid), { DRY_RUN: "1" }); + assert.equal(response.status, 201); + }); + + it("rejects wrong methods, types, sizes and bodies", async () => { + const env = { DRY_RUN: "1" }; + assert.equal((await worker.fetch(new Request("https://r.example.org/"), env)).status, 405); + assert.equal((await worker.fetch(post(valid, { "content-type": "text/plain" }), env)).status, 415); + const big = { ...valid, report: "x".repeat(MAX_BODY_BYTES) }; + assert.equal((await worker.fetch(post(big), env)).status, 413); + assert.equal((await worker.fetch(post("{not json"), env)).status, 400); + assert.equal((await worker.fetch(post({ ...valid, schema: 9 }), env)).status, 400); + }); + + it("rate-limits by a hashed client key", async () => { + const keys = []; + const env = { DRY_RUN: "1", REPORT_LIMITER: { limit: async ({ key }) => (keys.push(key), { success: false }) } }; + + const response = await worker.fetch(post(valid), env); + + assert.equal(response.status, 429); + assert.match(keys[0], /^[0-9a-f]{24}$/); + assert.ok(!keys[0].includes("203.0.113.7")); + }); +}); diff --git a/tools/report_worker/wrangler.toml b/tools/report_worker/wrangler.toml new file mode 100644 index 0000000..f937436 --- /dev/null +++ b/tools/report_worker/wrangler.toml @@ -0,0 +1,18 @@ +compatibility_date = "2026-09-01" +main = "src/index.js" +name = "parallelizer-reports" + +# Request logs would include client IP addresses; keep them off. +[observability] +enabled = false + +# Per-client limit: 5 reports per minute. Remove this block if your plan doesn't offer the binding (see README). +[[ratelimits]] +name = "REPORT_LIMITER" +namespace_id = "1001" +simple = {limit = 5, period = 60} + +[vars] +DRY_RUN = "0" +# "owner/repo" of the private repository that receives the reports. +GITHUB_REPO = "" From 1d0faeff2631b4ede6b31bca940d83616e12ea70 Mon Sep 17 00:00:00 2001 From: Andrii Liekariev <elfpkck@gmail.com> Date: Mon, 28 Sep 2026 01:48:39 +0300 Subject: [PATCH 2/2] Fix review findings in problem reports - Unticking "Include the geometries" removes the section by its heading, even after edits, and keeps the box ticked with a note when it can't. - Send report fits the report by the size of the JSON body actually sent. - Guard the kept operations with a lock: a Processing run finishes in its worker thread while the report may be built. - Convert recorded geometries to one CRS before shifting them to a local origin. - Correct the Worker comment on the body size check. --- CLAUDE.md | 2 +- PolygonsParallelToLine/src/diagnostics.py | 61 ++++++++++++++++----- PolygonsParallelToLine/src/map_tool.py | 21 ++++--- PolygonsParallelToLine/src/report_dialog.py | 30 +++++++++- PolygonsParallelToLine/src/report_sender.py | 26 +++++---- PolygonsParallelToLine/src/snapshot.py | 13 +++-- tests/test_diagnostics.py | 46 +++++++++++++++- tests/test_report_dialog.py | 25 +++++++++ tests/test_report_sender.py | 13 ++++- tools/report_worker/src/index.js | 3 +- 10 files changed, 196 insertions(+), 44 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 276971f..e73b633 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ Alongside the Processing algorithm, `Plugin.initGui` registers a dedicated "Para ## Diagnostics -`src/diagnostics.py` owns the plugin log (`logging.getLogger("parallelizer")`, set up in `Plugin.initProcessing`, torn down in `unload`): a handler to `QgsMessageLog` (tag `Parallelizer`) and a rotating file `<QGIS profile>/parallelizer/parallelizer.log`. `@capture_errors` wraps the map tool's Qt event handlers and `Algorithm.processAlgorithm`: it records unexpected exceptions (not `UserInputError`) and re-raises, and `diagnostics.notifier` makes `Plugin` offer "Report a problem…" in the message bar (queued, rate-limited). `ReportDialog` (`src/report_dialog.py`) shows `build_report()` for the user to edit, then copies it and opens a pre-filled GitHub issue (`issue_url`; `.github/ISSUE_TEMPLATE/bug_report.yml`, field `diagnostics`) or a `mailto:` link to `contact.REPORT_EMAIL` (`mailto_url`; empty in the repo and filled in from the `REPORT_EMAIL` secret by `release.yaml` only for the package, so never commit an address there; empty hides the email option; a much shorter length limit for mail clients); both go through `fit_report`, which drops the oldest log lines first. **Send report** (`src/report_sender.py`, `ReportSender`) POSTs a JSON payload (schema 1, report capped at `MAX_REPORT_BYTES`) through `QgsNetworkAccessManager` to `contact.REPORT_ENDPOINT` (filled from the `REPORT_ENDPOINT` secret like the email; empty hides the button). The receiver is `tools/report_worker/` (Cloudflare Worker, `node --test`), which files a private GitHub issue and must never store or log IPs or report content; the privacy promises it has to keep are in `site/privacy.md`. Nothing is ever sent automatically; keep it that way (GDPR/ePrivacy). Log calls must never include layer names, file paths, attribute values or coordinates; counts, geometry types, CRS authids, flags and versions are fine. `scrub()` is a best-effort second line of defense, not a license to log sensitive data. Raise `diagnostics.UserInputError` (a `QgsProcessingException`) for problems with the user's input so they are not recorded as bugs; a plain `QgsProcessingException` is recorded. Exception messages must not embed coordinates either. Each map tool release and each Processing run is a `diagnostics.operation(...)`; code inside adds sections via `current_operation()` using the describers in `src/snapshot.py` (fail-safe, no names/paths/coordinates). `compute_parallel_geometry`/`TargetRotator` take an optional `trace` dict for the rotation math; Processing passes none per feature and only re-runs the failing feature with a trace (`ParallelToReference.note_failed_feature`), keeping the hot loop free. The last `MAX_KEPT_OPERATIONS` non-idle operations are kept, plus the failed one. `setup_logging` also arms `faulthandler` into `crash.log` unless someone else owns it; QGIS usually does, writing to `<temp>/qgis-python-crash-info-<pid>` for its crash dialog, and the next session reads the newest unseen such file instead and map tool operations pass `watchdog=True` to dump stacks into `hang.log` after `HANG_TIMEOUT_S`; the next session moves both to `*-previous.log`, reports them and offers "Report a problem…" when they contain plugin frames. Geometries reach the report only through `geometry_block()`, shifted to a local origin, behind an opt-in checkbox in `ReportDialog`. +`src/diagnostics.py` owns the plugin log (`logging.getLogger("parallelizer")`, set up in `Plugin.initProcessing`, torn down in `unload`): a handler to `QgsMessageLog` (tag `Parallelizer`) and a rotating file `<QGIS profile>/parallelizer/parallelizer.log`. `@capture_errors` wraps the map tool's Qt event handlers and `Algorithm.processAlgorithm`: it records unexpected exceptions (not `UserInputError`) and re-raises, and `diagnostics.notifier` makes `Plugin` offer "Report a problem…" in the message bar (queued, rate-limited). `ReportDialog` (`src/report_dialog.py`) shows `build_report()` for the user to edit, then copies it and opens a pre-filled GitHub issue (`issue_url`; `.github/ISSUE_TEMPLATE/bug_report.yml`, field `diagnostics`) or a `mailto:` link to `contact.REPORT_EMAIL` (`mailto_url`; empty in the repo and filled in from the `REPORT_EMAIL` secret by `release.yaml` only for the package, so never commit an address there; empty hides the email option; a much shorter length limit for mail clients); both go through `fit_report`, which drops the oldest log lines first. **Send report** (`src/report_sender.py`, `ReportSender`) POSTs a JSON payload (schema 1, the encoded body capped at `MAX_PAYLOAD_BYTES`) through `QgsNetworkAccessManager` to `contact.REPORT_ENDPOINT` (filled from the `REPORT_ENDPOINT` secret like the email; empty hides the button). The receiver is `tools/report_worker/` (Cloudflare Worker, `node --test`), which files a private GitHub issue and must never store or log IPs or report content; the privacy promises it has to keep are in `site/privacy.md`. Nothing is ever sent automatically; keep it that way (GDPR/ePrivacy). Log calls must never include layer names, file paths, attribute values or coordinates; counts, geometry types, CRS authids, flags and versions are fine. `scrub()` is a best-effort second line of defense, not a license to log sensitive data. Raise `diagnostics.UserInputError` (a `QgsProcessingException`) for problems with the user's input so they are not recorded as bugs; a plain `QgsProcessingException` is recorded. Exception messages must not embed coordinates either. Each map tool release and each Processing run is a `diagnostics.operation(...)`; code inside adds sections via `current_operation()` using the describers in `src/snapshot.py` (fail-safe, no names/paths/coordinates). `compute_parallel_geometry`/`TargetRotator` take an optional `trace` dict for the rotation math; Processing passes none per feature and only re-runs the failing feature with a trace (`ParallelToReference.note_failed_feature`), keeping the hot loop free. The last `MAX_KEPT_OPERATIONS` non-idle operations are kept, plus the failed one. `setup_logging` also arms `faulthandler` into `crash.log` unless someone else owns it; QGIS usually does, writing to `<temp>/qgis-python-crash-info-<pid>` for its crash dialog, and the next session reads the newest unseen such file instead and map tool operations pass `watchdog=True` to dump stacks into `hang.log` after `HANG_TIMEOUT_S`; the next session moves both to `*-previous.log`, reports them and offers "Report a problem…" when they contain plugin frames. Geometries reach the report only through `geometry_block()`, converted to one CRS and shifted to a local origin, behind an opt-in checkbox in `ReportDialog`. ## Testing diff --git a/PolygonsParallelToLine/src/diagnostics.py b/PolygonsParallelToLine/src/diagnostics.py index 46a96c4..fcccf10 100644 --- a/PolygonsParallelToLine/src/diagnostics.py +++ b/PolygonsParallelToLine/src/diagnostics.py @@ -25,7 +25,17 @@ from typing import IO, TYPE_CHECKING, Any, Callable, TypeVar, cast from urllib.parse import quote, urlencode -from qgis.core import Qgis, QgsApplication, QgsGeometry, QgsMessageLog, QgsProcessingException, QgsProjUtils +from qgis.core import ( + Qgis, + QgsApplication, + QgsCoordinateReferenceSystem, + QgsCoordinateTransform, + QgsGeometry, + QgsMessageLog, + QgsProcessingException, + QgsProject, + QgsProjUtils, +) from qgis.PyQt.QtCore import ( # type: ignore[import-not-found] PYQT_VERSION_STR, QObject, @@ -107,6 +117,8 @@ class _State: default_factory=lambda: collections.deque(maxlen=MAX_KEPT_OPERATIONS) ) failed_operation: Operation | None = None + # Guards operations and failed_operation: a Processing run finishes in its worker thread. + lock: threading.Lock = dataclasses.field(default_factory=threading.Lock) dump_files: dict[str, IO[str]] = dataclasses.field(default_factory=dict) owns_faulthandler: bool = False # Dumps found at startup, i.e. written by the previous session: kind -> text. @@ -122,6 +134,8 @@ class Operation: sections: list[tuple[str, list[str] | dict[str, Any]]] = dataclasses.field(default_factory=list) # None stands for a geometry too large for the report; it is not kept in memory. geometries: list[tuple[str, QgsGeometry | None]] = dataclasses.field(default_factory=list) + # All geometries are kept in the CRS of the first one recorded with a CRS (layers can differ). + crs: QgsCoordinateReferenceSystem | None = None traced_features: int = 0 duration_s: float | None = None failed: bool = False @@ -140,11 +154,19 @@ def add_trace(self, title: str) -> dict[str, Any]: self.add(title, trace) return trace - def add_geometry(self, role: str, geom: QgsGeometry) -> None: + def add_geometry(self, role: str, geom: QgsGeometry, crs: QgsCoordinateReferenceSystem | None = None) -> None: if len(self.geometries) >= _MAX_OPERATION_GEOMETRIES or geom.isNull(): return - small = geom.constGet().nCoordinates() <= MAX_WKT_VERTICES - self.geometries.append((role, QgsGeometry(geom) if small else None)) + if geom.constGet().nCoordinates() > MAX_WKT_VERTICES: + self.geometries.append((role, None)) + return + kept = QgsGeometry(geom) + if crs is not None and crs.isValid(): + if self.crs is None: + self.crs = crs + else: + _transform(kept, crs, self.crs) + self.geometries.append((role, kept)) def report_lines(self, label: str) -> list[str]: status = "failed" if self.failed else "finished" @@ -275,8 +297,9 @@ def teardown_logging() -> None: _state.previous_dumps.clear() _state.last_error = None _state.last_error_type = None - _state.operations.clear() - _state.failed_operation = None + with _state.lock: + _state.operations.clear() + _state.failed_operation = None def log_file_path() -> Path | None: @@ -318,7 +341,8 @@ def operation(name: str, *, idle: bool = False, watchdog: bool = False) -> Itera yield op except Exception: op.failed = True - _state.failed_operation = op + with _state.lock: + _state.failed_operation = op raise finally: if hang_file is not None: @@ -326,7 +350,8 @@ def operation(name: str, *, idle: bool = False, watchdog: bool = False) -> Itera op.duration_s = time.perf_counter() - start _current.operation = None if op.failed or not op.idle: - _state.operations.appendleft(op) + with _state.lock: + _state.operations.appendleft(op) def current_operation() -> Operation: @@ -464,9 +489,11 @@ def previous_session_problem() -> str | None: def _listed_operations() -> list[tuple[str, Operation]]: """The failed operation (label "failed") first, then the other kept ones, newest first, labeled #1, #2...""" - failed = _state.failed_operation + with _state.lock: + failed = _state.failed_operation + operations = list(_state.operations) listed = [("failed", failed)] if failed is not None else [] - recent = [op for op in _state.operations if op is not failed] + recent = [op for op in operations if op is not failed] listed.extend((f"#{number}", op) for number, op in enumerate(recent, start=1)) return listed @@ -483,9 +510,10 @@ def geometry_block() -> str | None: listed = [(label, op) for label, op in _listed_operations() if op.geometries] if not listed: return None - kept = [(role, geom) for _, op in listed for role, geom in op.geometries if geom is not None] - first = next((geom for role, geom in kept if role.startswith("reference")), kept[0][1] if kept else None) - origin = next(first.vertices(), None) if first is not None else None + kept = [(role, geom, op.crs) for _, op in listed for role, geom in op.geometries if geom is not None] + first = next((item for item in kept if item[0].startswith("reference")), kept[0] if kept else None) + origin = next(first[1].vertices(), None) if first is not None else None + origin_crs = first[2] if first is not None else None lines = [GEOMETRY_SECTION] for label, op in listed: lines.append(f" {label}: {op.name}") @@ -494,11 +522,18 @@ def geometry_block() -> str | None: lines.append(f" {role}: omitted, more than {MAX_WKT_VERTICES} vertices; attach sample data instead") continue shifted = QgsGeometry(geom) + if op.crs is not None and origin_crs is not None: + _transform(shifted, op.crs, origin_crs) shifted.translate(-origin.x(), -origin.y()) lines.append(f" {role}: {shifted.asWkt(6)}") return "\n".join(lines) +def _transform(geom: QgsGeometry, source: QgsCoordinateReferenceSystem, dest: QgsCoordinateReferenceSystem) -> None: + if source != dest: + geom.transform(QgsCoordinateTransform(source, dest, QgsProject.instance())) + + def _indented(text: str | None) -> list[str]: if not text: return [" none"] diff --git a/PolygonsParallelToLine/src/map_tool.py b/PolygonsParallelToLine/src/map_tool.py index 779fc67..9b05f86 100644 --- a/PolygonsParallelToLine/src/map_tool.py +++ b/PolygonsParallelToLine/src/map_tool.py @@ -4,6 +4,7 @@ from qgis.core import ( Qgis, + QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsFeature, QgsFeatureRequest, @@ -223,8 +224,8 @@ def _rotate_target( if self.settings.pick_target_segment: target_segment = _closest_segment_of(feature.geometry(), click_layer) reference = self._reference_for_layer(layer) - trace = self._trace_feature(op, reference, feature) - op.add_geometry("click", QgsGeometry.fromPointXY(click_layer)) + trace = self._trace_feature(op, reference, feature, layer.crs()) + op.add_geometry("click", QgsGeometry.fromPointXY(click_layer), layer.crs()) rotated = compute_parallel_geometry( reference, feature.geometry(), @@ -343,7 +344,7 @@ def _rotate_layer_features( rect_geom = QgsGeometry.fromRect(layer_rect) if layer_rect is not None else None rect_center = layer_rect.center() if layer_rect is not None else None if rect_geom is not None and not any(role == "rectangle" for role, _ in op.geometries): - op.add_geometry("rectangle", rect_geom) + op.add_geometry("rectangle", rect_geom, layer.crs()) layer.beginEditCommand("Parallel to Line (bulk)") layer_rotated = 0 @@ -359,7 +360,7 @@ def _rotate_layer_features( kind, by_longest=self.settings.by_longest, target_segment=target_segment, - trace=self._trace_feature(op, reference, feature), + trace=self._trace_feature(op, reference, feature, layer.crs()), ) if rotated is None: continue @@ -421,7 +422,9 @@ def _begin(self, name: str, *, with_reference: bool = False) -> Operation: return op @staticmethod - def _trace_feature(op: Operation, reference: QgsGeometry, feature: QgsFeature) -> dict[str, Any] | None: + def _trace_feature( + op: Operation, reference: QgsGeometry, feature: QgsFeature, crs: QgsCoordinateReferenceSystem + ) -> dict[str, Any] | None: """Snapshot one target (up to MAX_TRACED_FEATURES per operation) and return its rotation trace.""" if op.traced_features >= MAX_TRACED_FEATURES: return None @@ -433,8 +436,8 @@ def _trace_feature(op: Operation, reference: QgsGeometry, feature: QgsFeature) - f"Target feature {op.traced_features}", [f"id: {feature.id()}", f"distance to reference: {distance:.6g}", *describe_geometry(geom)], ) - add_reference_geometry(op, reference, geom, label, distance) - op.add_geometry(label, geom) + add_reference_geometry(op, reference, geom, label, distance, crs) + op.add_geometry(label, geom, crs) return op.add_trace(f"Rotation math for target feature {op.traced_features}") def _set_reference(self, geom: QgsGeometry, layer: QgsVectorLayer, *, via: str, marker: QgsGeometry) -> None: @@ -446,8 +449,8 @@ def _set_reference(self, geom: QgsGeometry, layer: QgsVectorLayer, *, via: str, op = self._begin(f"map tool: set reference ({via})") for title, lines in self._reference_sections: op.add(title, lines) - add_reference_geometry(op, geom, marker, via, geom.distance(marker)) - op.add_geometry(via, marker) + add_reference_geometry(op, geom, marker, via, geom.distance(marker), source_crs) + op.add_geometry(via, marker, source_crs) self._clear_reference() reference = QgsGeometry(geom) map_crs = self.iface.mapCanvas().mapSettings().destinationCrs() diff --git a/PolygonsParallelToLine/src/report_dialog.py b/PolygonsParallelToLine/src/report_dialog.py index a03a439..844edc2 100644 --- a/PolygonsParallelToLine/src/report_dialog.py +++ b/PolygonsParallelToLine/src/report_dialog.py @@ -15,7 +15,7 @@ ) from . import contact -from .diagnostics import LOG_SECTION, issue_url, mailto_url, plugin_metadata +from .diagnostics import GEOMETRY_SECTION, LOG_SECTION, issue_url, mailto_url, plugin_metadata from .report_sender import ReportSender if TYPE_CHECKING: @@ -128,7 +128,16 @@ def _toggle_geometries(self, checked: bool) -> None: # noqa: FBT001 index = len(text) if index == -1 else index text = f"{text[:index]}{block}{text[index:]}" elif not checked: - text = text.replace(block, "") + removed = _without_geometry_section(text) + leftover = [line.strip() for line in self._geometry_block.splitlines()[1:] if line.strip()] + if removed is None and any(line in text for line in leftover): + # The header was edited away, so the section can't be found: never pretend it was removed. + self._show_status("Couldn't find the geometries to remove; delete them from the text yourself.") + self.geometry_checkbox.blockSignals(True) # noqa: FBT003 + self.geometry_checkbox.setChecked(True) + self.geometry_checkbox.blockSignals(False) # noqa: FBT003 + return + text = text if removed is None else removed self.text_edit.setPlainText(text) def report_text(self) -> str: @@ -164,3 +173,20 @@ def _open_with_full_copy(self, url: str) -> None: # Copied first, in case the link had to be truncated to fit its length limit. self.copy_to_clipboard() QDesktopServices.openUrl(QUrl.fromEncoded(url.encode("ascii"))) + + +def _without_geometry_section(text: str) -> str | None: + """``text`` without the geometry section (its header and the indented lines under it, edited or not).""" + lines = text.split("\n") + if GEOMETRY_SECTION not in lines: + return None + start = lines.index(GEOMETRY_SECTION) + end = start + 1 + while end < len(lines) and (not lines[end].strip() or lines[end].startswith(" ")): + end += 1 + while end > start + 1 and not lines[end - 1].strip(): + end -= 1 + # The blank line inserted before the section goes too. + if start > 0 and not lines[start - 1].strip(): + start -= 1 + return "\n".join(lines[:start] + lines[end:]) diff --git a/PolygonsParallelToLine/src/report_sender.py b/PolygonsParallelToLine/src/report_sender.py index 654fa59..21c20cc 100644 --- a/PolygonsParallelToLine/src/report_sender.py +++ b/PolygonsParallelToLine/src/report_sender.py @@ -17,23 +17,27 @@ from qgis.PyQt.QtNetwork import QNetworkReply # type: ignore[import-not-found] SCHEMA_VERSION = 1 -# The receiver rejects bodies over 64 KB; the rest of the payload is small. -MAX_REPORT_BYTES = 60_000 +# The whole JSON body, as sent: the receiver rejects bodies over 64 KB (MAX_BODY_BYTES in tools/report_worker). +MAX_PAYLOAD_BYTES = 60_000 TIMEOUT_MS = 15_000 _CREATED = 201 _TOO_MANY_REQUESTS = 429 def build_payload(report: str, title: str = "") -> bytes: - fitted = fit_report(report, lambda text: len(text.encode("utf-8")) <= MAX_REPORT_BYTES, MAX_REPORT_BYTES) - payload = { - "schema": SCHEMA_VERSION, - "title": title, - "plugin_version": plugin_metadata("version") or "unknown", - "qgis_version": Qgis.version(), - "report": fitted, - } - return json.dumps(payload).encode("utf-8") + """The report is fitted by its JSON-encoded size, since escaping line breaks, quotes and non-ASCII grows it.""" + + def encode(text: str) -> bytes: + payload = { + "schema": SCHEMA_VERSION, + "title": title, + "plugin_version": plugin_metadata("version") or "unknown", + "qgis_version": Qgis.version(), + "report": text, + } + return json.dumps(payload).encode("utf-8") + + return encode(fit_report(report, lambda text: len(encode(text)) <= MAX_PAYLOAD_BYTES, MAX_PAYLOAD_BYTES)) class ReportSender(QObject): diff --git a/PolygonsParallelToLine/src/snapshot.py b/PolygonsParallelToLine/src/snapshot.py index a3123f3..bc32d58 100644 --- a/PolygonsParallelToLine/src/snapshot.py +++ b/PolygonsParallelToLine/src/snapshot.py @@ -170,14 +170,19 @@ def reference_near(reference: QgsGeometry, target: QgsGeometry, distance: float) return reference if clipped.isNull() or clipped.isEmpty() else clipped -def add_reference_geometry( - op: Operation, reference: QgsGeometry, target: QgsGeometry, target_label: str, distance: float +def add_reference_geometry( # noqa: PLR0913 + op: Operation, + reference: QgsGeometry, + target: QgsGeometry, + target_label: str, + distance: float, + crs: QgsCoordinateReferenceSystem | None = None, ) -> None: """A small reference is added once as is; a large one is clipped around each target instead.""" if reference.isNull(): return if reference.constGet().nCoordinates() <= MAX_WKT_VERTICES: if not any(role == "reference" for role, _ in op.geometries): - op.add_geometry("reference", reference) + op.add_geometry("reference", reference, crs) else: - op.add_geometry(f"reference near {target_label}", reference_near(reference, target, distance)) + op.add_geometry(f"reference near {target_label}", reference_near(reference, target, distance), crs) diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 9a07231..ea46b59 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -3,11 +3,19 @@ import logging from logging.handlers import RotatingFileHandler from pathlib import Path +import threading from typing import NoReturn from urllib.parse import parse_qs, unquote, urlsplit import pytest -from qgis.core import Qgis, QgsApplication, QgsGeometry, QgsPointXY, QgsProcessingException +from qgis.core import ( + Qgis, + QgsApplication, + QgsCoordinateReferenceSystem, + QgsGeometry, + QgsPointXY, + QgsProcessingException, +) from PolygonsParallelToLine.src import contact, diagnostics from PolygonsParallelToLine.src.const import PLUGIN_DIR @@ -330,6 +338,42 @@ def test_geometry_block_uses_one_origin_across_operations(): assert " #2: set reference\n reference: LineString (0 0, 10 0)" in block +def test_geometry_block_converts_geometries_to_one_crs(): + wgs84 = QgsCoordinateReferenceSystem("EPSG:4326") + mercator = QgsCoordinateReferenceSystem("EPSG:3857") + with diagnostics.operation("set reference") as op: + op.add_geometry("reference", QgsGeometry.fromWkt("Point (0 0)"), mercator) + with diagnostics.operation("rotate") as op: + op.add_geometry("target 1", QgsGeometry.fromWkt("Point (1 0)"), wgs84) + op.add_geometry("target 2", QgsGeometry.fromWkt("Point (0 0)"), mercator) + + block = diagnostics.geometry_block() or "" + + # 1 degree of longitude at the equator is about 111 km in EPSG:3857. + assert "target 1: Point (111319.49" in block + assert "target 2: Point (0 0)" in block + + +def test_listing_operations_while_another_thread_finishes_them(): + errors: list[BaseException] = [] + + def finish_operations() -> None: + for number in range(2000): + with diagnostics.operation(f"op {number}"): + pass + + worker = threading.Thread(target=finish_operations) + worker.start() + while worker.is_alive(): + try: + diagnostics.build_report() + except RuntimeError as exc: # noqa: PERF203 + errors.append(exc) + worker.join() + + assert not errors + + def _write_dump(log_dir: Path, name: str, frame_file: str) -> None: log_dir.mkdir(parents=True, exist_ok=True) (log_dir / name).write_text( diff --git a/tests/test_report_dialog.py b/tests/test_report_dialog.py index 9c3bbd1..bccf26b 100644 --- a/tests/test_report_dialog.py +++ b/tests/test_report_dialog.py @@ -59,6 +59,31 @@ def test_geometry_checkbox_inserts_block_before_log_lines_and_removes_it(qgis_ap assert dialog.report_text() == report +def test_unticking_removes_an_edited_geometry_section(qgis_app): + report = f"Environment\n QGIS: 4.2\n\n{LOG_SECTION}\n line" + dialog = ReportDialog(report, geometries=lambda: f"{GEOMETRY_SECTION}\n #1: click\n target 1: Point (1 2)") + dialog.geometry_checkbox.setChecked(True) + dialog.text_edit.setPlainText(dialog.report_text().replace("Point (1 2)", "Point (1 3)")) + + dialog.geometry_checkbox.setChecked(False) + + assert dialog.report_text() == report + + +def test_unticking_keeps_the_box_ticked_when_the_section_cannot_be_found(qgis_app): + dialog = ReportDialog( + "Environment", geometries=lambda: f"{GEOMETRY_SECTION}\n #1: click\n target 1: Point (1 2)" + ) + dialog.geometry_checkbox.setChecked(True) + dialog.text_edit.setPlainText(dialog.report_text().replace(GEOMETRY_SECTION, "My shapes")) + + dialog.geometry_checkbox.setChecked(False) + + assert dialog.geometry_checkbox.isChecked() + assert "Point (1 2)" in dialog.report_text() + assert "Couldn't find the geometries" in dialog.status_label.text() + + def test_geometry_checkbox_disabled_without_geometries(qgis_app): dialog = ReportDialog("report") diff --git a/tests/test_report_sender.py b/tests/test_report_sender.py index 7f3fc36..c7e7a14 100644 --- a/tests/test_report_sender.py +++ b/tests/test_report_sender.py @@ -68,14 +68,23 @@ def test_payload_caps_the_report_dropping_oldest_log_lines(): log_lines = [f" log line {i:05d} " + "x" * 100 for i in range(2000)] report = "\n".join(["Environment", "", diagnostics.LOG_SECTION, *log_lines]) - fitted = json.loads(build_payload(report))["report"] + body = build_payload(report) + fitted = json.loads(body)["report"] - assert len(fitted.encode("utf-8")) <= report_sender.MAX_REPORT_BYTES + assert len(body) <= report_sender.MAX_PAYLOAD_BYTES assert diagnostics.TRUNCATED_NOTE in fitted assert "log line 01999" in fitted assert "log line 00000" not in fitted +def test_payload_size_counts_json_escaping(): + # Each line is 60 bytes as text but about 3x that once "é" becomes \u00e9 and quotes are escaped. + report = "\n".join(["Environment", "", diagnostics.LOG_SECTION, *[f' "{"é" * 29}"' for _ in range(1500)]]) + assert len(report.encode("utf-8")) < report_sender.MAX_PAYLOAD_BYTES * 2 + + assert len(build_payload(report)) <= report_sender.MAX_PAYLOAD_BYTES + + def test_send_posts_json_and_reports_the_reference(qgis_app, receiver): ok, message = _send("the report", "Bug: ValueError") diff --git a/tools/report_worker/src/index.js b/tools/report_worker/src/index.js index f9ebb7d..47db703 100644 --- a/tools/report_worker/src/index.js +++ b/tools/report_worker/src/index.js @@ -20,7 +20,8 @@ export default { if (!(request.headers.get("content-type") || "").startsWith("application/json")) { return json(415, { error: "send application/json" }); } - // Before reading the body: rejected clients cost no more than their headers. + // Before reading the body. A body without a Content-Length is still read in full before the size check; + // Cloudflare's own request size limit bounds it. // Optional: the binding may not exist on every plan (see README). if (env.REPORT_LIMITER) { const { success } = await env.REPORT_LIMITER.limit({ key: await rateLimitKey(request) });