diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..eaef747 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: 2026 CERN +# +# SPDX-License-Identifier: LGPL-2.1-or-later + +.git +.github +.gitlab-ci.yml +.venv +.pytest_cache +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ +coverage-html/ +coverage-report.xml +junit-report.xml +pts_reports/ +.idea/ +.vscode/ diff --git a/.github/RELEASE.md b/.github/RELEASE.md new file mode 100644 index 0000000..3cc2558 --- /dev/null +++ b/.github/RELEASE.md @@ -0,0 +1,30 @@ + + +# Release configuration + +Before creating the first release tag, a GitHub organization owner must create +two repository Environments: + +* `testpypi`, with no required reviewers; +* `pypi`, with required reviewers and deployment access restricted to protected + tags. + +Register trusted publishers in the TestPyPI and PyPI project settings using the +following values: + +| Setting | Value | +| --- | --- | +| Owner | `CERN` | +| Repository | `pts-framework` | +| Workflow | `release.yml` | +| Environment | `testpypi` or `pypi`, respectively | + +The project is published as `pts-framework`. Protect the `master` branch and +limit creation of `vX.Y.Z` tags to maintainers. A release tag first publishes its +artifact to TestPyPI. Install and smoke-test that version using the command in +the README, then approve the pending `pypi` Environment deployment to publish +the identical artifact to PyPI. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aaa0932 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 CERN +# +# SPDX-License-Identifier: LGPL-2.1-or-later + +name: CI + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Build the CI image + run: >- + docker build + --build-arg SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK=0.0+g${GITHUB_SHA::7} + --tag pts-framework-ci:${GITHUB_SHA} + . + + - name: Run tests + run: >- + docker run --rm pts-framework-ci:${GITHUB_SHA} + python -m pytest tests -vv --durations=20 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d4a6ee6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: 2026 CERN +# +# SPDX-License-Identifier: LGPL-2.1-or-later + +name: Release + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + build: + name: Test and build distributions + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out source + uses: actions/checkout@v6 + + - name: Validate and expose the release version + id: version + shell: bash + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Release tags must use the vX.Y.Z format." >&2 + exit 1 + fi + echo "value=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build the disposable CI image + run: >- + docker build + --build-arg SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK=${{ steps.version.outputs.value }} + --tag pts-framework-release:${{ github.sha }} + . + + - name: Run tests in the CI image + run: docker run --rm pts-framework-release:${{ github.sha }} + + - name: Build wheel and source distribution + run: >- + docker run --rm + --volume "${{ github.workspace }}/dist:/dist" + pts-framework-release:${{ github.sha }} + python -m build --outdir /dist + + - name: Store release distributions temporarily + uses: actions/upload-artifact@v4 + with: + name: release-dists + path: dist/ + if-no-files-found: error + retention-days: 30 + + publish-testpypi: + name: Publish to TestPyPI + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: testpypi + permissions: + id-token: write + steps: + - name: Download release distributions + uses: actions/download-artifact@v5 + with: + name: release-dists + path: dist/ + + - name: Publish distributions + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + name: Publish to PyPI + needs: publish-testpypi + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: pypi + permissions: + id-token: write + steps: + - name: Download release distributions + uses: actions/download-artifact@v5 + with: + name: release-dists + path: dist/ + + - name: Publish distributions + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5c639a7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2026 CERN +# +# SPDX-License-Identifier: LGPL-2.1-or-later + +ARG PYTHON_VERSION=3.13.14 +FROM python:${PYTHON_VERSION}-slim-trixie + +# PySide6 wheels include Qt itself. These runtime libraries are required for Qt's +# headless platform plugin while the test suite is running in the container. +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + libdbus-1-3 \ + libegl1 \ + libfontconfig1 \ + libglib2.0-0 \ + libgl1 \ + libxkbcommon0 \ + && rm -rf /var/lib/apt/lists/* + +ARG SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK=0+unknown +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + QT_QPA_PLATFORM=offscreen \ + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK=${SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK} + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY src ./src +COPY tests ./tests + +RUN python -m pip install --no-cache-dir --upgrade pip \ + && python -m pip install --no-cache-dir ".[test]" build + +CMD ["python", "-m", "pytest", "tests"] diff --git a/README.md b/README.md index 75a6015..a0eba05 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,28 @@ For full documentation, please visit [PTS Framework Documentation](https://acc-p ======= +## Continuous integration and releases + +The project is tested in a disposable Python 3.13 Docker image. To reproduce the +GitHub CI build locally, run: + +```sh +docker build --build-arg SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PTS_FRAMEWORK=0+local -t pts-framework-ci . +docker run --rm pts-framework-ci +``` + +Creating a protected `vX.Y.Z` tag runs the same tests, builds the wheel and source +distribution, and publishes them to TestPyPI. Verify the TestPyPI package in a clean +environment before approving the `pypi` GitHub Environment: + +```sh +python -m pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ pts-framework==X.Y.Z +``` + +Approval publishes the same distributions to PyPI. GitHub retains the release +artifact for 30 days; PyPI is the permanent package source. See +[release configuration](.github/RELEASE.md) for the one-time setup. + ## Licences pypts is distributed under the **LGPL-2.1-or-later** licence. diff --git a/pyproject.toml b/pyproject.toml index ac288d0..e2a0039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ authors = [ ] # Specify the minimum Python version required. It is not safe to use # this to control the *upper* version of Python. -requires-python = ">=3.11" +requires-python = ">=3.13" # The core dependencies of the project. dependencies = [ @@ -48,6 +48,7 @@ test = [ "pytest", "pytest-qt", "pytest-cov", + "pytest-timeout", ] doc = [ "Sphinx", @@ -96,6 +97,8 @@ features = [ [tool.pytest.ini_options] minversion = "7.0" addopts = "--strict-markers" +timeout = 90 +faulthandler_timeout = 60 testpaths = ["tests"] markers = [ "unit: unit tests", diff --git a/src/pypts/YamVIEW/recipe_creator.py b/src/pypts/YamVIEW/recipe_creator.py index fdc7313..837284f 100644 --- a/src/pypts/YamVIEW/recipe_creator.py +++ b/src/pypts/YamVIEW/recipe_creator.py @@ -71,7 +71,7 @@ def __init__(self): self.setup_status_and_layouts() self.toggle_dark_mode_action.setChecked(self.dark_mode) self.toggle_dark_mode(self.dark_mode, log_change=False) - install_system_theme_sync(QApplication.instance(), self._set_dark_mode) + self._disconnect_system_theme_sync = install_system_theme_sync(QApplication.instance(), self._set_dark_mode) # Define the shortcut activation action save_shortcut = QShortcut(QKeySequence("Ctrl+S"), self) @@ -79,6 +79,10 @@ def __init__(self): self.log("✅ Application started.") + def closeEvent(self, event): + self._disconnect_system_theme_sync() + super().closeEvent(event) + def setup_menu(self): menubar = self.menuBar() # File menu diff --git a/src/pypts/event_proxy.py b/src/pypts/event_proxy.py index bf8912a..ce3be0e 100644 --- a/src/pypts/event_proxy.py +++ b/src/pypts/event_proxy.py @@ -48,12 +48,23 @@ def __init__(self, event_q: SimpleQueue): self._test_package: str | None = None def RecipeEventProxyRunner(self): + """Fetch and process one event, stopping if the queue itself fails.""" try: event = self.event_q.get() - if event is None: # Sentinel to stop - self._running = False - return - + except Exception as e: + logger.error("Error retrieving RecipeEventProxy event: %s", e, exc_info=True) + self._running = False + return + + if event is None: # Sentinel to stop + self._running = False + return + + self._process_event(event) + + def _process_event(self, event): + """Transform one already-retrieved event and emit its Qt signal.""" + try: event_name, event_data = event logger.debug(f"Event Proxy received: {event_name}") logger.debug(f"Event data type: {type(event_data)}, length: {len(event_data) if hasattr(event_data, '__len__') else 'N/A'}") @@ -183,9 +194,9 @@ def RecipeEventProxyRunner(self): logger.warning(f"No dictionary created for event: {event_name}") except Exception as e: - logger.error(f"Error in RecipeEventProxy loop: {e}", exc_info=True) - # Depending on desired behavior, you might want to break or continue - # For robustness, we'll continue here + # A malformed event must not kill the proxy: the next queue read + # blocks normally and can still deliver valid recipe events. + logger.error(f"Error processing RecipeEventProxy event: {e}", exc_info=True) @Slot() def run(self): diff --git a/src/pypts/gui.py b/src/pypts/gui.py index 31b98fb..e2ee9a0 100644 --- a/src/pypts/gui.py +++ b/src/pypts/gui.py @@ -97,6 +97,8 @@ def __init__(self, return_content=True, binary=False): class MainWindow(QMainWindow): """The main application window, displaying recipe progress and results.""" + closing = Signal() + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -123,13 +125,22 @@ def __init__(self, *args, **kwargs): self._build_central() self._build_statusbar() self._apply_theme() - install_system_theme_sync(QApplication.instance(), self._set_dark_mode) + self._disconnect_system_theme_sync = install_system_theme_sync(QApplication.instance(), self._set_dark_mode) + self._close_notified = False self._switch_screen(SCREEN_IDLE) self.log_handler = TextEditLoggerHandler(self) self.log_handler.setFormatter(logging.Formatter("%(levelname)s : %(name)s : %(message)s")) self.log_handler.new_message.connect(self.log_text_box.append_line) + def closeEvent(self, event): + """Release application-wide signal connections before Qt closes us.""" + if not self._close_notified: + self._close_notified = True + self._disconnect_system_theme_sync() + self.closing.emit() + super().closeEvent(event) + def _build_menu(self): menu_bar = QMenuBar(self) self.setMenuBar(menu_bar) diff --git a/src/pypts/gui_theme.py b/src/pypts/gui_theme.py index 572762a..bbd8185 100644 --- a/src/pypts/gui_theme.py +++ b/src/pypts/gui_theme.py @@ -26,12 +26,29 @@ def detect_system_dark_mode(app=None) -> bool: return style_hints.colorScheme() == Qt.ColorScheme.Dark -def install_system_theme_sync(app, callback: Callable[[bool], None]) -> None: +def install_system_theme_sync(app, callback: Callable[[bool], None]) -> Callable[[], None]: + """Install system-theme synchronisation and return a disconnect callback.""" style_hints = _style_hints_for(app) if style_hints is None or not hasattr(style_hints, "colorSchemeChanged"): - return + return lambda: None - style_hints.colorSchemeChanged.connect(lambda scheme: callback(scheme == Qt.ColorScheme.Dark)) + def on_color_scheme_changed(scheme): + callback(scheme == Qt.ColorScheme.Dark) + + style_hints.colorSchemeChanged.connect(on_color_scheme_changed) + disconnected = False + + def disconnect() -> None: + nonlocal disconnected + if disconnected: + return + disconnected = True + try: + style_hints.colorSchemeChanged.disconnect(on_color_scheme_changed) + except (RuntimeError, TypeError): + pass + + return disconnect def get_theme_colors(dark: bool) -> dict[str, str]: diff --git a/src/pypts/recipes/RTM_recipe.yml b/src/pypts/recipes/RTM_recipe.yml index 1665a3e..606518c 100644 --- a/src/pypts/recipes/RTM_recipe.yml +++ b/src/pypts/recipes/RTM_recipe.yml @@ -7,7 +7,7 @@ recipe_version: 1.0.0 description: A sample recipe demonstrating how to call functions from example_tests.py main_sequence: Main continue_on_error: true -globals: +globals: {} --- sequence_name: Main description: The main sequence of steps for the example recipe. diff --git a/src/pypts/recipes/black_forest.yml b/src/pypts/recipes/black_forest.yml index f503199..459bbe6 100644 --- a/src/pypts/recipes/black_forest.yml +++ b/src/pypts/recipes/black_forest.yml @@ -8,7 +8,7 @@ recipe_version: 1.0.0 description: preparing the cake and checking the flavours main_sequence: my beautiful sequence continue_on_error: true -globals: +globals: {} --- sequence_name: my beautiful sequence description: this is the template recipe, that is used for testing the framework execution diff --git a/src/pypts/recipes/graph_testing.yml b/src/pypts/recipes/graph_testing.yml index 8bc56f2..88673b7 100644 --- a/src/pypts/recipes/graph_testing.yml +++ b/src/pypts/recipes/graph_testing.yml @@ -8,7 +8,7 @@ recipe_version: 1.0.0 description: satart acquisition and load the graph into the gui main_sequence: main continue_on_error: true -globals: +globals: {} --- sequence_name: main description: well, description diff --git a/src/pypts/recipes/simple_recipe.yml b/src/pypts/recipes/simple_recipe.yml index 2991dd2..6ebc1f9 100644 --- a/src/pypts/recipes/simple_recipe.yml +++ b/src/pypts/recipes/simple_recipe.yml @@ -8,7 +8,7 @@ description: A sample recipe demonstrating how to call functions from example_te main_sequence: Main report_name_include_serial: True continue_on_error: true -globals: +globals: {} --- sequence_name: Main description: The main sequence of steps for the example recipe. diff --git a/src/pypts/startup.py b/src/pypts/startup.py index ce6f13b..469a26d 100644 --- a/src/pypts/startup.py +++ b/src/pypts/startup.py @@ -50,15 +50,21 @@ def _on_start(): def _on_stop(): Runtime.stop_event.set() + cleaned_up = False + def _cleanup(): + nonlocal cleaned_up + if cleaned_up: + return + cleaned_up = True proxy.stop() - if recipe_thread.isRunning(): - recipe_thread.quit() - recipe_thread.wait(5000) + recipe_thread.quit() + recipe_thread.wait(5000) api.on_start = _on_start api.on_stop = _on_stop + window.closing.connect(_cleanup) app.aboutToQuit.connect(_cleanup) atexit.register(_cleanup) diff --git a/tests/unit_tests/test_a_gui.py b/tests/unit_tests/test_a_gui.py index a26ad8a..446859b 100644 --- a/tests/unit_tests/test_a_gui.py +++ b/tests/unit_tests/test_a_gui.py @@ -144,7 +144,7 @@ def test_show_message_arrow_keys_change_selected_button_and_enter_accepts(main_w qtbot.keyClick(panel.selected_button(), Qt.Key_Enter) - assert response_q.get() == "no" + assert response_q.get(timeout=5) == "no" assert panel.selected_button() is None assert main_window._screen_idx == gui.SCREEN_IDLE @@ -203,15 +203,23 @@ def test_toggle_theme_keeps_prompt_image(main_window, qtbot, tmp_path): def test_create_and_start_gui_shows_window(qapp): api = Mock() api.input_queue = SimpleQueue() + api.event_queue = SimpleQueue() + proxy = Mock() + thread = Mock() - with patch("pypts.startup.QApplication", return_value=qapp): + with patch("pypts.startup.QApplication", return_value=qapp), \ + patch("pypts.startup.RecipeEventProxy", return_value=proxy), \ + patch("pypts.startup.QThread", return_value=thread): window, app = create_and_start_gui(api) - try: - assert app is qapp - assert window.isVisible() - finally: - window.close() + assert app is qapp + assert window.isVisible() + + window.close() + + proxy.stop.assert_called_once_with() + thread.quit.assert_called_once_with() + thread.wait.assert_called_once_with(5000) def test_on_start_clicked_switches_to_running_tab_before_queue_start(main_window, monkeypatch): @@ -245,7 +253,7 @@ def fake_load_recipe(): assert load_states == [gui.SCREEN_IDLE] assert main_window._screen_idx == gui.SCREEN_RUNNING - assert start_queue.get() == ("START",) + assert start_queue.get(timeout=5) == ("START",) def test_update_sequence_shows_ready_to_start_when_loaded_not_running(main_window, sample_sequence): diff --git a/tests/unit_tests/test_event_proxy.py b/tests/unit_tests/test_event_proxy.py index 6e23285..df82e96 100644 --- a/tests/unit_tests/test_event_proxy.py +++ b/tests/unit_tests/test_event_proxy.py @@ -328,3 +328,17 @@ def test_none_sentinel_stops_proxy(self, proxy, event_q): event_q.put(None) proxy.run_once() assert proxy._running is False + + +def test_queue_get_error_stops_proxy_without_retrying(caplog): + """A failed queue read is terminal, avoiding a busy retry loop.""" + event_q = MagicMock() + event_q.get.side_effect = RuntimeError("broken queue") + proxy = RecipeEventProxy(event_q) + + with caplog.at_level("ERROR"): + proxy.run_once() + + assert proxy._running is False + event_q.get.assert_called_once_with() + assert "Error retrieving RecipeEventProxy event" in caplog.text diff --git a/tests/unit_tests/test_gui_theme.py b/tests/unit_tests/test_gui_theme.py index d40bee3..3e73bc3 100644 --- a/tests/unit_tests/test_gui_theme.py +++ b/tests/unit_tests/test_gui_theme.py @@ -22,6 +22,9 @@ def __init__(self): def connect(self, callback): self.callbacks.append(callback) + def disconnect(self, callback): + self.callbacks.remove(callback) + class _FakeStyleHints: def __init__(self, scheme): @@ -56,13 +59,16 @@ def test_install_system_theme_sync_invokes_callback_for_changes(): app = _FakeApp(Qt.ColorScheme.Light) callback = Mock() - install_system_theme_sync(app, callback) + disconnect = install_system_theme_sync(app, callback) assert len(app.styleHints().colorSchemeChanged.callbacks) == 1 app.styleHints().colorSchemeChanged.callbacks[0](Qt.ColorScheme.Dark) callback.assert_called_once_with(True) + disconnect() + assert app.styleHints().colorSchemeChanged.callbacks == [] + def test_main_window_uses_detected_system_theme(qtbot): with patch("pypts.gui.detect_system_dark_mode", return_value=True): diff --git a/tests/unit_tests/test_report.py b/tests/unit_tests/test_report.py index b459e6b..f516339 100644 --- a/tests/unit_tests/test_report.py +++ b/tests/unit_tests/test_report.py @@ -95,7 +95,7 @@ def test_creates_report_file(self, tmp_path): "-t", str(timestamp) ] - result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, timeout=30) print("STDOUT:", result.stdout) print("STDERR:", result.stderr) @@ -111,7 +111,7 @@ def test_report_content(self, tmp_path): assert report_py_path.exists() command = [sys.executable, str(report_py_path), "-o", str(output_dir), "-t", str(timestamp)] - result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False) + result = subprocess.run(command, cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, timeout=30) print("STDOUT:", result.stdout) print("STDERR:", result.stderr) assert result.returncode == 0, "Script execution failed"