diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..62fe7d7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,42 @@ +name: Bug report +description: Report a reproducible PACE Controller problem +title: "[Bug]: " +labels: [bug] +body: + - type: textarea + attributes: + label: What happened? + description: Describe the exact actions, connection type, and observed result. + validations: + required: true + - type: textarea + attributes: + label: Exact error message and log + description: Paste the error and the relevant diagnostic-log lines. Remove sensitive network information if necessary. + validations: + required: true + - type: textarea + attributes: + label: Expected behaviour + validations: + required: true + - type: input + attributes: + label: PACE Controller version and operating system + placeholder: "For example: 1.0.1, Windows 11" + validations: + required: true + - type: input + attributes: + label: Instrument and connection + placeholder: "For example: PACE 5000, Ethernet 192.168.10.2:5025" + validations: + required: true + - type: checkboxes + attributes: + label: Safety and privacy + options: + - label: I reproduced the issue on a safe, unloaded, low-pressure setup. + required: true + - label: I reviewed every attachment for confidential laboratory or network data. + required: true diff --git a/.github/scripts/prepare_cross_platform_release.py b/.github/scripts/prepare_cross_platform_release.py index 1f4df6f..1fc2dfb 100644 --- a/.github/scripts/prepare_cross_platform_release.py +++ b/.github/scripts/prepare_cross_platform_release.py @@ -21,6 +21,18 @@ LEGACY = ROOT / "PACE_Controller.ps1" LEGACY_HASH = "aa6ffe5431dfab7d2ea998f9b59e8ac5163b0e3478e84a3c15e2e826fb356b8e" SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") +VERSION_BADGE_RE = re.compile( + r"^\[!\[(?:Latest release|Version)\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M +) +DOI_BADGE_RE = re.compile(r"^\[!\[DOI\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M) +VERSION_BADGE = ( + "[![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)]" + "(https://github.com/SebRoLENS/pace-controller/releases/latest)" +) +DOI_PENDING_BADGE = ( + "[![DOI](https://img.shields.io/badge/DOI-pending-lightgrey)]" + "(https://github.com/SebRoLENS/pace-controller/releases/latest)" +) def version_tuple(version: str) -> tuple[int, int, int]: @@ -76,6 +88,41 @@ def assert_legacy_unchanged() -> None: ) +def replace_section(text: str, heading: str, next_heading: str, body: str) -> str: + pattern = re.compile( + rf"(?ms)^{re.escape(heading)}\n.*?(?=^{re.escape(next_heading)}\n)" + ) + if not pattern.search(text): + raise SystemExit(f"Could not find README section {heading!r}") + return pattern.sub(body.rstrip() + "\n\n", text, count=1) + + +def update_pending_doi_metadata(version: str) -> None: + text = ROOT_README.read_text(encoding="utf-8") + if not VERSION_BADGE_RE.search(text): + raise SystemExit("Could not find README version badge") + text = VERSION_BADGE_RE.sub(VERSION_BADGE, text, count=1) + if DOI_BADGE_RE.search(text): + text = DOI_BADGE_RE.sub(DOI_PENDING_BADGE, text, count=1) + else: + text = text.replace(VERSION_BADGE, VERSION_BADGE + "\n" + DOI_PENDING_BADGE, 1) + citation = f"""## Citation + +If PACE Controller contributes to published work, cite the exact version used. +GitHub also provides a **Cite this repository** entry from [`CITATION.cff`](CITATION.cff). + +Version **{version}** will be archived by the Zenodo GitHub integration. The +version DOI will then be inserted here automatically. + +> Romi, S. (2026). *PACE Controller* (Version {version}) [Computer software]. +> GitHub. https://github.com/SebRoLENS/pace-controller/releases/tag/v{version} +""" + ROOT_README.write_text( + replace_section(text, "## Citation", "## License and independence", citation), + encoding="utf-8", + ) + + def update_version(new: str) -> None: replace_one(INIT, r'^__version__\s*=\s*"[^"]+"$', f'__version__ = "{new}"', "package version") replace_one(PYPROJECT, r'^version\s*=\s*"[^"]+"$', f'version = "{new}"', "project version") @@ -86,6 +133,9 @@ def update_version(new: str) -> None: replace_one(CITATION, r'^version: ".*"$', f'version: "{new}"', "citation version") replace_one(CITATION, r'^url: ".*"$', f'url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v{new}"', "citation URL") replace_one(CITATION, r'^date-released: .*$', f'date-released: {dt.date.today().isoformat()}', "citation date") + cff = re.sub(r"^doi:\s*.*\n", "", CITATION.read_text(encoding="utf-8"), flags=re.M) + CITATION.write_text(cff, encoding="utf-8") + update_pending_doi_metadata(new) def update_changelog(new: str) -> None: diff --git a/.github/scripts/sync_zenodo_doi.py b/.github/scripts/sync_zenodo_doi.py new file mode 100644 index 0000000..c24e54d --- /dev/null +++ b/.github/scripts/sync_zenodo_doi.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Find a PACE Controller release DOI and synchronise repository metadata.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +VERSION_FILE = ROOT / "cross_platform" / "src" / "pace_controller" / "__init__.py" +README = ROOT / "README.md" +CITATION = ROOT / "CITATION.cff" + +VERSION_RE = re.compile(r'^__version__\s*=\s*"(\d+\.\d+\.\d+)"', re.M) +VERSION_BADGE_RE = re.compile( + r"^\[!\[(?:Latest release|Version)\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M +) +DOI_BADGE_RE = re.compile(r"^\[!\[DOI\]\([^)]+\)\]\([^)]+\)[ \t]*$", re.M) +VERSION_BADGE = ( + "[![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)]" + "(https://github.com/SebRoLENS/pace-controller/releases/latest)" +) +VALID_TITLES = { + "pace controller", + "pace-controller", + "pace controller: cross-platform pressure controller for druck pace 5000/6000", +} + + +def current_version() -> str: + match = VERSION_RE.search(VERSION_FILE.read_text(encoding="utf-8")) + if not match: + raise SystemExit("Could not find __version__") + return match.group(1) + + +def version_matches(value: object, wanted: str) -> bool: + text = str(value or "").strip() + return text in {wanted, f"v{wanted}"} + + +def extract_doi(record: dict) -> str | None: + pids = record.get("pids") or {} + doi = pids.get("doi") if isinstance(pids, dict) else None + if isinstance(doi, dict) and doi.get("identifier"): + return str(doi["identifier"]) + if isinstance(doi, str): + return doi + if record.get("doi"): + return str(record["doi"]) + metadata = record.get("metadata") or {} + return str(metadata["doi"]) if metadata.get("doi") else None + + +def zenodo_records(query: str) -> list[dict]: + params = urllib.parse.urlencode({"q": query, "size": 25}) + request = urllib.request.Request( + f"https://zenodo.org/api/records?{params}", + headers={"Accept": "application/json", "User-Agent": "pace-controller-release-bot/0.1"}, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", "replace").strip() + raise RuntimeError(f"Zenodo API returned HTTP {exc.code}: {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"Zenodo API request failed: {exc}") from exc + return ((payload.get("hits") or {}).get("hits") or []) + + +def find_doi(version: str) -> str | None: + candidates: list[dict] = [] + seen: set[str] = set() + last_error: RuntimeError | None = None + for query in ('"PACE Controller"', f'"PACE Controller" AND "{version}"', version): + try: + records = zenodo_records(query) + except RuntimeError as exc: + last_error = exc + continue + for record in records: + identifier = str(record.get("id") or "") + if identifier and identifier in seen: + continue + seen.add(identifier) + metadata = record.get("metadata") or {} + if str(metadata.get("title", "")).strip().lower() not in VALID_TITLES: + continue + if version_matches(metadata.get("version"), version) and extract_doi(record): + candidates.append(record) + if not candidates: + if last_error is not None: + raise last_error + return None + candidates.sort( + key=lambda record: str(record.get("updated") or record.get("created") or ""), + reverse=True, + ) + return extract_doi(candidates[0]) + + +def replace_section(text: str, heading: str, next_heading: str, body: str) -> str: + pattern = re.compile( + rf"(?ms)^{re.escape(heading)}\n.*?(?=^{re.escape(next_heading)}\n)" + ) + if not pattern.search(text): + raise SystemExit(f"Could not find README section {heading!r}") + return pattern.sub(body.rstrip() + "\n\n", text, count=1) + + +def apply_metadata(version: str, doi: str) -> None: + doi_url = f"https://doi.org/{doi}" + doi_badge = f"[![DOI](https://zenodo.org/badge/DOI/{doi}.svg)]({doi_url})" + + readme = README.read_text(encoding="utf-8") + if not VERSION_BADGE_RE.search(readme): + raise SystemExit("Could not find README version badge") + readme = VERSION_BADGE_RE.sub(VERSION_BADGE, readme, count=1) + if DOI_BADGE_RE.search(readme): + readme = DOI_BADGE_RE.sub(doi_badge, readme, count=1) + else: + readme = readme.replace(VERSION_BADGE, VERSION_BADGE + "\n" + doi_badge, 1) + citation = f"""## Citation + +If PACE Controller contributes to published work, cite the exact version used. +GitHub also provides a **Cite this repository** entry from [`CITATION.cff`](CITATION.cff). + +> Romi, S. (2026). *PACE Controller* (Version {version}) +> [Computer software]. Zenodo. {doi_url} + +DOI: [**{doi}**]({doi_url}) +""" + README.write_text( + replace_section(readme, "## Citation", "## License and independence", citation), + encoding="utf-8", + ) + + cff = CITATION.read_text(encoding="utf-8") + cff = re.sub(r"^doi:\s*.*\n", "", cff, flags=re.M) + cff = re.sub(r'^version:\s*.*$', f'version: "{version}"', cff, flags=re.M) + cff = re.sub(r'^url:\s*.*$', f'url: "{doi_url}"', cff, flags=re.M) + lines = cff.splitlines() + repository_index = next( + (index + 1 for index, line in enumerate(lines) if line.startswith("repository-code:")), + None, + ) + if repository_index is None: + raise SystemExit("Could not find repository-code in CITATION.cff") + lines.insert(repository_index, f'doi: "{doi}"') + CITATION.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--version") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + version = args.version or current_version() + try: + doi = find_doi(version) + except RuntimeError as exc: + print(str(exc), file=sys.stderr) + raise SystemExit(3) from exc + if not doi: + print(f"Zenodo DOI for v{version} not found yet.", file=sys.stderr) + raise SystemExit(2) + if args.apply: + apply_metadata(version, doi) + print(doi) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/cross-platform-release.yml b/.github/workflows/cross-platform-release.yml index 7deb348..25213ea 100644 --- a/.github/workflows/cross-platform-release.yml +++ b/.github/workflows/cross-platform-release.yml @@ -11,6 +11,7 @@ on: permissions: contents: write + actions: write concurrency: group: pace-controller-cross-platform-release @@ -294,6 +295,8 @@ jobs: - frozen legacy PowerShell/WinForms v0.3.1 retained unchanged. **Safety:** validate the software at low pressure on an unloaded setup. Software interlocks do not replace hardware pressure protections or operator supervision. + + **Zenodo DOI:** pending archival or repository integration. EOF gh release create "v${VERSION}" release-assets/* \ --repo "$GITHUB_REPOSITORY" \ @@ -301,3 +304,13 @@ jobs: --title "PACE Controller v${VERSION}" \ --notes-file /tmp/release-notes.md \ --verify-tag + + - name: Dispatch Zenodo DOI sync + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + gh workflow run sync-zenodo-doi.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref main \ + -f "version=${VERSION}" diff --git a/.github/workflows/sync-zenodo-doi.yml b/.github/workflows/sync-zenodo-doi.yml new file mode 100644 index 0000000..8da99d8 --- /dev/null +++ b/.github/workflows/sync-zenodo-doi.yml @@ -0,0 +1,108 @@ +name: Sync Zenodo DOI + +on: + workflow_dispatch: + inputs: + version: + description: Release version to synchronise (for example 1.0.1) + required: true + type: string + +permissions: + contents: write + +concurrency: + group: zenodo-doi-sync-${{ inputs.version }} + cancel-in-progress: false + +jobs: + sync-doi: + runs-on: ubuntu-latest + timeout-minutes: 75 + steps: + - uses: actions/checkout@v6 + with: + ref: main + fetch-depth: 1 + + - name: Resolve release version + id: version + env: + REQUESTED_VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${REQUESTED_VERSION#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid release version: $VERSION" + exit 1 + fi + gh release view "v${VERSION}" --repo "$GITHUB_REPOSITORY" >/dev/null + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Synchronising Zenodo DOI for PACE Controller v$VERSION" + + - name: Wait for Zenodo archival + id: zenodo + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + DOI="" + for attempt in $(seq 1 120); do + set +e + DOI="$(python3 .github/scripts/sync_zenodo_doi.py --version "$VERSION" 2>/tmp/zenodo-error.txt)" + STATUS=$? + set -e + if [ "$STATUS" -eq 0 ] && [ -n "$DOI" ]; then + echo "Found Zenodo DOI $DOI for v$VERSION" + break + fi + if [ "$attempt" -eq 1 ] || [ $((attempt % 10)) -eq 0 ]; then + echo "Zenodo record not available yet (attempt $attempt/120)." + cat /tmp/zenodo-error.txt || true + fi + sleep 30 + done + if [ -z "$DOI" ]; then + echo "Zenodo DOI for v$VERSION was not found within one hour." + exit 1 + fi + echo "doi=$DOI" >> "$GITHUB_OUTPUT" + + - name: Apply DOI metadata + env: + VERSION: ${{ steps.version.outputs.version }} + DOI: ${{ steps.zenodo.outputs.doi }} + run: | + git fetch origin main + git reset --hard origin/main + python3 .github/scripts/sync_zenodo_doi.py --version "$VERSION" --apply >/dev/null + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add README.md CITATION.cff + if ! git diff --cached --quiet; then + git commit -m "Link v${VERSION} to Zenodo DOI ${DOI} [skip release]" + git push origin HEAD:main + fi + + - name: Add DOI to GitHub release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + DOI: ${{ steps.zenodo.outputs.doi }} + run: | + gh release view "v${VERSION}" --repo "$GITHUB_REPOSITORY" --json body --jq '.body' > /tmp/release-notes.md + python3 - <<'PY' + import os + import re + from pathlib import Path + + path = Path('/tmp/release-notes.md') + text = path.read_text(encoding='utf-8') + line = f"**Zenodo DOI:** https://doi.org/{os.environ['DOI']}" + updated, count = re.subn(r"(?m)^\*\*Zenodo DOI:\*\*.*$", line, text, count=1) + if count == 0: + updated = text.rstrip() + "\n\n" + line + "\n" + path.write_text(updated, encoding='utf-8') + PY + gh release edit "v${VERSION}" \ + --repo "$GITHUB_REPOSITORY" \ + --notes-file /tmp/release-notes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c01f847..de6451b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes are documented here. The project follows semantic versioning - Ignored an empty line-feed fragment when a CRLF instrument reply is split across TCP packets. - Added a loopback TCP regression test for the complete connection handshake. +- Added a bilingual Help menu, author/affiliation acknowledgements, and a + direct GitHub issue-reporting action with robust frozen-Linux link opening. +- Added automatic post-release Zenodo DOI discovery and metadata synchronisation. ## [1.0.0] - 2026-08-28 diff --git a/CITATION.cff b/CITATION.cff index 6c132e6..47b52da 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -4,7 +4,9 @@ title: "PACE Controller" type: software authors: - family-names: Romi - given-names: S. + given-names: Sebastiano + affiliation: "European Laboratory for Non-Linear Spectroscopy (LENS), University of Florence (UNIFI)" + email: romi@lens.unifi.it orcid: "https://orcid.org/0000-0002-9553-7788" repository-code: "https://github.com/SebRoLENS/pace-controller" url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.1" diff --git a/README.md b/README.md index 6d09aae..fbf0b16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # PACE Controller [![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)](https://github.com/SebRoLENS/pace-controller/releases/latest) +[![DOI](https://img.shields.io/badge/DOI-pending-lightgrey)](https://github.com/SebRoLENS/pace-controller/releases/latest) [![Windows](https://img.shields.io/badge/Windows-10%2F11-0078D4?logo=windows)](https://github.com/SebRoLENS/pace-controller/releases/latest) [![Linux](https://img.shields.io/badge/Linux-x86__64-FCC624?logo=linux&logoColor=black)](https://github.com/SebRoLENS/pace-controller/releases/latest) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) @@ -52,6 +53,10 @@ No NI-VISA, Druck USB driver, Python, LabVIEW, or Internet connection is require - Automatic MEASURE attempt on required-telemetry loss during CONTROL. - Full-precision CSV telemetry and diagnostic logs. - Hardware-free simulator and automated screenshot tests. +- Bilingual Help menu with institutional acknowledgements and direct GitHub + issue reporting. +- Automatic Zenodo DOI discovery and citation-metadata synchronisation after + release archival. ## PACE configuration @@ -100,6 +105,17 @@ python -m pace_controller --simulate python -m pytest ``` +## Citation + +If PACE Controller contributes to published work, cite the exact version used. +GitHub also provides a **Cite this repository** entry from [`CITATION.cff`](CITATION.cff). + +Version **1.0.1** will be archived by the Zenodo GitHub integration. The +version DOI will then be inserted here automatically. + +> Romi, S. (2026). *PACE Controller* (Version 1.0.1) [Computer software]. +> GitHub. https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.1 + ## License and independence MIT License. See [LICENSE](LICENSE). diff --git a/cross_platform/README.md b/cross_platform/README.md index 6028b72..19215be 100644 --- a/cross_platform/README.md +++ b/cross_platform/README.md @@ -51,6 +51,9 @@ Ethernet and RS-232 use the same SCPI control engine and the same safety checks. - Permanent sample-side and inlet-side leak indicators with editable thresholds. - CSV telemetry and diagnostic logs at full received precision. - Offline simulator for hardware-free validation. +- Bilingual Help menu with author/affiliation acknowledgements and a direct + link to the public GitHub issue tracker. +- Automatic Zenodo DOI synchronisation after each archived GitHub release. ## Software protections diff --git a/cross_platform/docs/PACE_Controller_Manual.md b/cross_platform/docs/PACE_Controller_Manual.md index c47e830..e971710 100644 --- a/cross_platform/docs/PACE_Controller_Manual.md +++ b/cross_platform/docs/PACE_Controller_Manual.md @@ -1,6 +1,6 @@ --- title: "PACE Controller - Cross-Platform User and Technical Manual" -author: "S. Romi" +author: "Sebastiano Romi - LENS, University of Florence (UNIFI)" date: "Version 1.0.1 - 2026" geometry: margin=2.2cm colorlinks: true @@ -174,6 +174,11 @@ The software: Press **Disconnect** to stop automation, request MEASURE, close the communication channel, and restore temporary network configuration. +The bilingual **Help** menu contains **Report a problem**, which opens the +public GitHub issue tracker, and **About PACE Controller**, which shows the +version, author, institutional affiliation, contact email, project link, and +independence notice. + ## 7. Main display ![Real interface generated in offline simulator mode](pace_controller_gui.png){ width=100% } @@ -386,4 +391,11 @@ The complete source and workflows are included in the repository. Binaries remai PACE Controller is independent software and is not an official Druck product. It is released under the MIT License. +### Author and contact + +- Sebastiano Romi +- European Laboratory for Non-Linear Spectroscopy (LENS) +- University of Florence (UNIFI) +- + Development used AI-assisted programming. Validate the application on a safe, unloaded, low-pressure setup and report unexpected behaviour with the logfile, PACE model, module, firmware version, connection type, and exact reproduction steps. diff --git a/cross_platform/pyproject.toml b/cross_platform/pyproject.toml index 2ffc9c4..a19f7b1 100644 --- a/cross_platform/pyproject.toml +++ b/cross_platform/pyproject.toml @@ -9,7 +9,7 @@ description = "Cross-platform Ethernet and RS-232 controller for classic Druck P readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } -authors = [{ name = "S. Romi" }] +authors = [{ name = "Sebastiano Romi", email = "romi@lens.unifi.it" }] dependencies = [ "PySide6==6.8.3", "pyserial==3.5", @@ -26,4 +26,3 @@ where = ["src"] [tool.pytest.ini_options] testpaths = ["tests"] - diff --git a/cross_platform/src/pace_controller/external.py b/cross_platform/src/pace_controller/external.py new file mode 100644 index 0000000..32aed9f --- /dev/null +++ b/cross_platform/src/pace_controller/external.py @@ -0,0 +1,51 @@ +"""Open host files and URLs without leaking frozen-app libraries.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from collections.abc import Mapping + + +_BUNDLED_ENVIRONMENT_KEYS = ( + "APPDIR", + "APPIMAGE", + "ARGV0", + "LD_PRELOAD", + "QML2_IMPORT_PATH", + "QML_IMPORT_PATH", + "QT_PLUGIN_PATH", + "QT_QPA_PLATFORM_PLUGIN_PATH", + "_MEIPASS2", +) + + +def host_environment(environment: Mapping[str, str] | None = None) -> dict[str, str]: + """Restore the environment that existed before the frozen app started.""" + result = dict(os.environ if environment is None else environment) + original_library_path = result.pop("LD_LIBRARY_PATH_ORIG", None) + if original_library_path: + result["LD_LIBRARY_PATH"] = original_library_path + else: + result.pop("LD_LIBRARY_PATH", None) + for key in _BUNDLED_ENVIRONMENT_KEYS: + result.pop(key, None) + return result + + +def open_with_host_application(target: str) -> bool: + """Launch the desktop opener with a clean environment on Linux.""" + if not sys.platform.startswith("linux"): + return False + environment = host_environment() + command = shutil.which("xdg-open", path=environment.get("PATH")) + if command is None: + return False + subprocess.Popen( + [command, target], + env=environment, + start_new_session=True, + ) + return True diff --git a/cross_platform/src/pace_controller/i18n.py b/cross_platform/src/pace_controller/i18n.py index 3887653..2bb8e70 100644 --- a/cross_platform/src/pace_controller/i18n.py +++ b/cross_platform/src/pace_controller/i18n.py @@ -124,6 +124,15 @@ "routine_loaded": "Routine loaded: {path}", "settings_saved": "Settings saved.", "offline_preview": "OFFLINE UI PREVIEW - simulated values, no commands sent", + "help": "&Help", + "report_problem": "Report a problem", + "about": "About PACE Controller", + "about_tagline": "Open-source graphical controller for classic Druck PACE 5000/6000 instruments.", + "author_acknowledgements": "Author and acknowledgements", + "about_independent": "Independent software released under the MIT License. PACE Controller is not an official Druck product.", + "project_updates": "Project and updates", + "open_link_title": "Open link", + "open_link_error": "PACE Controller could not open this item automatically. Copy it into your browser or file manager:\n\n{target}", }, "it": { "window_title": "Controllore di pressione PACE {version}", @@ -245,6 +254,15 @@ "routine_loaded": "Routine caricata: {path}", "settings_saved": "Impostazioni salvate.", "offline_preview": "ANTEPRIMA UI OFFLINE - valori simulati, nessun comando inviato", + "help": "&Aiuto", + "report_problem": "Segnala un problema", + "about": "Informazioni su PACE Controller", + "about_tagline": "Controllore grafico open source per gli strumenti Druck PACE 5000/6000 classici.", + "author_acknowledgements": "Autore e riconoscimenti", + "about_independent": "Software indipendente distribuito con licenza MIT. PACE Controller non è un prodotto ufficiale Druck.", + "project_updates": "Progetto e aggiornamenti", + "open_link_title": "Apri collegamento", + "open_link_error": "PACE Controller non è riuscito ad aprire automaticamente questo elemento. Copialo nel browser o nel file manager:\n\n{target}", }, } diff --git a/cross_platform/src/pace_controller/ui.py b/cross_platform/src/pace_controller/ui.py index 69da0b3..44cd4e3 100644 --- a/cross_platform/src/pace_controller/ui.py +++ b/cross_platform/src/pace_controller/ui.py @@ -6,15 +6,18 @@ import math import os import platform +from collections.abc import Callable from pathlib import Path from PySide6.QtCore import Qt, QTimer, QUrl -from PySide6.QtGui import QColor, QDesktopServices, QFont, QPainter, QPen +from PySide6.QtGui import QAction, QColor, QDesktopServices, QFont, QPainter, QPen from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, + QDialog, + QDialogButtonBox, QFileDialog, QFormLayout, QFrame, @@ -40,6 +43,7 @@ ) from . import __version__ +from .external import open_with_host_application from .i18n import Translator from .leak import LeakAssessment, LeakMonitor from .models import ( @@ -77,6 +81,9 @@ QTextEdit { background: #17212b; color: #d7e5ef; font-family: "Cascadia Mono", monospace; } """ +REPOSITORY_URL = "https://github.com/SebRoLENS/pace-controller" +ISSUES_URL = f"{REPOSITORY_URL}/issues" + class LockButton(QToolButton): def __init__(self) -> None: @@ -168,6 +175,43 @@ def set_level(self, level: str, text: str) -> None: self.value.setText(text) +class AboutDialog(QDialog): + def __init__( + self, + translator: Translator, + open_target: Callable[[str | Path], None], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self.setWindowTitle(translator("about")) + self.setMinimumWidth(480) + layout = QVBoxLayout(self) + text = QLabel( + f"

PACE Controller {__version__}

" + f"

{translator('about_tagline')}

" + f"

{translator('author_acknowledgements')}

" + "

Sebastiano Romi
" + "European Laboratory for Non-Linear Spectroscopy (LENS)
" + "University of Florence (UNIFI)
" + 'romi@lens.unifi.it

' + f"

{translator('about_independent')}

" + f'

{translator("project_updates")}

' + ) + text.setOpenExternalLinks(False) + text.linkActivated.connect(open_target) + text.setAlignment(Qt.AlignCenter) + text.setWordWrap(True) + layout.addWidget(text) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + report_button = buttons.addButton( + translator("report_problem"), QDialogButtonBox.ButtonRole.ActionRole + ) + report_button.clicked.connect(lambda: open_target(ISSUES_URL)) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + class MainWindow(QMainWindow): def __init__( self, @@ -193,6 +237,7 @@ def __init__( self.setMinimumSize(1080, 760) self.resize(1280, 900) self.setStyleSheet(APP_STYLE) + self._build_help_menu() self._build_ui() self._connect_signals() self.refresh_serial_ports() @@ -212,6 +257,23 @@ def __init__( QTimer.singleShot(100, self.request_connect) QTimer.singleShot(2200, self.capture_screenshot) + def _build_help_menu(self) -> None: + self.help_menu = self.menuBar().addMenu("") + self.report_action = QAction(self) + self.report_action.triggered.connect(lambda: self._open_external(ISSUES_URL)) + self.about_action = QAction(self) + self.about_action.triggered.connect(self.show_about) + self.help_menu.addAction(self.report_action) + self.help_menu.addSeparator() + self.help_menu.addAction(self.about_action) + self._localized.extend( + [ + (self.help_menu, "help"), + (self.report_action, "report_problem"), + (self.about_action, "about"), + ] + ) + def _build_ui(self) -> None: central = QWidget() root = QVBoxLayout(central) @@ -649,7 +711,7 @@ def _connect_signals(self) -> None: self.stop_routine_button.clicked.connect(self.service.stop_and_measure) self.save_settings_button.clicked.connect(self.save_leak_settings) self.open_data_button.clicked.connect( - lambda: QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_directory()))) + lambda: self._open_external(data_directory()) ) self.vent_button.clicked.connect(self.start_vent) @@ -661,6 +723,27 @@ def _connect_signals(self) -> None: self.service.alarm.connect(self.on_alarm) self.service.busy_changed.connect(self.set_busy) + def _open_external(self, target: str | Path) -> None: + value = str(target) + try: + opened = open_with_host_application(value) + if not opened: + url = QUrl.fromLocalFile(value) if isinstance(target, Path) else QUrl(value) + opened = QDesktopServices.openUrl(url) + if opened: + return + except OSError as exc: + if hasattr(self, "log_view"): + self.log_view.append(f"External opener failed: {exc}") + QMessageBox.warning( + self, + self.t("open_link_title"), + self.t("open_link_error", target=value), + ) + + def show_about(self) -> None: + AboutDialog(self.t, self._open_external, self).exec() + def _load_settings_into_ui(self) -> None: config = self.settings.connection self.language_combo.setCurrentIndex(1 if self.settings.language == "it" else 0) diff --git a/cross_platform/tests/test_core.py b/cross_platform/tests/test_core.py index 82f495d..8e39d18 100644 --- a/cross_platform/tests/test_core.py +++ b/cross_platform/tests/test_core.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import importlib.util import socket import threading import time @@ -9,6 +10,7 @@ import pytest from pace_controller import __version__ +from pace_controller.external import host_environment from pace_controller.i18n import STRINGS from pace_controller.leak import LeakMonitor from pace_controller.models import LeakThresholds @@ -40,6 +42,75 @@ def test_translations_have_identical_keys() -> None: assert set(STRINGS["en"]) == set(STRINGS["it"]) +def test_frozen_environment_is_removed_before_opening_host_links() -> None: + cleaned = host_environment( + { + "PATH": "/usr/bin", + "LD_LIBRARY_PATH": "/tmp/frozen", + "LD_LIBRARY_PATH_ORIG": "/usr/lib", + "QT_PLUGIN_PATH": "/tmp/plugins", + "APPIMAGE": "/tmp/PACE.AppImage", + } + ) + assert cleaned["PATH"] == "/usr/bin" + assert cleaned["LD_LIBRARY_PATH"] == "/usr/lib" + assert "LD_LIBRARY_PATH_ORIG" not in cleaned + assert "QT_PLUGIN_PATH" not in cleaned + assert "APPIMAGE" not in cleaned + + +def test_zenodo_sync_applies_doi_metadata(tmp_path: Path) -> None: + script_path = ROOT / ".github" / "scripts" / "sync_zenodo_doi.py" + spec = importlib.util.spec_from_file_location("pace_zenodo_sync", script_path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + readme = tmp_path / "README.md" + citation = tmp_path / "CITATION.cff" + readme.write_text( + "\n".join( + [ + "# PACE Controller", + "", + "[![Version](https://img.shields.io/github/v/release/SebRoLENS/pace-controller)](https://github.com/SebRoLENS/pace-controller/releases/latest)", + "[![DOI](https://img.shields.io/badge/DOI-pending-lightgrey)](https://github.com/SebRoLENS/pace-controller/releases/latest)", + "", + "## Citation", + "", + "Pending.", + "", + "## License and independence", + "", + "MIT", + ] + ) + + "\n", + encoding="utf-8", + ) + citation.write_text( + "\n".join( + [ + "cff-version: 1.2.0", + 'version: "1.0.1"', + 'repository-code: "https://github.com/SebRoLENS/pace-controller"', + 'url: "https://github.com/SebRoLENS/pace-controller/releases/tag/v1.0.1"', + ] + ) + + "\n", + encoding="utf-8", + ) + module.README = readme + module.CITATION = citation + module.apply_metadata("1.0.1", "10.5281/zenodo.12345678") + + updated_readme = readme.read_text(encoding="utf-8") + updated_citation = citation.read_text(encoding="utf-8") + assert "https://doi.org/10.5281/zenodo.12345678" in updated_readme + assert 'doi: "10.5281/zenodo.12345678"' in updated_citation + assert 'url: "https://doi.org/10.5281/zenodo.12345678"' in updated_citation + + def test_scpi_parsing_and_formatting() -> None: assert scpi_number(":SENS1:PRES 2.500000E+01") == 25.0 assert scpi_numbers('0,"No error"') == [0.0] diff --git a/cross_platform/tests/test_project.py b/cross_platform/tests/test_project.py index c1b318f..ecd1045 100644 --- a/cross_platform/tests/test_project.py +++ b/cross_platform/tests/test_project.py @@ -17,6 +17,7 @@ def test_release_and_documentation_files_exist() -> None: "src/pace_controller/main.py", "src/pace_controller/service.py", "src/pace_controller/transports.py", + "src/pace_controller/external.py", "pace_controller_launcher.py", "scripts/build_windows.ps1", "scripts/build_linux.sh", @@ -36,9 +37,29 @@ def test_required_cross_platform_features_are_present() -> None: assert "source_margin_rearm_bar: float = 2.2" in service assert 'f"{value:.3f} {unit}"' in ui assert "LockButton" in ui + assert "ISSUES_URL" in ui + assert "AboutDialog" in ui + assert "romi@lens.unifi.it" in ui def test_frozen_legacy_hash_is_declared() -> None: assert (ROOT / "LEGACY_SHA256.txt").read_text(encoding="utf-8").strip().startswith( "aa6ffe5431dfab7d2ea998f9b59e8ac5163b0e3478e84a3c15e2e826fb356b8e" ) + + +def test_repository_integrations_are_present() -> None: + repository = ROOT.parent + expected = [ + ".github/ISSUE_TEMPLATE/bug_report.yml", + ".github/scripts/sync_zenodo_doi.py", + ".github/workflows/sync-zenodo-doi.yml", + ] + for relative in expected: + assert (repository / relative).is_file(), relative + + release = (repository / ".github/workflows/cross-platform-release.yml").read_text( + encoding="utf-8" + ) + assert "Dispatch Zenodo DOI sync" in release + assert "actions: write" in release